Add a reading-time component to your site with a small TypeScript Plugin. Start with a working PathMX project.
The same files, commands, and expected results work for people and coding agents. Use the Plugin API reference for signatures and context contracts; this guide walks through a complete local implementation.
If you only need reusable Markdown and styling, start with an authored component. Use a TypeScript Plugin for computed output, browser behavior, domain operations, or integration with another system.
Install the schema library in your project root:
bun add zod
Create plugins/reading.plugin.ts, beside your paths/ folder:
import { defineComponent, html } from "@pathmx/core"
import { z } from "zod"
export default defineComponent({
tag: "x-reading-time",
props: z.object({ words: z.number().int().positive() }),
render({ props }) {
const minutes = Math.max(1, Math.ceil(props.words / 200))
return html`<span>${minutes} min read</span>`
},
})
The definition names the authored tag directly. Core converts ordinary HTML attributes according to the schema and validates typed bindings without coercing their values. The renderer receives validated props and returns escaped HTML. This component needs no compilation hook, loader or browser client.
Add the component to paths/index.md:
# A short guide
<x-reading-time words="650" />
Start with one useful idea.
Validate the authored use, then start development:
bun run pmx lint
bun run pmx dev
The page shows 4 min read. Change words to 1000 and save to see
5 min read. Run bun run pmx lint to check authored uses; a missing or
nonpositive words value reports an invalid component attribute.
This component works in static exports because its output depends only on public authored attributes.
A Plugin can also serve HTTP endpoints without contributing a component.
Create plugins/catalog.plugin.ts beside paths/:
import { definePlugin, route } from "@pathmx/core"
export default definePlugin({
id: "catalog",
routes: [
route.get("status", () => Response.json({ ok: true })),
route.get("pages", async (ctx) => {
const view = ctx.view(await ctx.session())
const pages = await view.query("sources", { from: "/", limit: 20 })
return Response.json(pages, {
headers: { "Cache-Control": "private, no-store" },
})
}),
],
})
The Plugin id supplies the /api/catalog/ mount. Its two routes are
GET /api/catalog/status and GET /api/catalog/pages. The first returns a
public health response; the second returns up to 20 Sources readable by the
request's visitor. Anonymous requests use an anonymous View. With an installed
credential provider, ctx.session() resolves that provider's session.
Run bun run pmx lint, start bun run pmx dev, then use the origin printed by
the server. For a server on port 3000:
curl -i http://localhost:3000/api/catalog/status
curl -i http://localhost:3000/api/catalog/pages
curl -I http://localhost:3000/api/catalog/status
curl -i -X POST http://localhost:3000/api/catalog/status
Expect 200 and {"ok":true} for status, 200 and a JSON array for pages,
200 with no body for HEAD, and 405 for the unsupported POST. If no Sources
are readable, the pages result is []. Edit the response and save to verify
that development reloads the route code.
route.post, put, patch, delete, options, and all use the same
(path, handler) shape. Read a JSON body with await ctx.request.json() and
validate it before use. ctx.url contains query parameters; ctx.path is
relative to the Plugin mount for namespaced handlers. A leading / declares an
application-public path and makes ctx.path the pathname. Paths match exactly;
namespaced handlers can end in /* to match descendants.
Handlers return a standard Response or a Promise of one.
Routes run before automatic credential resolution. Calling ctx.session()
resolves identity; the handler still decides what that caller may do. Use
ctx.view(await ctx.session()) for visitor reads and handle denied access
explicitly. Never return ctx.sources.all() to the browser: it exposes trusted
repository reads without visitor filtering.
For user-initiated writes, define an Action so input,
availability, bounded effects, and transaction results follow the shared contract.
Integration routes can call await ctx.mutate(async tx => { ... }), but that is
an unscoped server capability. Verify the integration credential or webhook
signature and authorize the operation before opening the transaction. Keep
credentials in server configuration, outside browser modules and authored output.
Use route.document when an endpoint depends only on admitted Sources and its
public location. In the catalog Plugin, replace the status handler with:
route.document("status", {
contentType: "application/json",
render: () => JSON.stringify({ ok: true }),
})
This declaration serves GET /api/catalog/status live and publishes the same
JSON during static export. No separate publish registration is needed. Document
renderers receive ctx.view and ctx.location; they return a body, and Core
handles the response. Application-wide documents use anonymous reads, even for a
signed-in visitor; a subject-selected observation
retains its admitted View and restrictions. The viewer-dependent pages handler still needs a live host.
Use sourceRoutes(source) with route.redirect for aliases derived from authored
Sources. Core keeps these claims current when Sources change, checks admission,
and publishes public aliases as redirect pages. See the complete
Source redirect example,
HTTP route API, and
publication API.
PathMX discovers plugins/name.plugin.ts and
plugins/name/index.plugin.ts entrypoints. A directory entrypoint can import
nearby components, styles, and helpers. Keep dependencies in the project's root
package.json.
An entrypoint default-exports a component, a Plugin, a zero-argument factory, or a
definePreset([...]) composition of those contributions. Use a factory when a
Plugin owns mutable state or resources so each app gets its own instance.
Installed package Plugins are activated through
the root manifest.
Repository entrypoints add custom behavior or configure a package; a repository
Plugin with the same id replaces that configured Plugin. Within the repository,
duplicate ids are errors. requires names Plugin ids that must be installed
earlier; it does not install them.
Component renderers receive validated attributes in props and rendered children
in slots. Keep compile repeatable and independent of the viewer. Read
viewer-specific state in load through view; follow the
component lifecycle
for slots and live updates.
A Plugin can also contribute validated Actions,
queries, Source and Block schemas, routes, and lifecycle hooks. Initialize owned
resources in setup and release them in close; host code must await
app.close(). Use the provided contexts for Source access and mutations.
Keep Bun or filesystem integration at the host boundary
when the same Plugin needs to run in a browser host.
For a queried browser app with a static fallback, follow the React catalog example.
For a complete configured composition, see Assessment.
Repository-wide View bindings
use the Plugin's bindings contribution to export unique Source/query descriptors.
Domain queries compose admitted reads through context.view; credentials and
operational database records remain separate from View state.
A component without client contributes no browser code. With
client: moduleRef(() => import("./client.ts")), PathMX builds and caches the
client once per Engine generation and loads it only when that component is rendered
for the current visitor. Unselected Source-read branches contribute no client assets.
Keep authored preparation in compile and visitor-specific reads in load;
the compiled Program remains reusable across visitors.
For an optional panel, point client at a small launcher. Native controls stay
available in the rendered HTML, and ordinary dynamic imports defer the panel and
its dependencies. PathMX loads the panel’s imported CSS before the import resolves.
No asset manifest or separate frontend build is needed.
For example, create plugins/tools.plugin.ts:
import { defineComponent, moduleRef } from "@pathmx/core"
export default defineComponent({
tag: "x-tools-trigger",
render: () => '<button type="button" class="pmx-button">Tools</button>',
client: moduleRef(() => import("./tools.launcher.ts")),
})
Create plugins/tools.launcher.ts:
import { deferredControl } from "@pathmx/core/controls/browser"
import type { ComponentClientContext } from "@pathmx/core"
const tools = deferredControl("Tools", () => import("./tools.panel.ts"))
export default function mount({ element, signal }: ComponentClientContext<unknown>) {
element.addEventListener("click", () => {
void tools.activate(element, ({ openTools }) => openTools(element),
() => !signal.aborted && element.isConnected)
}, { signal })
}
Create plugins/tools.panel.ts and its stylesheet:
import { nativeModal } from "@pathmx/core/controls/browser"
import "./tools.css"
let modal: ReturnType<typeof nativeModal> | undefined
export function openTools(trigger: HTMLElement) {
if (!modal) {
const dialog = document.createElement("dialog")
dialog.className = "tools-dialog"
dialog.setAttribute("aria-label", "Tools")
const close = document.createElement("button")
close.type = "button"
close.textContent = "Done"
close.addEventListener("click", () => modal!.close())
dialog.append("Your tools are ready. ", close)
document.body.append(dialog)
modal = nativeModal(dialog)
}
modal.open(trigger)
}
/* plugins/tools.css */
.tools-dialog {
color: var(--pmx-color-fg, CanvasText);
background: var(--pmx-color-surface, Canvas);
padding: 1.5rem;
}
Author <x-tools-trigger />. Only the launcher arrives with the page. The first
activation downloads the panel and its stylesheet, then opens it; later activations
reuse them. deferredControl handles busy feedback, duplicate activations, Escape,
navigation cancellation, and an explicit reload-to-retry path for failed modules.
Its label identifies one control lifecycle; reuse that control for its triggers.
The activation callback runs only if the trigger remains connected. A third
activate argument can check additional feature availability, and its callback
receives an AbortSignal for any further asynchronous opening work.
The same bundle paths work on a live server and in static export. Browser-hosted publications select entries at render time too; their complete offline installation still downloads the publication’s artifacts so later interactions work offline. Module code is public static code: keep secrets and authorization in server Actions.
A repository Plugin can attach a bundled client to each document. Create
plugins/counter/index.plugin.ts:
import { definePlugin } from "@pathmx/core"
export default definePlugin({
id: "counter",
async documentAssets(ctx) {
await ctx.addBrowserBundle(new URL("./client.ts", import.meta.url), "counter")
},
})
Create plugins/counter/client.ts:
import { mountPathMXBehavior } from "@pathmx/core/controls/browser"
mountPathMXBehavior("[data-counter]", (root, { signal }) => {
let count = 0
const update = () => {
const output = root.querySelector("output")
if (output) output.value = String(count)
}
root.addEventListener("click", (event) => {
if (!(event.target instanceof Element) || !event.target.closest("button")) return
count += 1
update()
}, { signal })
return { update }
})
Create paths/counter.page.md:
# Count your practice rounds
<div data-counter>
<button type="button">Count</button>
<output>0</output>
</div>
Run bun run dev, open /counter.page, and click Count twice: the output is 2.
Edit the heading while that page stays open; the existing root retains its count.
Remove the counter root and its listener is disposed. Reloading starts at 0;
this example does not promise persistent work.
documentAssets owns site-wide delivery; mountPathMXBehavior owns each matching
root's lifetime. For a client needed only by one contributed component, use
React's component-owned client
or a literate component's js fence. See Plugin configuration
when another project needs to install your configured package.
A Plugin's lint hook reports authoring problems. Add this hook to the
reading Plugin above, alongside id and components; run bun run check to
see missing-description warnings. Use the optional third argument for severity
and machine-readable details:
import type { Plugin } from "@pathmx/core"
const lint: NonNullable<Plugin["lint"]> = (report, app) => {
for (const source of app.sources.all()) {
if (!source.data.description) {
report(source, "This Source has no description.", {
severity: "warning",
code: "description.missing",
hint: "Add a short description to the Source frontmatter.",
})
}
}
}
severity is error (the default) or warning. code, hint, and block must be
nonempty strings. Only report line and column when you have actual file
coordinates; task/list line numbers may be relative to a Block. Invalid metadata
fails the lint hook explicitly. Identical reports are deduplicated, while reports
at different Blocks or file positions remain separate.
For an expected indexing or compilation failure, use
app.invalid(source, message, { code, hint, block }). It supports the same optional
details except severity: it always stops that operation with an error that lint
can collect. Let unexpected programming errors propagate.
Run these commands from the consuming project root:
bun run pmx plugins list
bun run pmx lint --format json
bun run typecheck
A clean lint result is []. Use the project's TypeScript check when it provides
one; lint validates authored Sources and does not replace TypeScript checking.
Open the homepage and verify 650 produces 4 min read, then 1000 produces
5 min read after saving. Temporarily use words="0", confirm lint reports
the invalid attribute, and restore the valid value. No client JavaScript is
needed for this component.
For interactive Plugins, also verify keyboard activation, focus, navigation away
and back, and removal of the component during a live edit. For viewer-specific
reads, check both anonymous and admitted visitors. If you promise static support,
run bun run pmx export --help, export the project, and inspect its output.
When sharing beyond this repository, follow Plugin configuration to create a package with an exported entrypoint, activation metadata, and a compatible Core peer dependency. Include imported browser and CSS files in the package. Install it into a clean consuming project and repeat the same checks.
Document the authored tag, accepted attributes, defaults, expected output, required capabilities, and verification commands together. Link the API reference for shared contracts so people and agents use one maintained source.
| Symptom | Check |
|---|---|
| Plugin does not load | Put the entrypoint beside paths/, under plugins/, use a discovered filename, and provide a default export. |
| Component is unknown | Match tag: "x-reading-time" to <x-reading-time> and confirm its Plugin is loaded. |
| Attribute validation fails | Use the schema’s expected types; Core converts HTML attributes and preserves typed binding values. |
| Dependency or duplicate id error | requires names earlier Plugin ids; it does not install packages. Give independent Plugins unique ids. |
| Viewer state leaks between renders | Keep visitor reads in load through view; never cache them in compile or shared Plugin state. |
| Client state or listeners disappear | Use the browser lifecycle helpers or React integration and check retained and removed roots. |
A Plugin's displays map declares presentations selected by Block display
topmatter. Each display uses the Plugin's component named
x-{plugin-id}-group-{display-name}. Core compiles member Blocks independently
and supplies their evaluated content as named slots; the display only arranges it.
import { defineComponent, definePlugin, fragment, html } from "@pathmx/core"
import { z } from "zod"
export default function CardsPlugin() {
return definePlugin({
id: "cards",
displays: { cards: {} },
components: [defineComponent({
tag: "x-cards-group-cards",
props: z.object({ instance: z.string() }),
render({ props, slots }) {
return html`<div id="${props.instance}" class="cards">
${Object.values(slots).filter(Boolean).map(content =>
html`<article>${fragment(content)}</article>`
)}
</div>`
},
})],
})
}
Author group: examples and display: cards on the opening Block, then
group: examples on its consecutive members. Component props contain source
(the owning Source ID), group (the authored group name), instance (the placement
ID), and members (a JSON list of Block IDs). Slots are named member-0, member-1,
and so on, preserving that order. A conditional member supplies empty content when
inactive. Keep the remaining members' original sections intact.
A display's optional validate(block, source) returns an authoring-error message
or undefined. It runs against authored data. Viewer-specific labels or metadata
belong in the component's load hook, using view.readSource(props.source);
its renderer receives the selected data. Never fetch or render member
bodies again. Duplicate display names and missing compiled definitions are rejected.
Declare an optional client: moduleRef(() => import("./client.ts")) on the
component definition. Its module loads only on pages using that display, just as
for other native components. Whole-Source includes scope generated IDs and their
references independently; including one Block preserves its linear content.
For custom markup from a trusted HTML-producing helper, fragment(markup) from
@pathmx/core marks it as HTML for composition. Ordinary strings interpolated
into html are escaped; never mark untrusted text as a fragment.
Displays own their interaction and fallback behavior. Tabs opts into linear
Player flow with data-pmx-play-linear on its root and data-pmx-group-panel on
panel wrappers; its client restores ordinary panels when Player starts. Use the
same convention only for displays whose members should become sequential Player
Blocks. Keep content in its original member sections so canonical links and live
updates continue to work.