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 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.
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.
| Piece | Meaning |
|---|---|
<!-- componentName: x --> | Block topmatter naming the tag. |
html, css, js fences | Template, 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.md | Imports 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.
Plugin components use x-<plugin>-<name> tags. Core ships these:
| Tag | Renders |
|---|---|
<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 />.
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.
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.
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.
| Scope | Values |
|---|---|
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.
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:
| Helper | Behavior |
|---|---|
el | This 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. |
attrs | Current 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. |
input | Access the instance's compiled Input field, when declared. |
settings | get(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.
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.
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.
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.
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.
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.
- Queries · Callouts
- Real component files: docs components, play layout
- Reuse content
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.
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.