Make a custom Plugin

Add a reading-time component to your site with a small TypeScript Plugin. Start with a working PathMX project.

Choose your extension

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.

Create the component

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.

Use it in Markdown

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.

Add server routes

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.

Handle requests and protect writes

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.

Choose live or static behavior

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.

Organize and compose Plugins

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.

Add behavior when needed

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.

Load optional UI on first use

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.

Add a browser module

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.

Report authoring issues

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.

Verify and share the Plugin

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.

Troubleshooting

SymptomCheck
Plugin does not loadPut the entrypoint beside paths/, under plugins/, use a discovered filename, and provide a default export.
Component is unknownMatch tag: "x-reading-time" to <x-reading-time> and confirm its Plugin is loaded.
Attribute validation failsUse the schema’s expected types; Core converts HTML attributes and preserves typed binding values.
Dependency or duplicate id errorrequires names earlier Plugin ids; it does not install packages. Give independent Plugins unique ids.
Viewer state leaks between rendersKeep visitor reads in load through view; never cache them in compile or shared Plugin state.
Client state or listeners disappearUse the browser lifecycle helpers or React integration and check retained and removed roots.

Add a Block display

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.