Components

Components add reusable elements to Markdown. Define templates in a .components.md file, or contribute components from a TypeScript Plugin.

For an interactive site tool, use a local Plugin and place its Component in a .page.md Source. This keeps routing, layouts, access-filtered data and live updates together. A standalone .html file is served verbatim: it does not receive Markdown link rewriting, Source layout, or Component validation. Local entrypoints such as plugins/tool.plugin.ts and plugins/tool/index.plugin.ts are discovered beside the project's paths/ folder.

<x-navigation-toc max-depth="2" />

Markdown components

Markdown components draw on literate programming: write a document that explains the component, with working code interleaved in the order that helps readers understand it. Describe what a component does, define its markup, and explain its styling or behavior alongside the relevant code fences. The file serves as both an implementation and a guide to using it.

A *.components.md file holds one Block per component. PathMX uses the Block's html, css, and js fences as its implementation. Prose and examples make the component file readable as a page.

Create paths/cards.components.md. This lesson card explains its slots before defining the template:

<!-- componentName: lesson-card -->

# Lesson card

Group a lesson title and its instructions in a card. The named `title` slot
holds the heading; the default slot holds the lesson content.

```html
<article class="lesson-card">
  <header><slot name="title" /></header>
  <slot />
</article>
```

A border separates the card from the surrounding page. Use the theme's border
color so the card follows the site's appearance.

```css
.lesson-card { border: 1px solid var(--pmx-color-border); }
```

Use md fences for authoring examples within a component Block. An example written as an html fence becomes part of that component's template.

The reading shelf components show this style with explanations and a working two-row bookcase.

Create paths/lesson.page.md, import the file, and use the tag:

[@cards]: ./cards.components.md

# Your first lesson

<lesson-card>
  <slot name="title">Week 1</slot>
  Set up the toolchain.
</lesson-card>

Run bun run dev and open /lesson.page. The card groups “Week 1” and its instructions inside a border. Edit the component's padding or the lesson text to see the result update.

Wrap saved-response controls

When a literate component presents a response field, put the Input controls in the authored slot. They retain the containing Source's response Block and stable field names.

For example, define this component in cards.components.md:

---

<!-- componentName: rating-field -->

## Rating field

```html
<fieldset>
  <legend>{{ label }}</legend>
  <slot />
</fieldset>
```

Then use it in a response Block:

<!-- id: review -->

<rating-field label="Reading comfort">
<x-input-radio name="comfort" value="comfortable" label="Comfortable" required />
<x-input-radio name="comfort" value="difficult" label="Difficult" />
</rating-field>

<x-input-submit label="Save review" update-label="Update review" />

Saving uses the same Actor, writable Home, and response rules as ordinary Input. Keep the controls in the slot rather than generating them inside the literate template with interpolated names such as name="{{ name }}"; that form is not supported and produces a Plugin-owned row-consumer diagnostic. Use a Plugin component for dynamic field generation through its supported compile contract.

PieceMeaning
<!-- componentName: x -->Block topmatter naming the tag.
html, css, js fencesTemplate, global stylesheet in pmx.plugins, and browser script.
<slot />Where ordinary children render.
<slot name="x" />Named region filled by <slot name="x">…</slot> in the use.
{{ href }}, {{ title }}In a query component, each result's route, meta, and data fields.
[@alias]: ./x.components.mdImports a component file; the alias is local.

A component template's href="#top" stays on the page using the component when top is outside the template. The same applies to SVG <use href="#page-sprite">. IDs declared inside the template and their local references are scoped to each instance. To link to the definition's own document, write its path explicitly, for example href="./cards.components.md#details". Included content keeps its Source-relative fragment behavior.

For example, a page with <div id="top"> can use a component containing <a href="#top">Back to top</a> without navigating to the component's file. An included excerpt's #details still points at its original Source when that heading is outside the excerpt.

Run bun run pmx lint --format json to locate a known hyphenated component used without its Source-local import. Importing it in a layout makes it available only to the layout markup. A component document that demonstrates its own tags also needs an import of itself. Inline code, fenced examples and comments are ignored; unknown browser custom elements are not classified as missing PathMX components.

Lint reports duplicate component definitions at both Sources while pmx mv and pmx rm, including --dry-run, remain available to repair the project. A componentName declaration must be the Block's leading metadata comment, before link definitions; lint warns when the declaration is hidden behind those links.

Contract

Plugin components use x-<plugin>-<name> tags. Core ships these:

TagRenders
<x-navigation-nav name="primary" />A named .navigation.md menu, viewer-aware.
<x-breadcrumbs-trail />The folder trail to the current Source.
<x-navigation-toc max-depth="3" />Headings of the current page. min-depth too.
<x-theme-scheme-toggle />Light and dark scheme switch.
<x-source-content />Layout outlet for the page body (Layouts only).
<x-source-embed src="./example.page.md" />Independent page embedded inside this Source.
<x-source-references />Layout destination for independently open references.
<x-source-overlay />Layout declaration for Source overlays.

Sound effects provides x-sfx-cue, x-sfx-loop, x-sfx-toggle, and x-sfx-volume for authored audio and viewer controls.

Packages add their own, such as <x-search-trigger />, <x-player-app />, <x-completion-progress />, and <x-auth-session />.

Source embeds

Embed another Source's complete standalone page, including its layout:

<x-source-embed src="./example.page.md" />

The stock SourcesPlugin supplies this component. src resolves beside the Source that authors the component and accepts a local Source filename or canonical route, with optional query parameters and a fragment. Unreadable targets render as unavailable. Remote websites and non-page Sources are not supported.

Unlike @include, the embed is an independent iframe with its own styles, scrolling, and interactions. Internal links navigate within the frame. External links follow the site's Embedding plugin policy. Without JavaScript, the Source's title remains a normal link to its page. Repeated ancestor pages and nesting beyond four embed levels also fall back to that link.

Every PathMX document marks its root <html> with data-pmx-embedded when the browser loads it inside a frame. The marker is set before page styles and client hydration, including when the Embedding plugin is absent. Sources and Plugins can use it to adapt their presentation:

html[data-pmx-embedded] .standalone-tools { display: none; }

The local repository-admin switcher uses this marker to hide inside frames. It describes the browser presentation, not permissions or a different layout; server rendering has no frame context. With JavaScript disabled, the browser cannot set the marker.

id and class identify and style the host. Set --pmx-source-embed-height in an authored stylesheet to change its viewport height; the default is the smaller of 32rem and 75svh. The iframe fills the available width and uses the Source's title as its accessible name. Its height does not automatically expand to fit its entire document.

<x-source-embed id="working-example" src="./example.page.md" />

<style>
  #working-example { --pmx-source-embed-height: 36rem; }
</style>

The embedded document survives containing-page live edits when the embed host and destination remain stable. Changing src loads the new destination. Try the independent Source example. For managed references and overlays, see Opening Sources.

Player-enabled destinations can start in Slides or Focus using the same URL parameters as ordinary Player links:

<x-source-embed src="./lesson.page.md?play=presentation#practice" />
<x-source-embed src="./lesson.page.md?play=focus" />

The destination needs <x-player-app /> in its page or layout and the Player plugin installed. Player stays within the iframe viewport; its position and mode do not change the containing page's URL. See embedded slides.

Table of contents

Navigation provides <x-navigation-toc /> to list the current page's headings. Use min-depth and max-depth to choose heading levels, and title to change “On this page.” The route follows heading nesting and highlights the current section as you read. Headings inside Source Blocks appear in document order.

The outline defaults to a disclosure showing the current section. Choosing a heading closes it. When your layout places it beside the article, set --pmx-toc-layout: sidebar on its wrapper to keep it expanded. Switch to inline in the same rule that moves it above the article:

.reading-layout {
  container: reading / inline-size;
}
.reading-outline {
  --pmx-toc-layout: sidebar;
}
@container reading (max-width: 60rem) {
  .reading-outline {
    --pmx-toc-layout: inline;
  }
}

Choose the container threshold to match your layout's column rules. The component follows that layout signal instead of a viewport breakpoint. Resizing within one mode preserves the reader's open or closed choice. Allow for any sticky navigation in the page's scroll padding.

Anchor links scroll smoothly with the default theme. Reduced-motion preferences use immediate scrolling and route updates. Without JavaScript, the outline stays expanded and its links still work.

Component data

Use the complete artist/type/site layout example for the Source dataset, component file, and layout wiring. A template can read the represented Source and the containing Block without a binding declaration. Explicit component attributes remain ordinary placeholders:

<!-- componentName: artist-summary -->

# Artist summary

```html
<header>
  <h1>{{ source.title }}</h1>
  <p>{{ source.category }}</p>
  <p>Visit booth {{ source.booth }}</p>
  <span>{{ block.caption }}</span>
  <span>{{ label }}</span>
</header>
```

Import the file from an artist layout and use <artist-summary label="Exhibitor" />. source.title, source.category, and source.booth come from the artist being presented. The layout Source and the component definition do not replace that Source. block.caption belongs to the Block that authors this use; it is empty when the use has no containing Block or that Block has no caption.

ScopeValues
source.<key>Represented Source data, canonical id, type, href, and derived title, description, image.
block.<key>Containing or represented Block data, canonical id, type, href, and its own derived metadata. Source data is not inherited.
{{ label }}Explicit label attribute. Query results and component: self-rendering retain their existing flat field mapping.

Canonical identity and derived metadata take precedence over colliding authored fields. Nested data uses dot notation, such as source.contact.website. Scalar values are HTML-escaped; absent values and non-scalar objects render empty. This is field interpolation, without loops or expressions. Use a Plugin component for iteration or domain-specific rendering. Ordinary Markdown retains unresolved tokens as authored text and also supports source.<key> and block.<key> from its own compile-time Source and Block.

Nested components inherit the represented context, while authored slots retain their original containing Block. In Source query results and rich references, source is the result or target Source. In Block results, block is that result Block and source is its owner. A custom named query can return a canonical source reference to select that context; its other scalar fields remain flat attributes.

Scoped templates read through the current View when rendering, so admission rules and live Source dependencies apply. Values are not embedded into shared compiled templates or automatically sent to browser JavaScript. Actor and application state are not implicit template scopes. Browser scripts receive explicit instance attributes. Use View bindings such as $profile.display-name for declared Source data and typed query projections, and Plugin components for Actions. Bindings can appear directly in a template or in an instance's scalar attributes; they resolve for each current View.

Browser lifecycle

Add a second Block to the same paths/cards.components.md:

---

<!-- componentName: lesson-counter -->

## Lesson counter

```html
<div>
  <button type="button">Practice again</button>
  <output>0</output>
</div>
```

```js
let count = 0
on("button", "click", () => {
  count += 1
  $("output").value = String(count)
})
```

Add <lesson-counter /> to paths/lesson.page.md after its existing import. Click Practice again: the output increments. This count is local interaction state, so remounting or reloading resets it.

Keep related controls inside one HTML root so the instance's scoped helpers can reach them. A component's js fence runs when that instance mounts. Use on() to register event listeners and cleanup() to release resources when the instance is removed or its script changes. PathMX removes listeners registered with on() automatically.

The JavaScript fence receives these helpers:

HelperBehavior
elThis instance's root element.
$(selector)First matching descendant, or null.
$$(selector)Matching descendants as an array.
on(target, event, handler, options?)Listen on a descendant selector or an EventTarget, including document; listeners are removed on cleanup.
attrsCurrent instance attributes; refreshed before update callbacks.
update(fn)Run immediately and again when this instance's markup changes.
cleanup(fn)Release a resource when the instance is disposed.
inputAccess the instance's compiled Input field, when declared.
settingsget(plugin) reads and set(plugin, values) saves that Plugin's device settings; both return Promises and require installed Settings.

A css fence keeps its selectors unchanged and applies page-wide in @layer pmx.plugins. Prefix selectors with your component's root class to limit where they apply. Normal unlayered site styles override normal layered component styles regardless of specificity; see Cascade layers. For a link styled as a button, use <a class="pmx-button" data-pmx-link="button" href="…">…</a> and exclude [data-pmx-link] from broad site link rules.

Repeated presentation components can share a name, including controls in a radio group. Each instance gets distinct generated IDs for its local labels, ARIA references, and SVG paint references. Treat generated IDs as internal; use an authored class or data attribute to select your component.

Updating the page after an interaction

When someone clicks a button or makes a selection, update the existing HTML elements that need to change. Keep the surrounding elements in place. This helps the browser keep keyboard focus, continue playing audio or video, and display images and animations without interruption.

The counter above follows this pattern:

$("output").value = String(count)

Here, $("output") finds the existing <output> inside this copy of the component. Assigning its value changes the displayed count. The button stays in place, so someone using a keyboard can press it again without moving focus.

For more complex components, create the fixed structure once and update it as people interact. A chessboard, for example, can keep its 64 square elements and the images of pieces that have not changed. Commentary can stay in place until there is a different note to show.

Assigning innerHTML or calling replaceChildren() on a container removes its existing children. Use these when replacing that content is intentional; avoid rebuilding an entire interactive component just to change one value or item. PathMX does not automatically preserve elements that your JavaScript removes.

To check an interaction, try it with both a pointer and a keyboard. Confirm that focus stays where expected and that unchanged images, media, and controls remain in place.

Keeping local state when page content changes

Live updates can replace a component's rendered contents. By default, an affected instance cleans up and mounts again. To keep local state across updates, register an update(fn) callback: it runs immediately and when the component's rendered markup changes, with attrs refreshed. During live and Action updates, unchanged rendered subtrees retain their DOM and local state, including components in layouts outside Blocks. Updating a Graph footer, for example, does not reset an unchanged lesson or its widgets. Navigation still reconciles the destination's instances. Query current elements inside the callback and restore any generated content there.

During pmx dev, edits to separate .js or .mjs files under the Source root reload connected pages, including all nested module imports. This also resets component state; update() preserves state across document morphs, not full page reloads. Editing a component's inline js fence still uses the cleanup-and-mount lifecycle above.

Browser module lifecycle

For a complete site-wide bundle setup, follow Add a browser module. Put the TypeScript below in that example's plugins/counter/client.ts and the HTML in paths/counter.page.md.

Choose who owns the HTML before adding behavior. Use server-rendered HTML with mountPathMXBehavior when the server should replace form structure as data or navigation changes. A Plugin component with client: moduleRef(...) owns its interactive root: its client must update the contents when new data arrives. Returning different server HTML alongside clientData does not transfer that ownership back to the server. See Plugin components.

Browser entrypoints can attach behavior to ordinary HTML with mountPathMXBehavior from @pathmx/core/controls/browser. It mounts matching roots immediately, retains each root's local state, and handles later PathMX renders and removal. For example, this counter keeps its count when authored HTML is refreshed:

<div data-counter>
  <button type="button">Count</button>
  <output>0</output>
</div>
import { mountPathMXBehavior } from "@pathmx/core/controls/browser"

const stop = 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 }
})

// Call stop() if the entrypoint itself is being disposed.

Core loads newly required browser bundle entrypoints before committing an incoming document. Use this lifecycle helper so arriving roots mount at commit; module evaluation may still see the previous document. Shared component client entrypoints use one bundle URL regardless of which component a page authors first.

Setup is synchronous and runs once per matching root. Return an object with optional update() and dispose() methods, or return nothing for listeners that need no additional lifecycle work. update() runs once after setup and after relevant renders. During live and Action updates, an unchanged rendered subtree skips updates, whether it is inside a Block or in the surrounding layout. Changed server attributes and content still apply; unchanged attributes retain browser-owned state. Query current children inside update() because a retained root's authored contents may have changed. If a behavior depends on data outside its root, subscribe to that data explicitly rather than using unrelated page renders as its update signal.

New matching roots mount on the next PathMX render. Removed roots dispose at the next DOM mutation checkpoint, even without another render. A root that no longer matches the selector disposes during reconciliation. Disposal first aborts signal, removing listeners registered with it, and then calls dispose() once. The returned stop() function stops reconciliation and disposes all remaining roots.

For lazy imports, check the signal after loading and release any mounted resource in dispose(). In this example the local chart module exports mountChart(root), returning update() and dispose() methods:

import { mountPathMXBehavior } from "@pathmx/core/controls/browser"

mountPathMXBehavior("[data-chart]", (root, { signal }) => {
  let chart: { update(): void; dispose(): void } | undefined
  void import("./chart.js").then(({ mountChart }) => {
    if (signal.aborted) return
    chart = mountChart(root)
  }).catch((error) => {
    if (!signal.aborted) console.error("Chart failed to load.", error)
  })
  return {
    update() { chart?.update() },
    dispose() { chart?.dispose() },
  }
})

React's mountPathMXApps uses the same lifecycle for root retention, updates, and unmounting. See the React reference.

Plugin components

Start with the complete reading-time Plugin for dependencies, named files, activation, and rendered output.

TypeScript Plugins declare native components with defineComponent. Authored preparation lives in compile; load reads the current View, and the portable runtime presents those inputs. Query rows invoke the reusable compiled component with structured props instead of recompiling generated Markdown.

owner identifies the authoring Source. page identifies the displayed Source, including its instance. Read viewer-specific state through the admitted view. See the Plugin API for the complete contract and ordinary Block outputs.

Forms in components

Native inputs, selects, and textareas in a component receive the site's shared field styles. Use .pmx-field to group a label with its control and supporting text, and .pmx-button for submit actions. Keep component CSS focused on layout and intentional variations; use the existing theme tokens for colors and shapes.

Follow the Form fields conventions for labels, errors, disabled states, and mobile sizing. CSS supplies presentation; your form or plugin owns submission, validation messages, and saved values. Actions describe how native forms invoke registered operations.

Custom Input clients

Components that declare an Input field receive an input capability in their literate JavaScript fence. The compiled binding supplies response and field ownership. The field name must match the component's declared field; it is not a global lookup.

const field = input.field(attrs.name)
const editor = $("input")
function render() {
  const { value, editable } = field.getSnapshot()
  const text = String(value ?? "")
  if (editor.value !== text) editor.value = text
  editor.disabled = !editable
}
cleanup(field.subscribe(render))
render()
on(editor, "input", () => {
  const text = editor.value
  field.set(text === "" ? undefined : Number.isFinite(Number(text)) ? Number(text) : text)
})

This numeric editor retains incomplete text in the shared draft. The Input model validates it; invalid values do not become valid output or bypass Submit validation. An ordinary Input Submit control saves the response. Subscriptions do not fire immediately: subscribe, then perform the initial render. Dispose subscriptions with cleanup; disposal releases the component view without erasing response state.

Native clients can obtain the same scoped capability with inputFor(componentRoot) from @pathmx/core/input/browser. The root must carry its compiled Input declaration. This capability reaches only the fields declared by this editor, not every field in the response; input.field("another-field") does not expand its scope. Neither DOM proximity to a form nor a field name alone establishes a binding. Handles become non-editable and reject writes after their component is removed or rebound. React clients use useInputField over the same response owner.

Multi-field editors

One editor can coordinate several existing Input fields without a second hidden component or a separate draft store. A signature editor, for example, saves a name and either a typed style or drawn mark while consent stays an ordinary checkbox. Declare the string props that name its fields:

const signature = defineComponent({
  tag: "x-offer-signature",
  props: z.object({ name: z.string(), mark: z.string() }),
  input: ["name", "mark"],
  render({ props }) {
    return {
      html: signatureEditorMarkup,
      clientData: { name: props.name, mark: props.mark },
    }
  },
  client: moduleRef(() => import("./signature.client.ts")),
})

Here signatureEditorMarkup is the Plugin's editor UI. The authored invocation selects fields already declared in its owning response Block:

<x-offer-signature name="signature" mark="signature-mark" />
<x-input-checkbox name="accept" value="yes" label="I agree" required />
<x-input-submit label="Sign" />

The input array lists prop keys, not literal field names. Core checks that each prop has a string value naming a field in this Block. It does not create fields or expose the rest of the form. Keep the existing field declarations and any native fallback controls; the component changes how those values are edited. input: true is the single-field form using the required string name prop.

In the client, obtain the declared handles:

import { inputFor } from "@pathmx/core/input/browser"

const input = inputFor(element)
const signature = input.field(data.name)
const mark = input.field(data.mark)

// In the editor's adopt handler:
signature.set(adopted.name)
mark.set(adopted.style === "drawn"
  ? `draw:${adopted.path}`
  : `type:${adopted.style}`)

Subscribe to both handles and render their current snapshots on mount and after changes; dispose both subscriptions with the client. Ordinary Submit validates and saves the whole response. The two set calls update the same draft; they are not a new atomic transaction API. Consent is independently required and cannot be changed through this editor's capability. Multi-field clients use named handles; Core does not choose a first field for context.field.