[@layout]: ../docs.layout.md

# Make a custom Plugin

Add a reading-time component to your site with a small TypeScript Plugin.
Start with a working [PathMX project](../start/first-site.guide.md).

---

## Choose your extension

The same files, commands, and expected results work for people and coding agents.
Use the [Plugin API reference](./plugins.reference.md) 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](../authoring/components.reference.md). 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:

```sh
bun add zod
```

Create `plugins/reading.plugin.ts`, beside your `paths/` folder:

```ts
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`:

```md
# A short guide

<x-reading-time words="650" />

Start with one useful idea.
```

Validate the authored use, then start development:

```sh
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/`:

```ts
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:

```sh
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](./actions.reference.md) 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:

```ts
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](./views.reference.md#review-another-actors-saved-work)
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](./plugins.reference#source-owned-redirects),
[HTTP route API](./plugins.reference.md#http-routes), and
[publication API](./plugins.reference.md#assets-transforms-and-publication).

---

## 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](../packages.reference.md#install-and-activate).
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](../authoring/components.reference.md#plugin-components)
for slots and live updates.

A Plugin can also contribute validated [Actions](./actions.reference.md),
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](./core.reference.md#portable-composition)
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](./react.reference.md#mount-a-source-backed-app).

For a complete configured composition, see [Assessment](../learning/assessment.reference.md).

Repository-wide [View bindings](../authoring/queries.reference.md#repository-wide-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`:

```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`:

```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:

```ts
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)
}
```

```css
/* 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`:

```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`:

```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`:

```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](./react.reference.md#mount-a-source-backed-app)
or a literate component's `js` fence. See [Plugin configuration](./plugin-configuration.reference.md)
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:

```ts
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:

```sh
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](./plugin-configuration.reference.md#define-a-configurable-package)
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

| 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](../authoring/components.reference.md#plugin-components) or [React integration](./react.reference.md) 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.

```ts
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.
