Use this reference alongside Make a custom Plugin.
Import the helpers and public types below from @pathmx/core. Callback types
are inferred by the helpers; no internal module imports are needed.
| API | Contract |
|---|---|
definePlugin(definition) | Returns an ordinary Plugin, preserving its inferred type. Only id is required. |
definePlugin({ config, create }) | Creates a schema-validated configurable factory. create receives the schema's parsed output and returns a Plugin. See configuration. |
definePreset([...]) | Composes Plugins, factories, and nested presets in order. Each Plugin retains its identity and resources. |
html | Tagged template that escapes ordinary interpolated values and composes HTML fragments and arrays. null, undefined, and booleans produce no text. |
Plugin ids use lowercase letters, numbers, and single hyphens between segments.
defineComponent({ tag, props, compile, load, render, client, css }) declares an
authored component such as x-reading-time. Add definitions to a Plugin's
components array or default-export a standalone component. moduleRef(() => import("./render.ts")) links a default-exported renderer or client without
mixing its portable code with host dependencies.
Props schemas own defaults, validation and transformations. Core decodes literal
attributes according to the schema; whole bindings retain their JSON types.
A z.number() prop accepts count="4". A z.boolean() prop follows HTML: open,
open="" and open="true" are true, open="false" is false, and an omitted
attribute uses the schema default.
Invalid values carry Source and Block diagnostics. Component tags identify their
definitions independently of the containing Plugin id.
HTML escaping does not validate URL schemes; validate authored URLs before putting them in links.
The Bun host discovers these entrypoints beside the paths/ directory:
plugins/*.plugin.{ts,tsx,js,jsx}
plugins/*/index.plugin.{ts,tsx,js,jsx}
Default-export a Plugin, a synchronous zero-argument PluginFactory, or a
PluginPreset, or standalone component. Discovery is ordered by filename. requires contains Plugin ids
that must already be registered; it does not fetch or install dependencies.
Use factories for app-owned mutable resources. Package activation and replacement
rules are described in Packages.
The portable createApp() host receives its Plugins explicitly and does not
scan directories; see Core API.
These optional fields extend an ordinary Plugin definition.
| Field | Value and behavior |
|---|---|
requires | Readonly array of earlier-installed Plugin ids. |
components | Component definitions with validated props, optional compilation and loading, portable rendering and optional clients. |
compiled | Block-level compilation, admission and evaluation for Block outputs such as Input responses. Authored components use components. |
displays | Named Block presentations with optional authored-data validate; each is rendered by the Plugin's x-{plugin-id}-group-{display-name} component. See Block displays. |
schema | A SchemaContribution: Source/Block types, data properties, and settings validation. Use exported defineSchema for schema definitions. |
actions | Record of local names to Actions; registered as plugin.name. See Actions. |
queries | Record of local query names to query contributions; registered as plugin.name. Resolve viewer-safe reads through the query context's view. See Queries. |
bindings | Record of unique application-wide named View reads, reserved against local shadowing. See repository-wide bindings. |
events | Record of local names to exported defineEvent definitions; registered as plugin.name. Server and Action Events are available through ctx.events. |
jobs | Record of local names to defineJob contributions for trusted Events. See durable jobs. |
relations | Readonly array of RelationContribution definitions for durable relations and optional rewrite behavior. |
impliedInstances | ({ source, iid }) => hrefs: Source instances that one declared instance implies, as iid-only hrefs relative to source. Core declares them like authored links, transitively. A Path instance can instantiate its Steps this way. |
database | Optional isolated SQL database with append-only migrations; see below. |
routes | Array of relative HTTP routes created with route; see below. |
credentials | A CredentialHost for request admission; see authentication. |
Prefer the linked feature references for complete domain examples. Registration
names such as reading.reset are distinct from authored tags such as
x-reading-time and HTTP mounts such as /api/reading/.
A Source type may set routes: false to keep its Sources out of public routes,
fragments, and search while retaining trusted server and CLI access. Email uses
this for authored message templates.
defineSchema({ sources?, blocks?, props?, settings? }) preserves its inferred
shape. Source and Block entries are keyed by type and accept label,
description, icon, color, and a Zod schema for the complete authored data
map. props assigns individual data property schemas with exclusive ownership.
settings.personal validates personal preferences through shared State.
Core's Meta plugin owns the overridable icon prop: a supported built-in icon
name or a single emoji. Source Views and query summaries expose meta.icon, using
the effective authored value or the registered Source-type icon. Custom consumers
should use this resolved value instead of implementing their own fallback.
A data prop can opt into named Source-instance overrides:
props: {
start: { schema: z.string(), overridable: true },
options: {
schema: z.strictObject({ label: z.string(), count: z.coerce.number().int() }),
overridable: true,
},
}
A plain schema entry remains non-overridable. Constructor values arrive as strings;
the owning schema controls validation and explicit coercion. An authored link such
as [Morning](./break.event.md?iid=morning&start=10:30) declares an instance.
For object props, dotted fields such as ?iid=retry&options.label=Retry&options.count=3
replace those fields while retaining other Source defaults. Core validates the
resulting object with the prop's schema. URL leaves remain strings, so the schema
must explicitly allow any number or boolean conversion. Dotted paths traverse
objects, not arrays; a whole-prop argument cannot overlap its dotted fields.
The root prop must opt into overrides. Conflicting declarations fail indexing
and are reported by lint.
Read the admitted instance with view.readSource("/break.event", { iid: "morning" }).
The result preserves its canonical id, adds iid, and exposes effective data,
metadata, and instance-aware Source/Block hrefs. An authored iid-only link uses
Source defaults when no override declaration exists. Missing or inaccessible
instances return undefined; omitting the options reads the default Source.
Instances supplied by impliedInstances retain the originating declaration's
access requirements through every implied parent. Public templates do not expose
instances declared only in private Sources. An independent admitted declaration
can make the same instance available.
Use sourceReference(source) when passing a resolved Source or Source View to an
Action or saving a State target. It preserves iid, producing a reference such as
/assignment.project?iid=retry. parseSourceReference(reference) returns the
canonical { id, iid? } pair. Constructor values belong in authored declarations,
not in Action targets or State for values.
await view.actions.run("input.submit", {
responses: { source: { answer: "My revised answer" } },
}, { target: "/assignment.project?iid=retry" })
HTML Action forms use the same reference in _target. Actor State stores it in
for; Input responses and Actor Completion remain separate per instance and Actor.
Source-backed Completion edits authored Markdown and does not offer mutation for
named instances. Use Actor storage for independently completable attempts.
Static export pre-renders publicly readable named instances and their admitted
configuration. The static browser runtime selects the representation while keeping
the authored ?iid=... URL; direct instance navigation requires JavaScript.
Included instance content is already rendered in the exported page. Private
configuration is not exported, and public links to unavailable instances fail
export validation. Instance support does not add writable Actor storage to an
ordinary static site.
This complete Plugin contributes a type, a query, and a named binding. Save it as
plugins/reading-list.plugin.ts:
import { definePlugin, defineSchema } from "@pathmx/core"
import { z } from "zod"
export default definePlugin({
id: "reading-list",
schema: defineSchema({
sources: {
article: { label: "Article", icon: "file-text" },
},
}),
queries: {
titles: {
output: z.array(z.object({ id: z.string(), title: z.string() })),
defaultSort: [{ by: "title" }],
async resolve(context) {
const sources = await context.query("sources", {
from: context.from,
type: "article",
})
return sources.map((source) => ({
id: source.id,
title: source.meta.title,
}))
},
},
},
bindings: {
readingArticles: { query: "sources", from: "/", type: "article" },
},
})
Add paths/first.article.md with # First article, then run
bun run pmx query reading-list.titles --from /. Its row contains that Source's
id and title. The same query can be used in authored query fences, a View, and
POST /api/query on a live server. A registered query is a viewer-safe read
capability on all those surfaces, so keep work bounded for arbitrary valid input.
A query definition has optional Zod input, required Zod array output, optional
defaultSort, and resolve({ input, view, app, from, query, depend }). The resolver context exposes
validated input, view, owning app, normalized from, query, and depend. Compose admitted reads through
context.query or context.view; do not substitute unrestricted repository data.
Query requests accept from, input, where, sort, and limit.
A binding is { source: id } or a query descriptor with query and optional
input, from, type, has, recursive, links, where, and sort.
Binding descriptors do not accept limit. Binding names are application-wide;
see binding consumption.
Core's default stack includes Schedule. A custom presentation can read effective Source or Block schedules through the current View:
import { createSchedulePlugin } from "@pathmx/core/schedule"
const schedule = createSchedulePlugin()
const item = schedule.read(view, "/workshop.event?iid=afternoon")
const markup = item ? schedule.render(item, { includeDate: true }) : ""
The reader returns resolved start/end/due instants with their authored values and
timezone. It retains the selected Source instance and respects View admission.
render produces exact time markup by default; use authored Schedule components
for live relative time, event status and elapsed progress. See
Schedule authoring.
Core's default plugin stack includes Location. Custom presentations can use its reader without registering another copy:
import { createLocationPlugin } from "@pathmx/core/location"
const location = createLocationPlugin()
const details = location.read(view, "/workshop.event?iid=afternoon")
// details?.place: { label, href }
// details?.online: { label: "Join online", href }
const markup = location.render(details)
read accepts a canonical Source reference, including an optional instance id.
It returns the admitted physical and online entries, or undefined if neither is
available. render returns escaped link markup. <x-location-details /> uses the
same reader for its owning Source or included instance. Apps assembling a custom
plugin stack can register location.plugin explicitly. See Location authoring.
Define Actions with defineAction, then put them in the Plugin's actions
record. An Action accepts title, description, target, input, form,
output, available, and required prepare. A string target matches a Source
type; a predicate can select Sources explicitly. Without target, the Action
is untargeted. Input may be a Zod schema or a function of the current View/target;
output is an optional Zod schema.
prepare({ input, view, app, principal, operation, target }) returns
{ effects?, run }. Preparation is read-only. Effects declare finite read,
create, update, and delete Source ids. run({ tx, events }) stages changes
and returns the result. available({ view, target, can }) controls availability;
actual authority must also be enforced during preparation. Follow the complete
Action example and effect rules.
To emit a domain event, define it once and register that same definition in
events. This complete Plugin records a validated acknowledgement from an
admitted Action caller:
import { defineAction, defineEvent, definePlugin } from "@pathmx/core"
import { z } from "zod"
const Acknowledged = defineEvent({ data: z.object({ message: z.string() }) })
export default function AcknowledgementsPlugin() {
let unsubscribe: (() => void) | undefined
return definePlugin({
id: "acknowledgements",
events: { acknowledged: Acknowledged },
actions: {
acknowledge: defineAction({
input: z.object({ message: z.string().min(1).max(200) }),
output: z.object({ recorded: z.boolean() }),
prepare({ input }) {
return {
run({ events }) {
events.record(Acknowledged, input)
return { recorded: true }
},
}
},
}),
},
setup(ctx) {
unsubscribe = ctx.events.subscribe((records) => {
for (const { event } of records) {
if (event.type === "acknowledgements.acknowledged") {
ctx.log.info("acknowledged", { id: event.id })
}
}
})
},
close() {
unsubscribe?.()
},
})
}
Save as plugins/acknowledgements.plugin.ts. Through an admitted View, run
await view.actions.run("acknowledgements.acknowledge", { message: "Ready" }).
The receipt's output is { recorded: true }, and the subscriber logs its event
id after commit. This example requires an admitted caller; it is not an anonymous
HTTP endpoint.
events.record(definition, data) stages schema-validated event data. Failed
staging or validation publishes neither Source changes nor staged events.
Subscribers receive committed EventRecord[] in Source commit order.
Each record has event, cursor, recordedAt, and optional tracking metadata.
Each Event has id, type, occurredAt, origin, and data. An Action origin
contains { kind: "action", operation, actor, target? }; server origins contain
{ kind: "server", plugin }. Core also emits action.completed for successful
invocations, including no-ops.
ctx.events.emit(definition, data) publishes a validated Event owned by that
Plugin. definitions() exposes registered metadata; records(after?) reads the
bounded in-memory journal. Expired or unknown cursors throw. Subscribers own their
pending work and are not awaited by Core. See Events for
tracking declarations, browser observations, limits, and complete producer examples.
Use relations when a Plugin introduces links outside ordinary Markdown syntax.
Each contribution implements synchronous read(source), returning
{ key, rel, href, target?, label?, qualifiers? }[]. Keep key stable within that Source
and contribution when only the destination changes.
Optional rewrite(source, changes, ctx) returns the complete revised Source
text. Changes are { type: "retarget", key, href } or { type: "remove", key }.
For frontmatter-owned links, ctx.patchFrontmatter(patch) produces complete
Source text without hand-rebuilding the header. Implement rewrites when those
links should participate in Source move/remove operations.
For example, a contribution can expose a related frontmatter link:
import { definePlugin } from "@pathmx/core"
export default definePlugin({
id: "related",
relations: [
{
read(source) {
return typeof source.data.related === "string"
? [{ key: "related", rel: "related", href: source.data.related }]
: []
},
rewrite(source, changes, ctx) {
const change = changes.find((change) => change.key === "related")
if (!change) return source.content
return ctx.patchFrontmatter({
related: change.type === "retarget" ? change.href : null,
})
},
},
],
})
Save as plugins/related.plugin.ts. Author related: ./other.page.md in a
Source's frontmatter and create that target. Preview a move with
bun run pmx mv other.page.md renamed.page.md --dry-run --format json;
the preview should include the frontmatter retargeting. A removal sets the
field to null, which the reader ignores.
Directory references use target: "scope". They follow a folder move without
becoming links to an index Source at that route. Moving or deleting only the
index Source leaves the scope unchanged. Core's query Plugin uses this for
explicit query from: values.
For example, plugins/catalog.plugin.ts can own a catalog directory field:
import { definePlugin } from "@pathmx/core"
export default definePlugin({
id: "catalog",
relations: [{
read(source) {
return typeof source.data.catalog === "string"
? [{ key: "catalog", rel: "catalog", href: source.data.catalog, target: "scope" }]
: []
},
rewrite(source, changes, ctx) {
const change = changes.find((change) => change.key === "catalog")
if (!change) return source.content
if (change.type === "remove") throw new Error("A catalog requires a scope.")
return ctx.patchFrontmatter({ catalog: change.href })
},
}],
})
Author catalog: /resources/codex in a Source and create
paths/resources/codex/entry.person.md. Running
bun run pmx mv resources/codex codex --dry-run previews catalog: /codex.
The graph reports these references with type: "scope" and no Source target.
An affected contribution without rewrite blocks the complete move before any
files change. References inside a moved tree that retain the same href need no
rewrite callback.
contextualRelations(source, { view, location? }, app) adds relations for an
explicit representation without writing durable graph links. Return
SourceRelation[]: rel, qualifiers, type, href, optional label,
fragment, and target: { type: "source", id }. Use the admitted View to check
any linked content; contextual links must not expose hidden Source data.
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>`
},
})
compile({ props, body, source, block, readSource, depend, invalid, app }) performs
static preparation only for invocations of this component’s declared tag.
staticProps: ["name"] selects the props it can use. Those
props must be literal; bindings produce a diagnostic. body is authored content,
not rendered slots. Return portable prepared data. readSource resolves relative
references and tracks dependencies; depend records other compiler inputs.
load({ props, compiled, source, page, block, view, app, query, session, location, depend, signal }) reads current authorized data. It runs with the complete validated props
and prepared result. source identifies the authoring Source and page the displayed
Source, including its instance. Use view and query for access-controlled reads.
app supplies the owning Plugin's host services. Return selected JSON data, not host
handles or session objects. Omit optional properties instead of returning undefined;
invalid JSON data reports the component, phase and field path (for example,
$["items"][1]["cover"]). Selected data is deeply frozen and object keys are
canonicalized in sorted order; do not use that ordering to preserve authored files.
Use arrays such as [{ id, label }] whenever authored order matters, including
question choices and navigation.
Use null for an unavailable result and signal for
cancellable work. block is the containing Block id. For per-Block presentation,
read its metadata through the admitted Source and pass selected data to render:
load({ source, block, view }) {
const role = view.readSource(source.id)?.blocks.find(item => item.id === block)?.data.role
return typeof role === "string" ? role : null
}
This lets a role Component use its Block's role without repeating it as an
attribute. compile also receives the authored Block for static preparation.
render({ props, compiled, data, slots }) returns markup composed with html.
Slot strings are already rendered HTML; use fragment when including them. Render
inline when the whole module is portable, or use
render: moduleRef(() => import("./card.render.ts")) with a default-exported function.
A separate renderer declares the props and data types it reads, like any
function's parameters. defineComponent checks them against props and load;
TypeScript reports a mismatch on the props or load property.
A whole binding such as rows="{{ $results.rows }}" preserves arrays, objects,
booleans, numbers and null through native and literate calls. Mixed text such as
label="Score: {{ $scores.total }}" produces a string. Keep structured props typed;
do not serialize them into strings before passing them to a component.
css accepts imported CSS text and is included only when the component is used.
Identical styles are deduplicated. A stylesheet does not require a JavaScript client.
client: moduleRef(() => import("./card.client.ts")) selects a client module.
Its default export receives { element, data, signal, field } and can return
{ update(data), dispose() }. To select client data, render { html, clientData }.
Core serializes that JSON value and preserves the client-owned root. Use the signal
for event listeners and dispose resources when the component leaves. See
React for a complete client example.
The client root is the first element of your rendered HTML, not the authored
x- tag, which does not remain in the page. Render one root element and give it a
class for your styles and queries; x-card { … } matches nothing. That root is the
client's element. update(data) runs after a live or Action render changes
that root, such as new selected data, including for a component placed in a Layout.
input: true attaches a custom editor to the existing Input field named by its
required string name prop. Core validates ownership in the same Block. The client
receives a handle for that one field instead of declaring another response or draft
store. For one editor that edits several fields, explicitly list the props naming
them:
props: z.object({ name: z.string(), mark: z.string() }),
input: ["name", "mark"],
The array contains prop keys; their validated string values identify existing fields in the same response Block. It must contain at least one required string prop. Core rejects missing fields and cross-Block bindings. A label or other prop never becomes a binding automatically.
Multi-field clients obtain named handles with inputFor(element).field(name) from
@pathmx/core/input/browser; context.field is supplied only for input: true.
Pass the field names in clientData when the client needs them. Subscriptions,
drafts and Submit use the same Input response owner. Removing or rebinding the
component revokes its handles. Undeclared fields remain inaccessible even if they
share the form. See the multi-field example.
While a Block stream writes a component, readers see nothing of it until its closing tag arrives, so it never renders with half its props or content. A component whose content reads well while partial, such as a callout of prose, can show it as it arrives:
export const callout = defineComponent({
tag: "x-callout",
streaming: "partial",
render: ({ slots }) => html`<aside class="callout">${fragment(slots.default ?? "")}</aside>`,
})
Once the opening tag is complete, the component renders with its tag closed for
display; a half-written attribute still waits. What it contains follows the same
streaming rules, so a component inside it that has not opted in still waits. Its
load runs on every refresh while the stream writes, so leave streaming at its
default, "complete", for components with expensive reads. Style the streaming state
with [data-pmx-streaming] .callout; the saved Block renders normally.
Block-producing contributions own domain outputs independently of presentation.
Their compiler declares outputs; the runtime supplies the values. Other Blocks
read them as $block.field. Core resolves dependencies, rejects ambiguous fields
and cycles, and applies when: participation. Rendering a report or control must
not become a prerequisite for those outputs to exist.
Except where stated, hooks may return their result directly or as a Promise.
app is the request-independent AppContext described below.
| Hook | Result and purpose |
|---|---|
setup(ctx) | void; initialize owned resources after registration, before indexing. Receives PluginContext. |
close(ctx) | void; release owned resources during app shutdown. Hosts must await app.close(). |
sourceRoutes(source) | Synchronous, pure; return route declarations owned by the indexed Source. Core replaces the claims after each complete Source change. |
source({ before?, after? }, app) | Synchronous; replace Plugin-owned indexed state on a Source transition. Return nothing or invalidation information. Addition has after, removal has before, and edits have both. |
precompile(markdown, ctx, app) | Return Markdown before HTML rendering. |
postcompile(html, ctx, app) | Return HTML after rendering a Source or Block. |
page(html, ctx, app) | Return assembled page HTML; collect page-specific assets through ctx. |
documentAssets(ctx, app) | void; collect document assets with optional ctx.page and ctx.view. Visitor selection belongs to this response. |
streamSelectors(ctx, app) | Synchronous; return a selector-to-handler record, with element(el) handlers for final HTML. ctx is the viewer context. |
lint(report, app) | void; report Source-local diagnostics only when lint explicitly runs. |
publish(ctx, app) | void; register finite public GET paths with ctx.route(pathname) or reject an unsupported authored use with ctx.unsupported(source, message). |
commands(group, ctx) | Synchronous registration of administrator CLI commands; command actions may be async. |
The precompile and postcompile context has source, optional block,
scope (page or embedded), stable containing instance, insertHtml, elements, and depend. The page hook
receives the page context described above. Document assets support
ctx.addAsset(...), for example { type: "stylesheet", href: "/theme.css" },
and await ctx.addBrowserBundle(new URL("./client.ts", import.meta.url), "tools").
Use a component-owned client when code is needed only for that component.
Declare browser modules used by conditional hooks in browserModules so hosts
that build before serving can prepare every possible response:
const tools = new URL("./tools.client.ts", import.meta.url)
export default definePlugin({
id: "tools",
browserModules: [tools],
async documentAssets(ctx) {
if (ctx.view?.identity.type === "authenticated") {
await ctx.addBrowserBundle(tools, "tools")
}
},
})
The declaration does not add the module to a page. The component or hook still
owns response inclusion. Component client entrypoints are declared automatically.
A source result can include dependencies, sourceIds, and targets.
Dependencies identify stale compiled work; targets request live reconciliation
without implying a rebuild. A target is { type: "route", route, actors? } or
{ type: "actor", actors }. Dependencies use { type: "source", path },
{ type: "block", path, id }, { type: "plugin", plugin, key }, or { type: "bundle", entrypoint }.
Declare the smallest dependency the consumer reads. For example,
ctx.depend({ type: "block", path: "/shared.md", id: "hint" }) tracks the hint
Block without also depending on every sibling in /shared.md. An unrelated
sibling-body edit preserves that consumer's compiled artifact. Editing or removing
hint invalidates it. Source metadata, shared definitions, or structural changes
that affect the Block's context can invalidate it as well. Use a Source dependency
when the consumer actually reads the whole Source.
For same-page live and action refreshes, Core can transmit only changed rendered Block roots when the document shell and Block order remain unchanged. This is independent of dependency declarations: the server still renders with current access checks and Plugin hooks before comparing output. Layout, assets, Block structure, or an unavailable baseline fall back to the full HTML response. Browser-owned state in unchanged Blocks is preserved. This does not make custom Plugin output eligible for navigation caching.
For lint, call report(source, message, details?); details can include severity
(error by default or warning), code, hint, block, line, and column.
For an expected compilation failure, app.invalid(source, message, details?)
throws a collectable diagnostic; its details omit severity. Supply a stable Plugin-specific
code, the owning block when known, and a concrete repair hint. Both text and
JSON lint output retain these details. Reserve this path for expected authoring
problems; let unexpected implementation errors propagate. See the
lint example.
Use ctx.insertHtml(renderedHtml) to place fully rendered block HTML into a
transform's output. Pass { inline: true } for phrasing HTML inside a paragraph.
The returned string belongs to the current compilation and can be returned from
precompile or postcompile, or composed into their output. The compiler preserves
the supplied HTML until all Markdown hooks finish, then restores it before page
hooks and View rendering. Those later phases still see the inserted content.
For example, save this as plugins/notice.plugin.ts:
import { definePlugin } from "@pathmx/core"
export default definePlugin({
id: "notice",
precompile(markdown, ctx) {
if (markdown.trim() !== ":::notice") return markdown
return ctx.insertHtml("<aside>A generated notice.</aside>")
},
})
A Block containing only :::notice renders the aside. To preserve inline
placement, ctx.insertHtml("<kbd>Enter</kbd>", { inline: true }) can be composed
into running prose. Pass rendered HTML: insertion does not parse Markdown or
escape the supplied string. Compose other Sources through authored includes and
source-backed components so Core retains their identity, admission, dependencies,
and instance boundaries. app.compile.models(source) exposes the calling Plugin's
current compiled Block models when host operations need declarations.
| Context | Available capabilities |
|---|---|
PluginContext | plugin, log, runtime, optional database, and Plugin-bound events. Used by setup and close before or outside indexed Source access. |
AppContext | PluginContext plus grouped jobs, sources, graph, actors, access, assets, compile, schema, yaml, rewriteHtml, invalid, and view(session, options?). Trusted, request-independent access. |
DocumentRouteContext | Admitted view and public location; passed to declarative document renderers with app. |
ServerContext | AppContext plus request, session admission, HTTP serving, and trusted mutations. See routes below. |
PluginCommandContext | Plugin identity, repository roots, log, optional database, running-host status, file installation, and lazy viewer-safe inspection. |
app.jobs.list({ limit }) and app.jobs.retry(id) provide Plugin-scoped live
durable job operations.
The Plugin authorizes callers; retry is immediate and separate from Source transactions.
Common AppContext reads include app.sources.get(idOrPath), all(),
resolve(pathname), discoverable(view), and isPage(source).
app.graph.relationsFrom(sourceId) and relationsTo(sourceId) read durable
relations. These trusted repository reads are not automatically viewer-filtered.
Inside a component's compile or load hook, these graph calls automatically
record a dependency on the queried Source's outgoing or incoming relations—even
when the result is empty. Core invalidates that consumer when the derived relation
result changes, including relations supplied by other Plugins and unresolved targets
that appear later. A prose-only edit that leaves those relations unchanged does not
invalidate a graph-query dependency.
load({ page, app, view }) {
return app.graph.relationsTo(page.id)
.filter(edge => view.readSource(edge.from))
}
Keep metadata and access reads through the View: graph-query tracking does not replace those dependencies or grant access. Calls outside component compile/load hooks are ordinary repository reads with no automatic hook dependency tracking.
Inside a component's load hook, view.readSource(id) automatically tracks the
read Source for live updates, including missing or denied reads. A component
reading another Source needs no extra depend() call or trusted repository lookup
to recover its storage path:
load({ view }) {
return { title: view.readSource("/tour.page")?.data.title ?? "Unavailable" }
}
For visitor output, prefer ctx.view.readSource(id) and View queries; see
Views.
app.access.can(principal, target, capability) makes an explicit access check.
app.assets.read(source) reads an authored asset and requires an appropriate
embedding access check by the caller. publishDerived(source, name, blob)
retains the original asset's access boundary. app.assets.publish(...) publishes
derived assets; do not use it to expose private data as public bytes.
Policy Plugins may synchronously implement access(app) to supply the single
base Source access policy, grants(target, { principal }, app) to return
additional capabilities or undefined, and
context(source, { principal, subject }, app) to return a View restriction, false to
deny, or undefined to decline. contextualRelations(source, ctx, app) may
asynchronously return relations for an explicit View representation. These
hooks belong to host/domain policy; ordinary components use the admitted View.
A requested subject requires an explicit matching subject in a returned
restriction. See reviewing another Actor’s saved work
for the read-only subject selection contract.
Create plugins/health.plugin.ts:
import { definePlugin, route } from "@pathmx/core"
export default definePlugin({
id: "health",
routes: [route.get("status", () => Response.json({ ok: true }))],
})
On a live server, GET /api/health/status returns {"ok":true}.
route.get, post, put, patch, delete, options, and all each accept
(path, handler). The handler receives ServerContext and returns a Response
or Promise<Response>. Relative paths mount below /api/<plugin-id>/; a leading
/ declares an application-public path. For example, route.get("/health", ...)
serves /health. Both forms honor the host mount. A namespaced handler's trailing
/* matches descendants; public wildcards are not supported. GET also handles
HEAD. A path with no matching method returns 405. Paths must be canonical, without
query strings, fragments, traversal, or encoded separators.
Application-wide handlers run before credential resolution. They do not implicitly authorize the
caller. ctx.request and ctx.url describe the request. ctx.path is relative to the
Plugin mount for namespaced handlers and the pathname for public handlers;
await ctx.session() resolves admission, and await ctx.readAccess(source)
checks that request's read access. To construct a viewer-filtered query:
route.get("pages", async (ctx) => {
const view = ctx.view(await ctx.session())
return Response.json(await view.query("sources", { from: "/" }), {
headers: { "Cache-Control": "private, no-store" },
})
})
This is a route entry to add to a Plugin's routes array. Do not return
ctx.sources.all() to a visitor: it is a trusted repository read.
ctx.serve(view, { basePath }) serves an admitted View under a mount.
ctx.actorHome() and ctx.actorAssets.create(relativePath, blob) / delete(path)
operate on the admitted Actor's Home.
await ctx.mutate(async tx => { ... }) commits through the Source transaction
pipeline. It is a trusted, unscoped server capability: authenticate and authorize
the request before using it. Prefer Actions for normal
user operations. A live route is not automatically a static endpoint;
publish explicitly contributes finite public GET snapshots when needed.
The Bun CLI host uses a ten-second idle timeout for ordinary HTTP requests. A handler that waits without sending a response can have its connection closed before it returns; the Core live stream is exempt. Return a pending status promptly and let the client check progress, or use a durable job for background work. A custom server owns its transport timeout; other hosting adapters may have different limits.
A component or query that reads Plugin-owned state records its dependency:
ctx.depend({ type: "plugin", plugin: "applause", key: "room" })
After a route commits a change to that state, call ctx.changed("room") before
returning its response. The Plugin id comes from the route's owner; the route
supplies only its local key. For example, an authenticated same-origin route in
an applause Plugin can write its SQL table and refresh the count:
route.post("clap", async (ctx) => {
if (ctx.request.headers.get("Origin") !== ctx.url.origin)
return new Response("A same-origin request is required.", { status: 403 })
if ((await ctx.session()).type !== "authenticated")
return new Response("Sign in to clap.", { status: 401 })
const db = ctx.database!
await db.run(`INSERT INTO ${db.tablePrefix}claps DEFAULT VALUES`)
ctx.changed("room")
return new Response(null, { status: 204 })
})
The table and its migration belong to that Plugin. Authorize the domain operation
before writing, and call changed only after the write succeeds. The method
returns void: it invalidates dependent query and compiled caches immediately
and refreshes affected views through the existing live connection. Fresh HTTP
reads work without a live connection; an old in-flight read cannot restore a
valid stale cache.
Keys carry no state or authority. Each viewer re-reads with current admission and the Plugin's normal filtering. Notifications may coalesce, so the projection must represent current state rather than depend on receiving every write. A reconnected viewer reads current state; this is no durable event log. The invalidation reaches readers of this host process. Multiple hosts need their own coordination for shared external storage.
changed belongs to route ServerContext. Actions, jobs and CLI contexts retain
their existing completion and invalidation contracts.
Declare deterministic documents once for live serving and static publication:
import { definePlugin, route } from "@pathmx/core"
export default definePlugin({
id: "catalog",
routes: [
route.document("/catalog.txt", {
contentType: "text/plain; charset=utf-8",
headers: { "Cache-Control": "public, max-age=300" },
fallback: "authored",
async render(ctx, app) {
return (await app.sources.discoverable(ctx.view))
.map((source) => ctx.location.url(source.path).href)
.join("\n")
},
}),
],
})
render(ctx, app) returns a standard response body or null, directly or as a
Promise. Core owns status, content type, HEAD handling, and publication. The
renderer receives no request or mutation methods. Output must depend only on its
admitted Sources and location; use a handler for request-specific output.
Application documents use an anonymous View, including when the visitor is signed
in. Source-owned documents use the admitted View live and an anonymous View during
publication. A mounted View also restricts document reads. A subject-selected
observation retains its admitted View for both kinds of document, so every read
keeps the reviewer, subject and context restrictions. ctx.location provides
origin (a URL), basePath, pathname(path), and url(path); use it to construct
URLs that honor the live or publication mount.
fallback: "authored" lets an exact authored file or page replace this document.
It does not suppress conflicts between Plugins or ambiguous Source ownership.
The built-in llms.txt, robots.txt, and sitemap.xml use this policy.
Create plugins/short.plugin.ts:
import { definePlugin, route } from "@pathmx/core"
export default definePlugin({
id: "short",
sourceRoutes(source) {
if (typeof source.data.short !== "string") return []
return [
route.redirect(`/${source.data.short}`, {
source: source.id,
params: { via: "qr", remove: null },
}),
]
},
})
With short: welcome on paths/guides/start.page.md, a GET to
/welcome?ref=a&ref=b&via=old&remove=1 returns a temporary 307 redirect to
/guides/start.page?ref=a&ref=b&via=qr, with Cache-Control: no-store.
Incoming keys and repeated values survive unless params replaces them with an
authored string or removes every occurrence with null. Incoming parameters
cannot select the destination Source. Core derives the canonical page URL from
source, which is a page Source id. HEAD returns the same headers with no body;
other methods return 405.
A redirect can also define onRedirect({ request, source, destination }, app) for
live analytics. Core awaits the callback after admission on GET only, logs failures,
and preserves the redirect. The callback cannot replace the response. HEAD, denied
requests, and static publication never invoke it. A callback that needs SQL must
use its Plugin's declared database; the route itself requires no SQL.
Static publication automatically creates an HTML redirect page for each public alias with a public target. Its small script applies the same query rules. The fallback link works without JavaScript and includes authored parameters; it cannot merge the incoming query string without JavaScript. Static output does not record live visits.
routes and sourceRoutes(source) accept documents, redirects, and live handlers.
sourceRoutes is pure and synchronous; derive declarations from the indexed
Source and keep request work in the handler or callback. Core removes previous
claims on Source edits, moves, or deletion and replaces transaction batches
atomically. The hook's Source owns every returned route. Core checks that owner
before invoking a Source handler or document; redirects also require access to
their target. An alias grants no access. Unavailable Sources follow the normal
404 behavior, with private response handling for authenticated reads.
Public claims cannot overlap ordinary Sources, assets, or reserved Core paths
(/api, /actions, /.pmx, and /~). Conflicts identify their owners in lint
and block publication. Source transactions reject newly introduced conflicts
before writing. A conflicting direct file edit keeps the server running but makes
the ambiguous route unavailable, without retaining the previous destination.
ctx.serve(view, { basePath }) serves declarative documents and redirects under
that View's restrictions. It does not redispatch live Plugin handlers with an
unrestricted server context.
Most Plugins should use Auth and the admitted
View. A custom admission Plugin can contribute Core's final CredentialHost
contract directly. Provider and admission composition belongs to the portable
@pathmx/auth/credentials entry point, including
createCredentialHost(provider, admission).
| Provider method | Contract |
|---|---|
signIn | GET link { method: "get", href } or null. |
ready(directory) | Initialize with a CredentialDirectory; returns Promise<void>. |
resolve(request) | Return { binding: { actor, identity }, signOut } or undefined. Identity contains provider, subject, optional email and claims; signOut is a POST link or null. |
fetch(request, directory, context?) | Handle provider endpoints and return a Promise<Response>. Persist the prepared Actor binding with successful proof/session state. |
close() | Release resources; returns Promise<void>. |
ActorAdmission.bind(actors) returns a directory with prepare(identity) and
admit(binding). prepare resolves a current { actor, identity, meta } candidate
before proof delivery and again when proof is accepted. admit rechecks the exact
Actor binding retained by a session. Authentication proves an identity; admission
determines whether that binding currently belongs to this app. Re-resolving an email
after session creation must never retarget an old credential to another Actor.
A direct CredentialHost implements async ready(actors),
resolve(request, actors, context?), fetch(request, actors, context?), and
close(). The optional request context provides the trusted socket peerAddress
and a hosting-adapter-resolved clientAddress, never unchecked forwarding headers.
Routes can delegate credential handling through ctx.handleCredentials().
A base access policy returned by access(app) implements synchronous
resolve(principal, target, inspect?), authorize(principal, change), and
canEmbed(container, dependency). resolve returns visibility
(public, private, or denied), capabilities (read, write, execute),
and optional blocked, owning target, and reason. authorize validates a
proposed create/update/delete and throws to reject it. Optional home,
homeActorId, diagnostics, and validate support personal paths and policy
validation. A Plugin-level validateSources(prepared, app) hook rejects invalid
cross-Source final states before trusted writes. prepared.changes preserves
operation intent and parsed before/after Sources; prepared.get() reads through
the proposed final repository. Only one base policy can be installed.
Supplemental grants stay beneath View restrictions and cannot bypass a
blocked base-policy target. A context restriction may implement read(source),
actions({ name, target }), and effects({ operation, id }) predicates.
Restrictions narrow authority; they do not grant it. Verify these integrations
with denied, anonymous, and admitted callers before exposing their routes.
addAsset(...assets) accepts the following document asset shapes:
| Type | Fields |
|---|---|
stylesheet | href and optional media; or authored source, optional media and layer; or generated name, text, optional media and layer. Generated CSS defaults to pmx.plugins. |
style | Inline text. |
script | href, authored source, or inline text with optional name; all accept optional module. |
preload | href, as, optional contentType and crossorigin. |
title | text. |
meta | attrs, a string record such as { name: "description", content: "..." }. |
link assets accept an attrs string record, for example:
ctx.addAsset({
type: "link",
attrs: { rel: "alternate", type: "text/markdown", href: "/index.page.md" },
})
Attribute values are escaped. Links with identical attributes deduplicate regardless
of attribute order; distinct alternate representations remain separate. Use
stylesheet and preload assets for their existing bundling and loading behavior.
Prefer addBrowserBundle(entrypoint, name) for TypeScript, TSX, and imported
CSS. It atomically contributes the bundle's JavaScript and initial styles.
Keep secrets out of browser entrypoints and imported modules.
app.assets.load(url) loads through the environment, and
publish(name, blob, contentType?) returns a published asset URL.
Documents and redirects declared with route.document and route.redirect
automatically contribute their finite static representations. For example:
import { definePlugin, route } from "@pathmx/core"
export default definePlugin({
id: "public-status",
routes: [
route.document("status", {
contentType: "application/json",
render: () => JSON.stringify({ ok: true }),
}),
],
})
Save as plugins/public-status.plugin.ts. /api/public-status/status returns the
same JSON live and in a static publication without a second registration.
A Plugin's publish(ctx, app) hook can report ctx.unsupported(source, message)
when an authored use has no static representation. For a live handler that also
supports an explicit anonymous snapshot, ctx.route(pathname) opts that finite
GET response into publication. It does not make request-dependent handlers or
mutations work on a static host. Prefer declarative documents for deterministic
content. See publishing for host and output choices.
Declare database only when the Plugin needs private operational state.
Use Sources and Actions for authored or personal content.
The host must support SQL; the Bun host stores it at
db/<plugin-id>.sqlite beside the project manifest. db/ is durable and
.pathmx/ is disposable generated output.
import { definePlugin } from "@pathmx/core"
export default definePlugin({
id: "sync-status",
database: {
migrations: [
{
id: "001-status",
sql: (prefix) => `CREATE TABLE ${prefix}status (name TEXT PRIMARY KEY, value TEXT NOT NULL);`,
},
],
},
async setup({ database }) {
if (!database) throw new Error("sync-status requires its declared database")
await database.run(
`INSERT OR IGNORE INTO ${database.tablePrefix}status (name, value) VALUES (?, ?)`,
["sync", "idle"],
)
},
})
SqlDatabase is available only to its owning Plugin through supported contexts.
| Method | Result |
|---|---|
all<Row>(sql, params?) | Promise of readonly rows. |
get<Row>(sql, params?) | Promise of one row or undefined. |
run(sql, params?) | Promise of { changes, lastInsertRowId? }. |
exec(sql) | Promise of void; execute SQL without parameters. |
transaction(async database => result) | Promise of the callback result within a database transaction. |
Parameters are an array or named record of string, number, bigint,
Uint8Array, or null. Bind values instead of interpolating SQL.
Migrations have unique ids and nonempty SQL; append migrations and never edit,
remove, or reorder applied history. PathMX opens databases lazily, applies
migrations transactionally, and closes connections with the app. Ignore private
db/ in Git and back it up when continuity matters.
database.tablePrefix is an empty string for separate Bun databases and a
host-validated identifier prefix for shared SQL storage. Prefix every owned table
and index in migrations and queries. Migration factories receive this prefix;
their empty-prefix SQL determines the migration checksum. Hosts requiring a
prefix reject plain-string migrations. Bind data values as parameters; only the
host-provided identifier prefix belongs in interpolated SQL.
Bun hosts resolve an explicit stateRoot, then PATHMX_DB_DIR, then the project
db/ directory. Relative values resolve against the project root. Use the same
location for the server and CLI; portable Core receives its resolved location
without reading process environment. A standalone Source directory with no owning
package and a name other than paths uses its own sibling <directory-name>.db/,
so unrelated directories cannot share durable state accidentally. For example:
import { PathMX } from "@pathmx/core/bun"
const app = await PathMX({
root: "/srv/course/paths",
stateRoot: "/srv/course-db",
})
Administrative commands for this host use
PATHMX_DB_DIR=/srv/course-db bun run pmx analytics status when Analytics is
installed. Keep SQL storage outside Sources and disposable output.
Create plugins/reading-admin.plugin.ts:
import { definePlugin } from "@pathmx/core"
export default definePlugin({
id: "reading-admin",
commands(group, ctx) {
group.command("pages").action(async () => {
const { view } = await ctx.inspect()
console.log(
JSON.stringify(await view.query("sources", { from: "/" }), null, 2),
)
})
},
})
Run bun run pmx reading-admin pages. group is a Commander command mounted
below the Plugin id. ctx.inspect(actor?) lazily opens the local project. Its
view simulates anonymous access by default, or the supplied Actor. Local
administrator inspection separately exposes all repository Sources through
sources.get(id), sources.all(), and sources.resolve(pathname), including
Sources that the simulated View cannot read. pagePath(id) returns a page-capable
Source's canonical path; routeDiagnostics() reports route conflicts.
events.definitions() exposes registered Event metadata. The host closes the
app when the command ends.
Use ctx.mutate() to change Sources from a local administrator command. It stages
a Source transaction, validates the resulting project, checks for stale storage,
and commits through Core:
await ctx.mutate(async tx => {
const source = await tx.readSource("/reading/index")
tx.source(source.id).data({ title: "Reading room" })
})
Pass { dryRun: true } as the second argument to stage and validate the same
transaction without committing it:
await ctx.mutate(async tx => {
await tx.create("/reading/new.lesson", "# A new lesson\n")
}, { dryRun: true })
Dry runs check Source and Block schemas, final-state Plugin validators, Source
identity conflicts, and current storage. They return an empty applied list and
do not update the repository or publish live changes. The callback must stage
its writes through tx; the option cannot undo unrelated side effects inside
the callback. A preview does not reserve its destinations for a later command.
While pmx dev or pmx serve runs for the repository, the command still runs
locally, but the server commits its transaction. The callback plans against the
command's index. The server commits only if every Source the plan read is
unchanged; otherwise the command fails and can be rerun. Later plans and
ctx.inspect() in the same command see each committed result. Before loading
Plugins for inspection or opening a command's database, the CLI confirms the
server is reachable and uses the same state directory. A mismatch fails before
Plugin setup or command code can write local state.
ctx.events.emit()
likewise admits Events through that server, whose worker runs the resulting
jobs. ctx.host is true in that case. Leave external effects to the server's
jobs then, rather than performing them in the command:
await ctx.events.emit(Queued, { delivery: id })
if (!ctx.host) await deliverNow(id)
This local repository authority is independent of the inspection View's Actor.
It does not represent an authenticated server request. Keep Actor-bound server
mutations in server.mutate(); use the command transaction for project maintenance.
The command context also exposes pathsRoot, projectRoot, stateRoot,
plugin, log, host, and the optional isolated database. To scaffold editable
files, call this inside a command action:
await ctx.installFiles(
[{ root: "paths", path: "reading/index.md", content: "# Reading\n" }],
{ existing: "preserve" },
)
root is paths or project. Installation rejects duplicate or escaping paths
and rolls back partial writes. Different existing content is an error by default;
existing: "preserve" keeps it. Results contain destination and a status of
created, unchanged, or preserved.
- Custom Plugin guide — build, validate, preview, and share.
- Plugin configuration — reusable packages and options.
- Core API — portable hosts and environments.
- Actions, Queries, and Views — domain operations and admitted reads.
A Plugin can declare a portable server slice for a host that serves static pages.
The host selects it explicitly; ordinary export does not instantiate it or open
its database. publishServer(app) receives trusted authoring context and returns:
publishServer(app) {
return {
module: new URL("./published-server.ts", import.meta.url),
data: publicSnapshot(app),
database: { migrations: [{ id: "001", sql: "CREATE TABLE counts (name TEXT PRIMARY KEY, count INTEGER);" }] },
routes: [{ method: "POST", path: "/api/counts/collect" }],
}
}
This is a hook on a Plugin whose ID is counts; publicSnapshot is that Plugin's
own admission-aware projection. Keep private Sources and executable authoring
schemas out of the snapshot. The module exports create(data, database) returning
fetch(request) and, optionally, inspect(request) for host-authenticated reads
and maintain() for bounded durable maintenance. Types are exported from
@pathmx/core/publish. The runtime gets the projected snapshot and a SqlDatabase,
not a second repository engine. Database migrations use the shared checksum ledger
through migrateDatabase from @pathmx/core/database.
A supporting host sets runtime.has(pluginId, "published-server") during its
publication pass. A Plugin may include its client after its server snapshot has
been prepared; the capability does not permit collection during export. Routes
must remain under /api/<plugin-id>/. Live Actions, credentials, Source writes,
and arbitrary live hooks are not implied by this declaration.
The Cloudflare adapter supplies SQLite-backed Durable Objects with serialized SQL operations, rollback-capable asynchronous transactions, and positional SQL parameters. Named parameters, nested transactions, and integers outside JavaScript's safe range fail explicitly. The Analytics Plugin is a complete consumer using the same browser protocol, admission projection, migrations, and query functions on Bun and Cloudflare.