v0.15.0

Released May 25, 2026 · All releases

Composable embedding. The v0.15.0 milestone delivers the cluster of runes and infrastructure that makes refrakt's "embed anything inline" story real: a body-only drawer rune with xref-as-trigger (), an expand rune that inlines registered entities (), a snippet rune that embeds project files as syntax-highlighted code (), and the supporting plumbing that ties them together: $page.*/$file.* page variables (), generic file roots for namespaced file resolution (), unconditional plan-entity registration (), and configurable xref resolution with patterns and decoupled entity/URL lookup ().

Changes

  • Drawer rune: a body-only rune that opens its content as a slide-in panel from an xref-as-trigger in prose (SPEC-060). A cross-reference to the drawer's id anywhere on the page opens the drawer; the body is registered as a page-scoped entity rather than rendered inline.
  • Ships in two states: a no-JS <details>-style fallback that works without scripts, and a progressively-enhanced <dialog> with slide-in animation, scroll-lock, a keyboard shortcut, URL-hash sync, and back-button integration.
  • New EntityRegistration.scope: 'page' | 'site' field distinguishes page-local entities (drawer bodies) from site-wide ones, so a drawer registered on one page doesn't leak into another page's xref namespace.
  • @refrakt-md/types: EntityRegistration.scope.
  • @refrakt-md/runes: {% drawer %} schema + transform + drawer pipeline; engine config for both rendered states.
  • @refrakt-md/behaviors: drawer behavior (<dialog> enhancement, shortcut, hash sync, back-button).
  • @refrakt-md/lumina: drawer CSS for the no-JS and JS states.
  • File roots: configurable named directories that file-reading runes can reach via namespace:filename syntax (SPEC-063).
  • A new top-level fileRoots field in refrakt.config.json registers named directories that Markdoc partials (and, when SPEC-062's v2 lands, the snippet rune) can resolve from:
  • Plugins can declare their own file roots via a new Plugin.fileRoots field on the plugin shape (paths relative to the plugin package's own directory). User-config and plugin-registered roots merge at load time: user config wins any collision (with a dev warning); plugin-vs-plugin namespace collisions throw at plugin load.
  • The namespace site is reserved for future site-level resolution. Empty namespace, absolute paths, and traversal escapes are all rejected with clear errors.
  • Backwards-compatible: existing unprefixed {% partial file="footer.md" /%} references continue to resolve from each site's _partials/ directory.
  • @refrakt-md/types: RefraktConfig.fileRoots, Plugin.fileRoots.
  • @refrakt-md/runes: assertFileRootNamespaceAllowed, plus LoadedPlugin.fileRoots / MergedPluginResult.fileRoots so adapters can introspect plugin-contributed roots.
  • @refrakt-md/content: readFileRoots, resolveUserFileRoots, mergeFileRoots, validateNamespacedReference, plus the new fileRoots option on SiteLoaderOptions, VirtualSiteLoaderOptions, LoadContentFromTreeOptions, and VirtualRefraktLoaderOptions.
  • createRefraktLoader automatically reads refrakt.config.json#/fileRoots, merges with plugin roots, and threads the result through the loader. Diagnostics (e.g. shadowed plugin namespaces) print to stderr.
  • Page variables: add $page.dir, $page.slug, $page.title, and $file.path; rename $page.filePath to $page.path (breaking).
  • $page.path — content-root-relative file path, POSIX-normalized (replaces $page.filePath)
  • $page.dir — directory portion of $page.path ("" for content-root pages, no trailing slash)
  • $page.slug — last segment of $page.url (for index pages, the directory name; "" for the homepage)
  • $page.title — $frontmatter.title when present and non-empty after trimming, else the first H1 in the page AST (depth-first, descending into rune children), else undefined
  • $file.path — project-root-relative source file path, POSIX-normalized (project root is the directory containing refrakt.config.json)
  • Breaking: $page.filePath is removed. Authored content that referenced $page.filePath must use $page.path instead. A grep of the refrakt corpus showed zero usages; external sites that adopted it should rename in the same step they upgrade.
  • Adapters (SvelteKit, Eleventy, Editor) now thread the project root through to the content loader so $file.path works out of the box. Hosts using loadContentFromTree directly can pass projectRoot on the options bag; when omitted, $file.path falls back to the content-root-relative path.
  • Plan plugin: unconditional scan of plan.dir, entity registration with sourceFile + extract, dynamic plan: file-root namespace (SPEC-064).
  • The plan plugin's register pipeline hook now performs an unconditional scan of the project's plan.dir after processing site-loaded pages. Every parseable plan entity (spec, work, bug, decision, milestone) found on disk is registered into the EntityRegistry, regardless of whether the file is part of any site's content tree. This is what makes the {% expand "SPEC-023" /%} rune (SPEC-066) work for plan content that isn't published to the site.
  • sourceFile — project-root-relative POSIX path to the source .md file.
  • extract — a closure that returns the top-level plan rune AST node from a freshly-parsed source file, or null if the file's structure has been edited away from the expected shape. Consumed by {% expand %} for inline substitution.
  • Site-load registrations win any duplicate (they have a real sourceUrl); the scan skips files whose entity is already in the registry. Files with no parseable plan rune (READMEs, notes) are silently skipped — the filename convention is a hint, not a filter, so files like arbitrarily-named.md still register if they contain a valid {% spec id="..." %} rune. Duplicate IDs across two plan files surface as an error naming both file paths.
  • sourceFile?: string — project-root-relative path to the source .md file backing the entity. Populated by plugins that scan disk; consumed by content-embedding runes.
  • extract?: (parsedSource) => Node | null — extracts the entity's top-level AST node from a freshly-parsed source file. Paired with sourceFile.
  • Runs once per build before any other hook, giving plugins access to the user's config and the ability to register file-root namespaces dynamically (when the right path can't be statically declared on Plugin.fileRoots). The plan plugin uses both: it reads plan.dir from the config and registers plan: pointing at the user's actual plan directory.
  • Plugin.fileRoots: { plan: '../../plan' } was NOT added. That static declaration would point at the wrong directory for npm-installed users (node_modules/plan/ rather than the user's project-root plan/). The plan plugin doesn't ship plan content — users have their own — so the namespace path is fundamentally per-project. Dynamic registration via configure is the correct mechanism.
  • The register hook still emits the existing site-load registrations for plan pages published to a site; the scan is additive.
  • Snippet rune: embed a project file as a syntax-highlighted code block (SPEC-062). Core rune; composes transparently inside {% codegroup %}, {% diff %}, and any future fence-consuming container via pre-resolve.
  • Implementation as AST preprocessor. Snippet is not a transform-time rune — every {% snippet %} tag is replaced with a Markdoc fence node before the schema-driven transform runs. The fence carries the file's resolved content, the inferred (or explicit) language, and data-snippet-source / data-snippet-title / data-snippet-lines attributes for downstream tooling.
  • By transform time, no snippet tags remain — only fences. Container runes that match fence (codegroup, diff, future runes) consume them transparently with no per-rune awareness of snippet. The standalone form's <figure class="rf-snippet"> chrome is applied by a post-transform wrap step that only fires for <pre data-snippet-source> elements not descended from a fence-consuming container output.
  • New preprocess pipeline phase. PluginPipelineHooks gains a preprocess hook that runs per page on the parsed Markdoc AST before the transform. The PreprocessContext extends PipelineContext with projectRoot and sandbox so file-reading preprocessors (snippet, future macros, future build-time include resolvers) have what they need; variables from the transform config aren't available pre-transform. Hook signature:
  • Core's corePipelineHooks registers the snippet preprocess hook through the existing createCorePipelineHooks factory — exercises the hook contract from within core, validating it as a general extension point.
  • Sandbox enforcement (in packages/runes/src/lib/read-file.ts): absolute paths rejected, traversal escapes rejected, symlinks escaping the project root rejected, missing files / directories rejected. All errors produce build errors that name the resolved path and the referencing page; line-range clamps produce warnings.
  • <pre> data-attribute pass-through. The fence node transform (packages/runes/src/nodes.ts) now forwards data-* attributes from the fence node to the rendered <pre>. This is how snippet markers (data-snippet-source, etc.) survive the transform so the wrap step can find them.
  • Docs site dogfood. New page at site/content/runes/snippet.md linked from the "Code & Data" sidebar section. The page renders live snippets of actual files in this repository, demonstrates composition with codegroup and diff using real source files, and ends with a recursive view-source-of-itself example via {% snippet path=$file.path lang="markdoc" /%} — the snippet docs page literally embeds its own source markdown.
  • Lumina ships baseline .rf-snippet and .rf-snippet__title styling.
  • Cross-reference resolution: configurable URL patterns, decoupled entity/URL lookup, per-segment encoding, and data-target-type propagation (SPEC-065).
  • The xref resolver now supports a xrefs: XrefPattern[] array in refrakt.config.json that maps unresolved IDs to URLs via regex + template. Compiled once per build via compileXrefPatterns (exported from @refrakt-md/runes).
    1. Entity lookup — find the entity in the registry (exact ID, then name/title). Captures metadata (label, type) regardless of whether a URL is available. 2. URL resolution — use the entity's sourceUrl if present and non-empty; otherwise iterate xrefs patterns, first match wins; otherwise unresolved. 3. Rendered anchor carries data-xref-id, data-xref-source="registry"|"pattern", and data-target-type="{entity-type}" when the entity was matched (drawer / future addressable runes query this).
  • EntityRegistration.sourceUrl is now optional. Empty strings passed at registration are normalized to undefined so plan content registered without a URL (SPEC-064) flows correctly through pattern resolution. The byTypeAndUrl index skips entries without a URL.
  • Substituted template values are encoded per URL segment (split on /, encode each segment, rejoin) so path-shaped captures like (?<path>[a-z0-9/-]+) preserve their slash structure: a pattern matching docs:guide/intro now resolves to .../guide/intro, not .../guide%2Fintro.
  • Authors using corePipelineHooks directly continue to work unchanged; the new createCorePipelineHooks({ xrefPatterns }) factory is used by the content loader to inject compiled patterns. createRefraktLoader reads refrakt.config.json#/xrefs and compiles automatically.
  • The old data-entity-type / data-entity-id attributes on resolved anchors are replaced by data-target-type / data-xref-id. No production code outside of the resolver itself referenced the old names.
  • Expand rune: inline a registered entity's body into the current page (SPEC-066). {% expand "SPEC-023" /%} pulls the target entity's content into the host page at an author-chosen heading level=, so a single .md source can render as its own canonical page and also embed inline elsewhere.
  • New generic data-outline-scope walkers isolate the embedded content's table-of-contents and namespace its heading IDs, so the embedded outline doesn't collide with the host page's headings or TOC.
  • Includes a canonical-link affordance back to the entity's own page.
  • Works for plan content that isn't published to the site, via the plan plugin's unconditional entity registration (SPEC-064).
  • @refrakt-md/runes: {% expand %} schema + resolver + expand pipeline; outline-scope walkers.
  • @refrakt-md/lumina: expand CSS (embedded-entity framing + canonical-link affordance).

Drawer rune

SPEC-060shipped

Drawer rune

A modal panel rune that holds richer content than a tooltip but lighter than a navigation away. Inspired by Linear's pattern where in-page references expand to explainer panels rather than forcing the reader to leave context.

The drawer rune is body-only: it declares an addressable panel by ID, and any {% ref %} to that ID becomes a trigger. Triggers can appear anywhere — in prose, in a navbar, in multiple places at once — and the inline-flow case ("spec mentioned mid-sentence") is automatic because xrefs are already inline. Built on <dialog> for accessibility; progressive enhancement renders the body as a visible in-flow block without JS.

runesbehaviorsuicorexref

Problem

Some inline references are too rich for a tooltip and too peripheral to navigate to. The author wants a quick-look explanation of a concept mentioned in passing — a sidebar of context — without sending the reader down a routing rabbit hole.

Today the choices are:

  • Link out — loses context, breaks flow, and the destination page usually has too much surrounding chrome for what was a small clarification.
  • Inline accordion — keeps context but balloons the source page when expanded, and doesn't read naturally for "click this term to learn more" patterns.
  • Custom dialog code — every site rolls its own; no shared accessibility behavior; not authorable from Markdown.

The drawer pattern is the missing primitive: a side-attached panel that overlays the page, holds rich content (text, code blocks, embedded runes), and closes cleanly back to where the reader was.


Design Principles

Body-only rune; triggers are xrefs. The drawer rune holds only its body. It registers itself as an addressable entity; {% ref "drawer-id" /%} becomes the trigger. No trigger section in the rune, no hr delimiter, no "button or anchor" inference — the trigger is whatever xref normally produces.

Triggers anywhere; multiple triggers free. Because the drawer is ID-addressable, the same drawer can be referenced from N places on the same page without coordination. The body is authored once; refs to it are normal authoring.

Inline-flow triggers, naturally. xref is an inline tag, so trigger refs flow inline in prose without breaking paragraphs. The "spec mentioned in a sentence" pattern works straightforwardly because the trigger machinery is already inline-shaped.

Progressive enhancement is automatic. Without JS, the drawer body renders as a visible in-flow block at its authored position. xref triggers are normal anchors pointing at #drawer-{id}; clicking scrolls to the drawer. Readers always have access to the content; the side-panel UX is the enhancement.

<dialog> for semantics. With JS, the behaviors layer enhances the drawer body into a <dialog> and intercepts xref clicks. esc-to-close, focus trap, and inert background come from the platform.

Keyboard shortcut as first-class. The view-source pattern wants a . shortcut (GitHub convention). The rune accepts a shortcut attribute; the behaviors layer registers a global listener — opt-in per drawer.

Page-scoped IDs. A drawer's ID is meaningful within its page, not site-wide. Two pages can each declare id="auth" without collision. Cross-page drawer triggers are out of scope for v1 (see Open Questions).


Authoring Surface

Syntax

Authentication is handled by {% ref "auth-explainer" /%}, which uses OAuth.

{# elsewhere on the page #}

{% drawer id="auth-explainer" title="Auth system" shortcut="." %}
A quick explainer with **rich content**:

- code blocks
- embedded runes
- {% expand "SPEC-023" /%}

Whatever fits the side panel.
{% /drawer %}

The xref {% ref "auth-explainer" /%} resolves to the drawer's address (#drawer-auth-explainer) and renders as a normal anchor. Without JS, clicking scrolls to the drawer at its authored position. With JS, the body is enhanced into a <dialog> and the xref click opens it via showModal().

Attributes

AttributeTypeDefaultMeaning
idstringrequiredStable ID. Used for entity registration and rendered as the drawer element's id="drawer-{id}".
titlestring—Panel heading. Renders in the dialog header (with JS) or as a heading at the drawer's position (without JS).
shortcutstring—Keyboard shortcut to open the drawer (".", "k", "cmd+k", etc.).
side"right" | "left" | "top" | "bottom""right"Edge the panel slides from (when enhanced).
size"sm" | "md" | "lg""md"Panel width (or height for top/bottom).
headingLevel1–6autoHeading level for the title in no-JS rendering. Auto-detects from outline position if omitted (same convention as nav/hint).

Content model

Plain block content. Whatever the author writes between the opening and closing tags is the drawer body — rich Markdown, embedded runes, code blocks, anything. No delimiter; no trigger section.


Output Contract

Drawer body

<section class="rf-drawer rf-drawer--side-right rf-drawer--size-md"
         id="drawer-auth-explainer"
         data-rune="drawer"
         data-side="right"
         data-size="md"
         data-shortcut=".">
  <header class="rf-drawer__header">
    <h3 class="rf-drawer__title">Auth system</h3>
    <button class="rf-drawer__close" type="button" aria-label="Close" hidden>×</button>
  </header>
  <div class="rf-drawer__body">
    <!-- body content -->
  </div>
</section>

BEM selectors:

  • .rf-drawer — drawer container
  • .rf-drawer__header — title + close-button row
  • .rf-drawer__title — title heading (level via headingLevel or auto-detected)
  • .rf-drawer__close — close button (hidden by default; behaviors layer reveals when enhanced)
  • .rf-drawer__body — content container
  • .rf-drawer--{right|left|top|bottom} — slide direction modifier (matches the modifiers.side shape used by every other rune in the catalog: the engine derives --{value} BEM modifiers from source: 'meta' modifiers, not --{name}-{value})
  • .rf-drawer--{sm|md|lg} — size modifier
  • [data-side] / [data-size] are the canonical variant selectors — themes (including Lumina) prefer these because they avoid potential cross-namespace BEM collisions if the rune ever grows more modifiers; the BEM classes ride along for theme overrides that want class-style selectors

Data attributes:

  • data-rune="drawer" on the container
  • data-side, data-size, data-shortcut as authored

xref resolved to a drawer

<a class="rf-xref rf-xref--drawer"
   href="#drawer-auth-explainer"
   data-xref-id="auth-explainer"
   data-xref-source="registry"
   data-target-type="drawer">
  the auth subsystem
</a>

The data-target-type="drawer" attribute is the marker the drawer behaviors layer queries for. It's a neutral convention: any rune that wants to be "addressable as a trigger" can set data-target-type="{rune-name}" on its resolved xrefs. Future popover, modal, sheet, etc. runes can adopt the same hook-point without expanding xref's surface.


xref Integration

Drawer entity registration

The drawer rune's register pipeline hook adds the panel as a page-scoped entity:

registry.register({
  id: 'auth-explainer',
  type: 'drawer',
  scope: 'page',
  sourceUrl: `${pageUrl}#drawer-auth-explainer`,
  data: {
    title: 'Auth system',
    side: 'right',
    size: 'md',
    shortcut: '.',
  },
});

scope: 'page' is a new field on EntityRegistration (see Engine Changes). Page-scoped entities are namespaced internally by page URL so two pages can each declare id="auth" without conflict. Site-scoped entities (specs, characters, etc.) continue to be globally addressable.

xref resolution

The existing xref resolver finds the drawer entity by ID, gets sourceUrl for the href, and propagates the entity type as data-target-type on the rendered anchor. The type → data-target-type propagation is a small generic extension to the resolver (see SPEC-065 — fits the same data-attribute convention already established there).

Same-page resolution: sourceUrl is #drawer-{id}; xref renders a same-page anchor.

Cross-page resolution: would yield /path/to/page#drawer-{id}; the destination page's behaviors layer would read the URL hash on load and open the drawer. Mechanically supported; UX deferred (see Open Questions).


Behavior

A drawer behavior in @refrakt-md/behaviors handles activation. On page load:

  1. Enhance each drawer: for every .rf-drawer element, replace its <section> shell with a <dialog> (or move its contents into a hidden sibling <dialog>; either shape works as long as the no-JS-visible / JS-hidden contract holds). Reveal the close button (hidden removed).
  2. Wire trigger clicks: query for a[data-target-type="drawer"] whose href="#drawer-{id}" matches a drawer on this page. Attach a click interceptor that calls event.preventDefault() and dialog.showModal().
  3. Wire shortcut listeners: for each drawer with data-shortcut, register a global keydown listener.
  4. Wire URL hash sync: on load, if location.hash === "#drawer-{id}", open the matching drawer.

Runtime behavior

  • Esc key → handled natively by <dialog>.
  • Backdrop click → close. Detected by event.target === dialog.
  • Close button click → dialog.close().
  • Shortcut press → open the drawer.
  • Opening a drawer → update location.hash to #drawer-{id} (replaceState, so back-button closes it).
  • Closing a drawer → clear the hash (replaceState).
  • Browser back button → if the hash had been set by drawer open, the popstate fires; behaviors layer closes the open drawer.

Shortcut handling rules

  • Listener uses keydown at the document level.
  • Skips when focus is inside input, textarea, select, or any [contenteditable] element.
  • Multiple drawers with the same shortcut: last-registered wins. Dev mode logs a warning naming both rune locations.
  • Modifier keys: cmd+, ctrl+, alt+, shift+ prefixes supported; bare keys are also valid.

Multiple drawers on one page

Each drawer is independent. Opening drawer A while drawer B is open closes B first (matches native <dialog> modality — browsers enforce single-modal).


Progressive Enhancement

Without JS

  • Drawer body renders as a styled in-flow <section> at its authored position
  • Title renders at the level chosen by headingLevel or auto-detect
  • Close button is hidden (hidden attribute)
  • xref triggers are normal anchors; clicking scrolls to the drawer via fragment navigation
  • Shortcut listener doesn't activate (no JS)
  • Readers get full content access; just no side-panel UX

The in-flow rendering should be visually distinct from regular page content so the reader understands "this is set-aside material" — a callout treatment, a left border, a different background, etc. Theme responsibility; Lumina ships a sensible default.

With JS

  • Drawer body is enhanced into a <dialog> (visible only when open)
  • Close button reveals
  • xref clicks open the dialog via showModal()
  • Shortcut and URL hash sync activate

Styling for the dual-mode

The drawer's CSS targets both .rf-drawer (in-flow state) and dialog.rf-drawer[open] (panel state) — same BEM classes, different layout properties depending on context. Themes can override either.


Engine Changes

  • New rune schema in packages/runes/src/tags/drawer.ts — block content model, no delimiter
  • Config entry in packages/runes/src/config.ts keyed Drawer: modifier-from-meta for side/size/shortcut, structural injection for the header (title + hidden close button)
  • register pipeline hook (extension of corePipelineHooks or new drawer-specific hook) that scans each page for drawer runes and registers them as page-scoped entities
  • EntityRegistration interface gains optional scope?: 'page' | 'site' (default 'site' for back-compat). Registry implementation internally namespaces page-scoped entries by page URL so IDs are page-local.
  • xref resolver: propagate the resolved entity's type as data-target-type on the rendered anchor (generic, not drawer-specific — small extension to SPEC-065's resolver)
  • CSS in packages/lumina/styles/runes/drawer.css covering both no-JS callout and dialog states
  • Behavior in packages/behaviors/src/drawer.ts, exported via packages/behaviors/src/index.ts
  • Registry entry in packages/svelte/src/registry.ts is not needed — drawer is identity-transform + progressive enhancement

Acceptance Criteria

  • {% drawer id="..." %} produces the drawer body as a visible in-flow <section> at its authored position
  • id attribute is required; missing id fails content load with a clear error
  • title attribute renders as a heading in the drawer header
  • headingLevel attribute controls the title's heading level
  • When headingLevel is omitted, the title's level is auto-detected from outline position (same convention as nav/hint)
  • shortcut attribute writes data-shortcut and registers a global keyboard listener when JS loads
  • side attribute applies rf-drawer--{value} modifier class and data-side="{value}" (matches engine modifier-from-meta convention)
  • size attribute applies rf-drawer--{value} modifier class and data-size="{value}"
  • Drawer element gets id="drawer-{author-id}" so fragment navigation lands at it
  • Drawer rune registers a page-scoped entity (type: 'drawer', scope: 'page') in the registry with sourceUrl: "{page-url}#drawer-{id}"
  • {% ref "drawer-id" /%} on the same page resolves to <a href="#drawer-{id}" data-target-type="drawer">…</a>
  • Multiple xrefs to the same drawer on the same page each resolve to the same anchor target
  • Drawer IDs are page-scoped: two pages each declaring id="foo" do not collide in the registry
  • xref resolver propagates the resolved entity's type as data-target-type on the rendered anchor (generic, used by drawer; available to any future addressable rune)
  • EntityRegistration interface gains an optional scope?: 'page' | 'site' field (default 'site')
  • Without JS: drawer body is visible in-flow; close button is hidden; clicking xref scrolls to drawer via fragment navigation
  • With JS: drawer body is enhanced into a <dialog>; xref clicks open the dialog via showModal()
  • Esc key closes the panel (native dialog behavior)
  • Backdrop click closes the panel
  • Close button in the drawer header closes the panel and is revealed when JS loads
  • Keyboard shortcut opens the panel; skipped when focus is in input/textarea/select/contenteditable
  • Multiple drawers with the same shortcut emit a dev-mode warning; last-registered wins
  • URL hash deep linking: #drawer-{id} opens on load; opening updates hash via replaceState; closing clears the hash
  • Browser back button closes an open drawer when the open-action set the hash
  • Lumina CSS implements both no-JS (in-flow callout) and JS (panel) rendering for all four sides and three sizes
  • refrakt inspect drawer shows the expected HTML
  • CSS coverage tests pass for .rf-drawer* selectors
  • Authoring docs cover: rune syntax, attribute reference, the xref-as-trigger pattern, progressive enhancement model, keyboard shortcut conventions, the data-target-type convention as a primitive available to other runes

Out of Scope

  • Cross-page drawer triggers — mechanically supported (cross-page xref resolves to /page#drawer-{id}, destination's URL hash sync opens it on load), but the UX questions (smooth transition? confirm before navigating away? what if the destination page is large?) deserve their own design. v1 is page-local triggers only.
  • Detached body (drawer body rendered somewhere other than its authored position) — body always renders in-flow. If the use case appears, that's a new design.
  • Multiple simultaneous open drawers — <dialog>.showModal() enforces single-modal; opening one closes any other. Stacked modal dialogs are an antipattern.
  • Nested drawers — out of v1; the modal stack semantics get murky.
  • Custom animation hooks — slide-in is CSS transitions on [open] + transform; authors can override in their theme but no JS-level animation API.
  • Persistent (non-modal) drawer / sidebar pattern — different primitive (always-visible drawer that pushes content). Could be a future {% sidebar %} rune.
  • Lazy-loaded body content — body is rendered at build time, present in DOM. Lazy-load could be a v2 enhancement.
  • Trigger element customization per-occurrence — xref handles this. If a particular ref wants different label/styling, the xref attributes (label=, etc.) cover it.
  • Drawer body heading shift to align with surrounding outline — body headings render at their authored levels; the drawer's headingLevel only affects the title. Adding level=-style demotion (like expand) could be a future addition if real authoring practice wants it.

Open Questions

How should the title's auto-detected headingLevel work? Likely one level deeper than the drawer's outline position — if the drawer sits at H2 body context, the title becomes H3. Spell out the exact walk during implementation; align with the existing nav/hint auto-level convention.

Should drawer entities surface in EntityRegistry consumers (graph views, "what links to what" tooling)? Probably yes, with scope: 'page' so consumers can filter. A graph showing "all drawers on this page" or "all xrefs that target drawers" is a useful debugging affordance.

Cross-page drawer triggers — when? Defer to a follow-up once page-local usage settles. The hard part is UX (transition feel, scroll position on landing), not mechanics. Open the question explicitly when someone has a concrete use case.

Should shortcut accept modifier combos (cmd+.) in v1? Recommend yes — the parser is trivial and bare-key shortcuts have a tiny surface.

For multi-drawer pages, should each shortcut listener be scoped to a region, or always global? Recommend global for v1. Region-scoped requires focus-tracking complexity.

Should closeOn exist? No. Opting out of standard close affordances is an accessibility regression. Reconsider only if a specific use case (confirmation dialog requiring explicit dismissal) appears.

Should the scope: 'page' | 'site' field live in this spec or a separate registry-evolution spec? This spec is the motivating consumer. Recommend adding it here; future page-scoped entity types can adopt the same field.

Should xref-to-drawer get a distinct visual treatment by default (different from xref-to-spec, etc.) via the rf-xref--drawer modifier? Probably yes — drawer refs are interactive in a different way than refs to content pages, and a subtle visual cue (a chevron, an underline style) helps readers understand "click for context here, not navigation." Theme decision; Lumina ships a default.

How does the drawer interact with frameworks that manage their own dialog stacks? Probably fine — <dialog> is native and frameworks don't typically intercept it. Adapter snapshot tests should cover a page with a drawer to catch surprises.


References

  • SPEC-065 — configurable xref resolution (the xref-as-trigger pattern; data-target-type extends SPEC-065's resolver)
  • SPEC-066 — expand rune (composes inside drawer bodies; the inline embed pattern)
  • SPEC-061 — page variables ($file.path for the view-source-of-current-page composition)
  • SPEC-062 — snippet rune (the view-source body in the canonical "page source drawer" example)
  • packages/behaviors/ — pattern for progressive-enhancement runes (accordion, tabs)
  • packages/runes/src/config.ts — engine config schema (modifiers, structure injection)
  • packages/lumina/styles/runes/ — reference CSS for existing runes
  • <dialog> MDN documentation — native modal semantics this rune builds on

Expand rune

SPEC-066shipped

Expand rune

A core rune that takes a registered entity ID and substitutes the entity's source content inline. Generic over entity types — works for plan specs, characters, organizations, events, or anything else a plugin registers with embeddable content. Symmetric with xref: same registry, same lookup chain, different output mode (inline content rather than a link).

The motivating use case is in-context plan-content previews via {% drawer %}{% expand "SPEC-023" /%}{% /drawer %}, but the same machinery serves any plugin that registers entities with extractor information. The accompanying plan-plugin work to scan and register plan entities unconditionally is captured in SPEC-064.

Lives in @refrakt-md/runes alongside xref. The name expand avoids collision with the existing {% embed %} rune for media URLs.

runestransclusionregistrycore

Problem

Authors writing about a domain entity often want to show it in context, not just link to it. A blog post about an architecture decision reads better with the ADR inline; a worldbuilding companion site benefits from rendering a character's full description next to the prose mentioning them; a docs page can illustrate a spec it implements by embedding the spec directly.

The existing primitives don't cover this:

  • xref / {% ref %} produces a link. Reader navigates away to see content.
  • partial / {% partial %} transcludes a file by path. Authoring requires knowing the exact file path; doesn't compose with the registry; doesn't carry the entity's identity into the output.
  • Hand-copying the content drifts the moment the canonical version is edited.

Every refrakt plugin that registers entities into the EntityRegistry already has the data structure that would support inline embedding — title, status, source location, type. What's missing is a generic transclusion mechanism that consults the registry, fetches the source, and substitutes it inline with cross-cutting affordances (TOC isolation, heading-ID namespacing, cycle detection, canonical-link).

The architecture mirrors xref exactly: xref resolves an ID to a URL; expand resolves the same ID to content. They share the registry as the lookup substrate but diverge on what they need from the entity record.


Design Principles

Symmetric with xref. Same registry lookup chain (exact ID → name match → fail). Same entity-type-hint disambiguation. Different output: xref renders an anchor, expand substitutes the entity's source AST. Authors who know xref already know how expand resolves.

Plugin-owned extraction. Plugins know their own entity shapes — a plan rune is one file with one top-level entity; a character rune might be one of several siblings in a definition file. The plugin provides the extractor (extract(parsedAst) → embeddable subtree); expand calls it. The rune itself stays type-agnostic.

Implicit embeddability. If a registration includes sourceFile + extract, the entity is embeddable. If not, expand fails with a clear "type does not support embedding" error. No separate embeddable: true flag; the presence of extraction information is the signal.

Local files only. expand reads from the file system, sandboxed to the project root (same security boundary as snippet from SPEC-062). No remote source fetching at build time. Users embedding plan content hosted externally on a third-party service (trace, etc.) need the source files locally; xref handles the external-URL case for refs without requiring local source.

Canonical-link via the xref resolver. The .rf-expand wrapper includes a canonical-link affordance pointing at the entity's authoritative URL. That URL is resolved via the same chain as xref: registry sourceUrl → SPEC-065 patterns → unresolved. So an entity embedded inline with expand and linked elsewhere with ref produces a consistent canonical URL.

One source of truth for presentation. expand wraps the substituted content but does not construct its own header. The embedded rune (plan rune, character rune, etc.) renders itself exactly as it would standalone, plus expand's thin wrapper. Status badges, titles, structure — all come from the embedded rune's own output. No second presentation layer to drift.

Neutral conventions for cross-cutting behavior. TOC isolation and heading-ID namespacing are driven by a neutral data-outline-scope attribute that any rune can set — not by expand-specific markers. The TOC walker and heading-ID walker consume the attribute generically; expand is one of several potential producers (future asides, sidenotes, quotes, panels, drawers may all opt into the same convention). Keeps coupling out of cross-cutting machinery.


Authoring Surface

Syntax

{# Basic embed — substitutes the entity inline #}
{% expand "SPEC-023" /%}

{# Optional explicit demotion — embed is meant to act as a sub-section
   of the host page rather than a peer document. Rare; advanced use. #}
{% expand "SPEC-023" level=3 /%}

{# Type-hint disambiguation when the same string matches multiple types #}
{% expand "Veshra" type="character" /%}

{# Show a visible "View canonical" link — useful when the embed is a peek
   and the canonical lives elsewhere (e.g. on trace) #}
{% expand "SPEC-023" canonical=true /%}

{# Headline composition — in-context preview in a drawer, with
   canonical link to bridge back to the full view #}
{% drawer title="SPEC-023" shortcut="s" %}
View spec

---

{% expand "SPEC-023" canonical=true /%}
{% /drawer %}

Attributes

AttributeTypeRequiredDefaultMeaning
primarystringyes—Entity ID or name to expand. Same shape as xref's primary.
levelnumbernounsetOpt-in heading demotion. When unset (default), embedded headings keep their natural levels — the embed renders as its own document. When set to N, demote all headings by N - 1 so the top heading becomes Hn. Rarely needed; useful only when the embed is genuinely meant to act as a sub-section of the host.
typestringno—Entity type hint for disambiguation. Constrains both registry lookup and (informally) extractor selection.
canonicalbooleannofalseWhen true, render a visible .rf-expand__canonical-link linking to the entity's canonical URL (resolved via the xref chain). When false, no visible affordance — but data-canonical-href on the wrapper is still populated for themes and tooling.

Output Contract

<!-- Default — canonical=false, no visible link, data attribute populated -->
<section class="rf-expand"
         data-rune="expand"
         data-entity-id="SPEC-023"
         data-entity-type="spec"
         data-outline-scope="SPEC-023"
         data-canonical-href="https://trace.refrakt.md/user/repo/specs/SPEC-023">

  <!-- The full rendered output of the embedded plan rune,
       with heading levels demoted by (level - 1). -->
  <section class="rf-spec" data-status="accepted">
    <header class="rf-spec__header">…</header>
    <div class="rf-spec__body">…</div>
  </section>
</section>

<!-- With canonical=true — visible link appended after the embedded content -->
<section class="rf-expand"
         data-rune="expand"
         data-entity-id="SPEC-023"
         data-entity-type="spec"
         data-outline-scope="SPEC-023"
         data-canonical-href="https://trace.refrakt.md/user/repo/specs/SPEC-023">

  <section class="rf-spec" data-status="accepted">
    <header class="rf-spec__header">…</header>
    <div class="rf-spec__body">…</div>
  </section>

  <a class="rf-expand__canonical-link"
     href="https://trace.refrakt.md/user/repo/specs/SPEC-023">
    View canonical
  </a>
</section>

BEM:

  • .rf-expand — wrapper around the embedded entity
  • .rf-expand__canonical-link — optional affordance linking to the entity's canonical URL (rendered only when canonical=true)

Data attributes (all on the wrapper):

  • data-rune="expand"
  • data-entity-id — the resolved entity's ID
  • data-entity-type — the entity type (spec, character, etc.)
  • data-outline-scope — value set to the entity ID; marker for the neutral TOC-isolation + heading-ID-namespacing convention (see Heading Handling)
  • data-canonical-href — mirrors the canonical link's href for tooling
  • data-source — "registry" (entity was found in registry) — same convention as xref

The inner content (.rf-spec, .rf-character, etc.) is whatever the embedded entity's rune produces. Themes can target embedded-specific styling via .rf-expand .rf-spec (etc.) without affecting standalone rendering.


Resolution

For each {% expand %} placeholder found during postProcess:

  1. Registry exact-ID match: registry.getById(typeHint?, primary). Optionally filtered by type hint.
  2. Registry name fallback: case-insensitive match against entity name/title. Same algorithm as xref.
  3. Embeddability check: the resolved entity must have both sourceFile and extract in its registration. If not, fail the build:
    Error: expand "SPEC-023" — entity type "spec" does not support embedding.
    
    The entity exists in the registry but has no embeddable source. Likely
    causes: the plugin that owns this entity type doesn't provide expand
    support, or the registration omitted sourceFile/extract.
    
    Referenced from: site/content/blog/auth.md:24
    
  4. Source read (cached per build): read sourceFile from the project root. Sandbox rules from SPEC-062 apply — no traversal, no symlink escape.
  5. Parse (cached per build): Markdoc.parse(source).
  6. Extract: call the entity's registered extract(parsedAst) → Markdoc.Node | null. If it returns null, fail the build:
    Error: expand "SPEC-023" — extractor returned no content.
    
    The source file (plan/specs/SPEC-023-foo.md) was read but the plugin's
    extractor couldn't locate the entity's content in the parsed AST. The
    file may have been edited out-of-sync with the registry.
    
  7. Heading processing: applied by the generic heading-ID walker (not expand-specific) once the wrapper has data-outline-scope set. Each heading node's id is prefixed with {scope-value}-- (e.g., SPEC-023--). If level=N was set on the expand rune, expand additionally shifts the heading level by N - 1 (clamping at H6 with a build warning, see Heading Handling below).
  8. Cycle check: if the resolved (type, id) is already in the current resolution stack, fail with the cycle path.
  9. Substitute: replace the expand placeholder with the (heading-processed) extracted subtree, wrapped in .rf-expand markup. data-outline-scope on the wrapper (set to the entity ID) is the neutral marker the TOC walker and the heading-ID walker both honor.
  10. Canonical-link resolution: resolve the entity's canonical URL via the standard xref resolver chain (SPEC-065: registry sourceUrl → patterns → unresolved). Set data-canonical-href on the wrapper regardless of the canonical attribute value — themes and tooling can always reach the URL. When canonical=true, additionally render a .rf-expand__canonical-link <a> element with the resolved href. When canonical=true and the URL resolves unresolved (no registry hit, no pattern match), render the link with rf-xref--unresolved styling (or omit the visible link — see Open Questions).

The substituted subtree is then re-processed by the host page's normal transform pipeline — embedded plan runes execute their own transforms, embedded refs resolve via the same xref chain, etc. expand doesn't pre-resolve any of that; it just provides the embedded AST as a peer of inline content.


Heading Handling

Embedded entities are quoted documents, not subsections of the host. An embedded spec keeps its own H1, its own structural hierarchy, its own anchor links. The host page's heading outline and TOC stay clean of the embed's structure. Three mechanisms cooperate to deliver this:

1. No demotion by default

Embedded headings retain their natural levels. A spec's H1 stays H1, its H2s stay H2s. This is structurally correct — HTML5 sectioning content (<section>, <article>, <dialog>) scopes headings to their containing element, so an H1 inside .rf-expand (or inside a drawer's <dialog>) represents the heading of that section, not the document root. Screen readers and the modern outline algorithm handle this correctly.

2. TOC builder respects data-outline-scope

The host page's TOC walker (used by {% toc %} and similar tooling) skips any heading node descended from an element with a data-outline-scope attribute. The host's TOC reflects only the host's structure; the embed's headings never appear in it.

data-outline-scope is a neutral convention, not an expand-specific marker. The attribute means "this subtree is a sub-outline boundary"; the TOC walker doesn't need to know about expand or any other rune. Other runes can adopt the same attribute and get the same behavior — a future {% sidenote %}, {% aside %}, or {% quote %} rune that contains its own headings can set data-outline-scope and be TOC-isolated automatically. Drawers (rendered as <dialog>) can set it too for explicit isolation, even though <dialog> is already a sectioning root semantically.

expand sets data-outline-scope on its wrapper, with the value set to the resolved entity's ID (e.g., data-outline-scope="SPEC-023").

3. Heading IDs are namespaced via the scope value

Heading anchor IDs inside an element with data-outline-scope are prefixed with the scope value plus --:

<section class="rf-expand" data-outline-scope="SPEC-023" data-entity-id="SPEC-023">
  <section class="rf-spec">
    <h1 id="SPEC-023--auth-system">Auth system</h1>
    <h2 id="SPEC-023--acceptance-criteria">Acceptance criteria</h2>
    …
  </section>
</section>

The same generic mechanism: a heading-ID walker sees data-outline-scope on an ancestor element and prefixes the ID accordingly. expand doesn't own the namespacing logic — it just sets the scope attribute.

Two consequences:

  • A host page with its own "Acceptance criteria" heading and an embedded spec with the same heading text don't collide on ID — host gets #acceptance-criteria, embed gets #SPEC-023--acceptance-criteria.
  • Deep links into embedded content are possible: /host-page#SPEC-023--acceptance-criteria points at the specific heading within the embed.

ID normalization (kebab-case, ASCII slug, etc.) follows the standard heading-ID conventions for the suffix portion; the prefix is the literal value of the nearest enclosing data-outline-scope attribute.

Optional explicit demotion (level=) — merges embed into host outline

For the rare case where an embed is genuinely meant to act as a sub-section of the host — say, a layout file that embeds a shared "section template" entity that should be styled as a sub-section — the author can opt in with level=N. This signals a different intent: the embed is no longer a quoted document; it's authored sub-content.

When level=N is set, expand does not emit data-outline-scope. Three consequences follow:

  1. TOC walker includes the embedded headings. They appear in the host's TOC as sub-section entries, which is what the author wants when treating the embed as part of their structure.
  2. Heading IDs go through the normal slugifier (no scope-value prefix). Authors accept the same ID-collision risk they'd have if they'd authored the content inline. A future heading-uniqueness linter would surface real collisions; that's a generic concern, not expand-specific.
  3. Heading levels are shifted by N - 1 so the embed's top heading becomes Hn.

The two modes — peer-document (default) and sub-section (level= set) — are deliberately a binary choice. The orthogonal case (demote visually but stay TOC-isolated) is better handled in CSS by targeting .rf-expand h1, .rf-expand h2, etc., than by expanding the attribute surface.

Demotion table:

level=data-outline-scopeTOC includes?H1 →H2 →H3 →
unset (default)set to entity IDno (isolated)H1H2H3
1not emittedyes (sub-section)H1H2H3
2not emittedyes (sub-section)H2H3H4
3not emittedyes (sub-section)H3H4H5

(Note: level=1 is effectively "merge into host outline without level shift" — same TOC behavior as level=2/3, but no demotion of the headings themselves. Rare but valid.)

When demotion is requested and would push headings past H6, clamp to H6 with a build warning:

Warning: expand "SPEC-023" at site/content/blog/example.md:24 — heading
demotion (level=4) would push 2 heading(s) past H6. Clamped to H6:
  - "Acceptance criteria" (was H4, would be H7)
  - "Out of scope" (was H4, would be H7)

Consider reducing level= or restructuring the source's heading depth.

Demotion still applies only to heading nodes in the Markdoc tree, not to rune-emitted custom elements styled as headings.

Visual treatment is theme-owned

Two H1s on a page (the host's and the embed's) can look visually odd if a theme styles all H1s identically. Themes handle this by targeting .rf-expand h1, .rf-expand h2, etc. and applying an embedded-heading treatment (smaller scale, boxed, "quoted" styling). Lumina ships a default; theme authors can override. Critically, this is a presentation concern — the underlying markup keeps natural heading semantics, themes adjust the visual hierarchy.


Cycle Detection

Each resolution maintains a stack of (type, id) tuples. When entering an expand resolution, push the resolving entity's (type, id). When finishing, pop. If pushing would create a duplicate, fail with the cycle path:

Error: expand cycle detected.

Cycle: SPEC-023 (spec) → WORK-051 (work) → SPEC-023 (spec)

Referenced from: site/content/blog/example.md:24

The stack is per-page-render, not global — embedding the same entity on two different pages is fine; embedding it inside itself transitively is the bug we catch.

Self-references ({% expand "SPEC-023" /%} inside SPEC-023) are caught the same way: pushing SPEC-023 when SPEC-023 is already at the top of the stack.

Note on {% ref %} inside embedded content: refs are links, not expansions. They don't push onto the resolution stack; they just resolve to a URL during postProcess. SPEC-023's body can {% ref "ADR-007" /%} and ADR-007's body can {% ref "SPEC-023" /%} back without any cycle concern. Only expand participates in cycle detection.


Plugin Extractor API

For an entity to be embeddable, its plugin must register the entity with two additional fields beyond the existing EntityRegistration shape:

interface EmbeddableEntityRegistration extends EntityRegistration {
  sourceFile: string;                              // project-root-relative path
  extract: (parsed: Markdoc.Node) => Markdoc.Node | null;
}
  • sourceFile — project-root-relative path to the source file. Used by expand for the file read; sandbox rules apply.
  • extract — given the parsed Markdoc AST of sourceFile, return the subtree to substitute. Returns null if the entity can't be located (treated as a build error by expand).

Both fields are optional from the existing EntityRegistration interface's perspective — entities without them are simply not embeddable. Pages (registered by core), headings (registered by core), and any other registry contributors that don't add these fields will fail expand with the standard "does not support embedding" error.

Extractor patterns

  • One entity per file (plan, certain worldbuilding plugins): extractor walks the parsed AST for the top-level rune tag matching the entity's type. Trivial.
  • Multiple entities per file (e.g. a character-list page defining several characters): extractor walks for the rune tag whose id attribute matches the entity's ID. Plugin's job to manage uniqueness.
  • Composite entities: a plugin can return an arbitrarily-shaped Markdoc subtree. expand doesn't constrain the structure.

Engine Changes

Registry interface

packages/types/src/registry.ts (or EntityRegistry type definition): the EntityRegistration interface gains optional sourceFile and extract fields. Plugins that opt in supply both; plugins that don't continue to register entities as before.

Resolver

New module packages/runes/src/expand-resolve.ts, structurally similar to xref-resolve.ts. Walks the page's renderable tree looking for data-rune="expand" placeholders, performs the resolution chain above, substitutes content.

Rune schema

New file packages/runes/src/tags/expand.ts, following xref's two-phase pattern: transform emits a placeholder span; postProcess resolves and substitutes.

Core pipeline hooks

packages/runes/src/index.ts — corePipelineHooks gains an expand-resolution step in postProcess, ordered after xref (so embedded refs can be resolved within the substituted content) or before (so refs in the host page resolve first). Open question — see below.

CSS

packages/lumina/styles/runes/expand.css: minimal styling for the wrapper and canonical-link affordance. The substantive content is styled by whichever rune is embedded (plan, character, etc.).


Acceptance Criteria

  • {% expand "SPEC-023" /%} substitutes the referenced entity's source content into the host page
  • Resolution chain: registry exact-ID → registry name → embeddability check → unresolved
  • Unresolved IDs fail the build with closest-match suggestions (Levenshtein ≤ 2)
  • Entities without sourceFile + extract produce a "does not support embedding" build error
  • Page entities (registered by core) cannot be expanded — produce the not-embeddable error
  • type attribute constrains registry lookup to the named type
  • sourceFile is read with the same sandbox rules as snippet (SPEC-062); traversal escape rejected
  • Source-file content is cached per build (multiple expands of entities from the same file = one parse)
  • Extractor returning null fails the build with "extractor returned no content"
  • Embedded heading levels are preserved by default (no demotion when level= is unset)
  • expand wrapper carries data-outline-scope="{entityId}" only when level= is unset (peer-document mode)
  • When level= is set, data-outline-scope is not emitted; the embed participates in the host outline as authored sub-content
  • The neutral data-outline-scope convention is consumed by two generic walkers, not by expand-specific code
  • Heading-ID walker prefixes IDs of headings inside any data-outline-scope subtree with {scope-value}-- (e.g., SPEC-023--acceptance-criteria)
  • When level= is set, heading IDs go through the normal slugifier (no prefix); ID collisions with host headings become the author's responsibility (caught by future heading-uniqueness lint, not by expand)
  • Heading IDs use the standard slugifier for the suffix portion (peer mode) or the full ID (sub-section mode)
  • TOC walker (used by {% toc %} and similar tooling) skips headings descended from any element with data-outline-scope set, regardless of which rune set it
  • When level= is set, embedded headings appear in the host TOC at their demoted level
  • level=N attribute opts into explicit demotion: headings shift by N - 1 while preserving relative hierarchy
  • Demoted headings clamped at H6 emit a build warning naming source location and affected heading text
  • Lumina ships a default heading treatment for .rf-expand h1/h2/etc. that visually distinguishes embedded headings without altering their semantic level
  • Cycle detection: (type, id) stack tracks open expansions; duplicate push fails with cycle path
  • Refs ({% xref %} / {% ref %}) inside embedded content do not participate in cycle detection
  • Output wrapper has .rf-expand class with data-rune, data-entity-id, data-entity-type, data-canonical-href, data-source="registry"
  • data-canonical-href on the wrapper is populated whenever the xref chain resolves, regardless of the canonical attribute value
  • canonical=true renders a visible .rf-expand__canonical-link <a> element
  • canonical=false (default) does not render a visible link element
  • .rf-expand__canonical-link href resolves via the xref chain (SPEC-065)
  • When canonical=true and the URL is unresolved, the link element renders with rf-xref--unresolved styling (preserving the affordance while signaling the resolution gap)
  • Refs and other runes within the substituted content resolve normally via the host page's pipeline
  • Composes with drawer (SPEC-060) — {% drawer %}{% expand %}{% /drawer %} works end-to-end
  • Composes with {% expand %} on its own (no drawer) in any host context
  • Lumina ships baseline .rf-expand + .rf-expand__canonical-link CSS
  • EntityRegistration interface gains optional sourceFile and extract fields with documented semantics
  • Authoring docs cover the rune, the no-demotion default, heading ID namespacing, the optional level= opt-in, the TOC isolation behavior, and the relationship to xref / partial / embed

Out of Scope

  • Page-entity embedding. Falls out of the implicit-embeddability design: core doesn't register pages with extractors. If "embed a whole page" becomes a real need, address it with a different primitive (page-embed with explicit layout-region handling) rather than overloading expand.
  • Remote source fetching (HTTP, github://, etc.). File system only. Heavy infrastructure (caching, network policy, build determinism) belongs in its own design.
  • Multi-entity expansion ({% expand "SPEC-023,SPEC-024" /%}). Use multiple {% expand %} runes.
  • Cherry-picking sections of an entity ("just the Acceptance Criteria of SPEC-023"). The extractor returns one subtree; partial extraction is a plugin-by-plugin concern, not a generic rune attribute.
  • Lazy / deferred expansion at render time (vs. build time). Build-time only.
  • Inline editing of embedded content. Read-only by construction.
  • Constructing a separate header / status display in expand itself. The embedded rune renders its own header; expand adds only the wrapper + canonical-link.
  • Density modes / summary rendering. Different presentation densities are a per-rune concern — if plan runes (or any other) want to support a summary mode, that's their feature, not expand's.
  • Backlinks / "where is this expanded from?". Out of scope; future site-graph concern.
  • Cross-site embedding in multi-site monorepos. Each site sees its own registry. Future spec if demand surfaces.

Open Questions

Should the postProcess hook run expand before or after xref resolution on the host page? Two cases to consider:

  • expand runs first → refs inside the substituted content are seen by xref's pass on the host page, so they resolve uniformly with everything else. Cleaner mental model.
  • xref runs first → refs in the host page are resolved before any embeds happen, but embedded content's refs would need a second xref pass after expand to be resolved. Worse: now there are two resolution rounds.

Recommend expand first, xref second. Single resolution pass for refs, regardless of whether they live in original or substituted content.

What should the canonical=true link show when the URL is unresolved? Options: (a) omit the link entirely (treat as canonical=false); (b) render with rf-xref--unresolved styling (visible but not clickable); (c) show as plain text with the entity ID. Current recommendation is (b) — preserves the affordance's existence (reader knows there's a canonical somewhere) while signaling the resolution gap. Authors who'd rather suppress entirely can leave canonical=false for that entity.

Should there be a project-level config to flip the canonical default? A site that consistently wants canonical links visible might want to set it once rather than per-rune. Tempting but adds surface; first ship the per-instance attribute and revisit if real demand surfaces.

Should the wrapper include a "back to host" affordance when the embed lives in a drawer or other framing context? Tempting but couples expand to context (drawer-aware vs not). Recommend no — let the framing context (drawer) provide its own close affordance.

How does expand interact with the tint cascade? Embedded content might come from a plan file with no tint declaration; the host page has its own tint. Recommend: the embed inherits the host's tint at the substitution point. Plan-file authoring shouldn't need to think about tints; they're a presentation-layer concern of the consumer site.

Should expand set data-source="pattern" or any indicator when the entity was found via name-match rather than exact ID match? Symmetric with xref's source attribute. Recommend yes for parity.

Performance: source-file caching across pages. If one source file (plan/specs/SPEC-023-foo.md) is expanded on twenty different pages, we want to parse it once, not twenty times. Per-build cache keyed by sourceFile handles this — same as snippet's caching.

Build-time vs. content-load-time extraction. Could the plugin's extract be called once at content-load (when entities are registered) rather than per-expansion? It would let the registration store the pre-extracted AST and skip per-call extraction. Tradeoff: eager memory cost vs. lazy compute cost. Recommend lazy (per-expand call, but cached) — keeps memory bounded for projects with many registered entities that aren't all embedded.

Should there be a lookup attribute to constrain resolution to exact-ID only (skip the name-match fallback)? Tempting for "fail fast on typos" use cases. Defer — xref doesn't have it either; consistent surface.


References

  • SPEC-060 — drawer rune (primary composition target)
  • SPEC-064 — plan plugin unconditional entity registration (companion spec — the registration work that makes plan entities embeddable)
  • SPEC-065 — configurable xref resolution (used for canonical-link affordance)
  • SPEC-062 — snippet rune (shared sandbox + source-caching machinery)
  • packages/runes/src/tags/xref.ts — symmetric existing rune; expand mirrors its two-phase structure
  • packages/runes/src/xref-resolve.ts — postProcess resolver pattern this spec follows

Snippet rune

SPEC-062shipped

Snippet rune

A rune that renders the contents of a file (path relative to the project root) as a syntax-highlighted code block. Solves the recurring documentation problem of keeping inline code examples in sync with actual source files, and incidentally enables the view-source-of-current-page pattern via {% snippet path=$file.path /%}.

Core rune (lives in @refrakt-md/runes). "Embed a file as a code block" is a universal primitive — useful in tutorials, marketing pages, blogs, world-building sites, anywhere an author wants build-time file embedding. Sits naturally next to codegroup, compare, diff, and datatable in the "Code & Data" category rather than in any plugin's specific surface.

Composes with {% drawer %} from SPEC-060 for the side-panel view-source pattern, depends on $file.path from SPEC-061 for the self-referential case, and — via a pre-resolve preprocessing step — composes transparently inside container runes that consume fenced code blocks ({% codegroup %}, {% diff %}, and any future fence-consuming rune). See Composition below. (Note: $file.path is the project-root-relative path, which matches snippet's project-root sandbox. $page.path exists too but is content-root-relative and the wrong frame for snippet's resolver.)

runesdocscodetransform

Problem

Documentation embeds code examples by copy-paste. The original source file lives somewhere (an examples/ directory, a package's src/, the page itself), and the docs author copies a chunk into a fenced code block. Inevitably:

  • The source file changes; the docs don't notice.
  • Boilerplate around the relevant chunk shifts line numbers; the docs reference is now wrong.
  • The docs example diverges far enough that running it produces different output than the docs claim.

Workarounds (build scripts that splice files into Markdown, custom shortcodes, manual sync rituals) are ad-hoc and project-specific. No primitive in the rune system covers "render this file's contents as a code block, syntax-highlighted, kept in sync at build time."

The same primitive incidentally solves the view-source case (path=$file.path), the "show me my refrakt.config.json" case, and any other "embed this file" need.


Design Principles

Path-based, project-root anchored. path is relative to the project root (where refrakt.config.json lives). This makes paths portable across sites in a multi-site monorepo, and matches authors' mental model of "the project tree."

Sandboxed by default. The resolver rejects paths that escape the project root after normalization. No traversal, no absolute paths, no symlinks pointing outside. Security isn't subtle — explicit rejection.

Build-time resolution. File reads happen during parse/transform. No runtime fetches, no fs calls in the browser. The output is a fully-rendered code block by the time it reaches the renderer.

Compose with existing code rendering. Refrakt already has a code-block rendering path with syntax highlighting (Niwaki). The snippet rune produces the same output shape — it's the source (file contents vs. inline body) that differs, not the output.

Pre-resolve to a fence node, not a transform-time rune. Snippet is implemented as an AST preprocessor: every {% snippet %} tag is replaced by a Markdoc fence node (carrying the file's content + language + attribution markers) before the schema-driven transform runs. This means every container rune that already accepts fences accepts snippets for free — {% codegroup %}, {% diff %}, and any future fence-consuming rune get composition without ever knowing about snippet. The figure+caption chrome is applied as a separate post-transform step that only fires when a snippet renders standalone (not when it's been consumed by a container). Composition is built in, not retrofitted.

Line ranges as first-class. Real example files have boilerplate (imports, type declarations, surrounding context) before the interesting bit. Without lines=, the rune is useful 80% of the time; with it, useful always.


Authoring Surface

Syntax

{# Embed a whole file #}
{% snippet path="examples/button.svelte" /%}

{# Embed a line range #}
{% snippet path="examples/button.svelte" lines="10-25" /%}

{# View source of the current page #}
{% snippet path=$file.path lang="md" /%}

{# Override inferred language #}
{% snippet path="config/refrakt.json" lang="jsonc" /%}

Attributes

AttributeTypeDefaultMeaning
pathstringrequiredPath to file, relative to project root. Rejected if it escapes the root.
linesstringfull fileLine range. Formats: "10-25", "10-" (to EOF), "-20" (from start). 1-indexed, inclusive.
langstringinferred from extensionSyntax-highlighting language hint.

Snippet intentionally has no title attribute. Output is structurally a fence by transform time, and fences don't carry titles. Authors wanting a labelled chrome wrap the snippet in {% codegroup title="..." %} — codegroup's single-fence chrome path produces the title bar without rendering tabs.


Output Contract

Two output shapes depending on context — both flow from the same AST representation (a Markdoc fence node carrying snippet attribution).

Standalone (snippet at document level)

When a snippet renders outside a container that consumes fences, a post-transform step wraps the resulting <pre> in a <figure> with optional caption:

<figure class="rf-snippet" data-rune="snippet"
        data-source-path="examples/button.svelte">
  <pre class="rf-code-block" data-lang="svelte"
       data-snippet-source="examples/button.svelte"><code>...</code></pre>
</figure>

BEM:

  • .rf-snippet — outer wrapper (visual differentiation + provenance attributes)
  • Inner pre/code produced by the existing code-block rendering

Data attributes on the figure:

  • data-rune="snippet"
  • data-source-path — the resolved path (useful for tooling, "edit this file" buttons, etc.)
  • data-lines — the line range, if lines= set

Nested (snippet inside a container rune)

When a snippet is consumed by a container that matches fence nodes (codegroup, diff, future fence-consuming runes), the container's transform sees the snippet's pre-resolved fence the same way it would see a regular fenced code block. The container produces its own chrome (tab panel, diff hunk, etc.) wrapping the resulting <pre>. The <pre> still carries data-snippet-source and data-snippet-lines so tooling can detect provenance; the figure+caption wrapper is not applied.

Net effect: an author writing

{% codegroup title="Button variants" %}
{% snippet path="examples/button-primary.svelte" /%}
{% snippet path="examples/button-secondary.svelte" /%}
{% /codegroup %}

gets the same output as if they'd inlined the two files into fenced code blocks — codegroup builds tabs from each panel, language inferred from each snippet's extension or lang= attribute. No special handling on codegroup's side.


Composition

The snippet rune is implemented as an AST preprocessor rather than a transform-time rune. This is the key design choice that makes composition work without each container rune having to know about snippet.

Pre-resolve preprocessing

After Markdoc parses a page into an AST but before the schema-driven transform runs, a per-page preprocessing step walks the AST. For each {% snippet %} tag node:

  1. Resolve and validate the path (project-root sandbox enforced; see Path Resolution & Sandbox below).
  2. Read the file. Slice by lines= if set.
  3. Determine the language: lang= attribute wins, else extension lookup via the shared lang-map module.
  4. Replace the tag node with a fence node in the AST, carrying:
    • content: the (possibly sliced) file text.
    • language: the resolved language.
    • attributes['data-snippet-source']: the resolved path (relative to project root, POSIX-normalized).
    • attributes['data-snippet-lines']: the lines= value when set.

By the time the transform runs, the AST contains no {% snippet %} tags — only fence nodes (some "regular," some "snippet-derived" distinguishable only by the data-snippet-* attributes).

Container composition

Container runes that match fence nodes (codegroup, diff, and any future fence-consuming rune) see snippets as regular fence nodes. They consume fence.attributes.content for source text and fence.attributes.language for language hint — both fields are populated correctly by the preprocessing step.

The container's transform calls Markdoc.transform(fence, config) on each fence (existing behavior). The result is a <pre> element that still carries the data-snippet-source markers as data attributes. The container's chrome (tab panel for codegroup, diff hunk for diff) wraps that <pre>.

Container runes need no changes to support snippet composition. The only requirement is that they not strip arbitrary data-* attributes from their fence-derived children — which they already don't.

Standalone wrapping

A small post-transform pass walks the renderable tree looking for <pre> elements carrying data-snippet-source. For each one whose nearest ancestor is not a fence-consuming container, it wraps the <pre> in a <figure class="rf-snippet"> for theme styling and tooling (data-source-path lives on the figure). No <figcaption> — snippet has no title; labelled chrome is delegated to {% codegroup title="..." %}.

"Fence-consuming container" is detected by the wrap step via a small allowlist of known container outputs (data-rune="code-group", data-rune="diff"). Future containers can opt in by setting data-consumes-fence on their output, or by being added to the allowlist when they ship.

Concrete composition cases

CompositionWhat happens
{% snippet %} at document levelFigure + caption wrapper applied; pre/code inside.
Two {% snippet %} inside {% codegroup %}Each becomes a tab; tab label from title= or filename. No figure wrapper.
Two {% snippet %} inside {% diff %} (before / after)Pre-resolved into two fences with content extracted; diff computes the hunk-level diff between them. No figure wrapper.
{% snippet %} inside {% drawer %} bodyDrawer renders the snippet's body content as drawer-panel children; the standalone wrap step still applies because drawer isn't a fence-consuming container. The drawer's panel contains the figure+caption snippet block.
Mixed: fenced ```ts block AND {% snippet %} inside the same {% codegroup %}Both become tabs; codegroup doesn't care about the origin.

Why pre-resolve (vs. widening container matchers)

The alternative considered was widening codegroup's / diff's content models to also match snippet tags and extracting source via a small abstraction. Pre-resolve was chosen because:

  • No container changes. Container runes never have to know snippet exists; they keep their match: 'fence' contracts.
  • Future containers get composition for free. A hypothetical {% file-tabs %}, {% reader-pane %}, or {% transcript %} rune that matches fences gets snippet composition without any per-rune work.
  • Single source of truth for "this is a code block." Whether the source came from a triple-backtick fence or a snippet tag, the AST representation is the same fence node by transform time. Less cognitive overhead for downstream tooling.

The cost is one new pipeline phase (preprocess) — see Engine Changes.


Path Resolution & Sandbox

Resolution rules

  1. Project root anchor: the directory containing the active refrakt.config.json. Resolved once at content-load time.
  2. Join + normalize: path.resolve(projectRoot, attributeValue) then path.normalize.
  3. Containment check: resolved path must start with projectRoot + path.sep. Reject otherwise.
  4. Symlink check: fs.realpath(resolved) must also start with projectRoot + path.sep. Reject symlinks escaping the sandbox.
  5. Existence check: fs.statSync(resolved).isFile(). Reject directories and missing files.

Rejection cases (build errors)

  • Absolute path (path="/etc/passwd") — rejected before joining.
  • Traversal (path="../../../etc/passwd") — rejected by containment check after normalization.
  • Symlink escape — rejected by realpath check.
  • Directory — path="src/" rejected with "path must be a file".
  • Missing file — rejected with "file not found at {resolved-path}".

Error format

Error: snippet path "examples/missing.ts" cannot be resolved.

Resolved to: /project/examples/missing.ts
Reason: file not found

Referenced from: docs/getting-started.md:42

Line Ranges

Format

InputMeaning
"10-25"Lines 10 through 25, inclusive
"10-"Line 10 to end of file
"-20"Line 1 through line 20
"10"Single line (line 10 only) — shorthand for "10-10"

1-indexed (matches editor line numbers), inclusive on both ends.

Edge cases

  • Out-of-range start ("500-600" on a 100-line file): build warning, output is empty range → build error suggesting the file's actual length.
  • Out-of-range end ("50-200" on a 100-line file): clamp to file length, build warning naming the clamp.
  • Inverted range ("25-10"): build error.
  • Non-numeric: build error with the malformed input echoed.

Dedent

Out of scope for v1 (see Out of Scope). Authors who need leading-whitespace trimming can use a line range that starts after the indentation level, or wait for a future dedent attribute.


Language Inference

A file-extension → language map shared across the rune system:

ExtensionLanguage
.ts, .tsxtypescript
.js, .jsx, .mjs, .cjsjavascript
.sveltesvelte
.vuevue
.md, .markdocmarkdoc
.jsonjson
.jsoncjsonc
.htmlhtml
.csscss
.yml, .yamlyaml
.tomltoml
.sh, .bashbash
(others)text (no highlighting)

The map lives at packages/runes/src/lang-map.ts, exported from @refrakt-md/runes. Consumers (the snippet rune itself, the inspect tool in packages/cli/, the contracts generator, future runes wanting extension inference) import from there.

@refrakt-md/runes is the right home because:

  • It's already a dependency of every consumer (plugins, CLI, inspect tooling).
  • Plugins can import from runes; runes can't import from plugins (dependency direction is correct).
  • It's "rune-shaped knowledge" — sits alongside the existing rune-utility surface (catalog, SEO extraction, engine config) rather than mixed in with the lower-level transform engine.

lang= always overrides inference.


Engine Changes

New pipeline phase: preprocess

The composition story requires a new per-page hook that operates on the parsed AST before the schema-driven transform runs. Adding it to PluginPipelineHooks:

export interface PreprocessContext extends PipelineContext {
  /** Absolute path to the project root (where `refrakt.config.json` lives).
   *  Used by snippet's resolver as the sandbox anchor; available to any
   *  preprocess hook that needs disk-relative resolution. */
  projectRoot?: string;
  /** Sandboxed filesystem helpers — same shape as the transform-time
   *  `__sandboxReadFile` family, exposed here because preprocess runs
   *  before the transform's config.variables exist. */
  sandbox?: { read: (p: string) => string | null; list: (p: string) => string[]; exists: (p: string) => boolean };
}

export interface PluginPipelineHooks {
  /** Phase 0 — Preprocess.
   *  Runs per page on the parsed Markdoc AST before the transform. Hooks
   *  may rewrite the AST (replace tags with other node types, inject nodes,
   *  resolve include-style references). The returned AST is the one passed
   *  to the transform. Return `undefined` to leave the AST unchanged.
   *
   *  Use sparingly — most concerns belong in transforms or postProcess hooks.
   *  Preprocess is for cases where the rune needs to be invisible to
   *  downstream transforms (e.g., snippet → fence so container runes don't
   *  need per-rune awareness). */
  preprocess?: (
    ast: import('@markdoc/markdoc').Node,
    page: { url: string; relativePath: string; filePath: string },
    ctx: PreprocessContext,
  ) => import('@markdoc/markdoc').Node | void;

  register?: ...;
  aggregate?: ...;
  postProcess?: ...;
}

This is a small but meaningful pipeline extension. Snippet is the v1 consumer (registered through corePipelineHooks since snippet is a core rune); future use cases (custom macros, content rewriters, build-time include resolvers) plug in the same way from plugins.

The content pipeline runs preprocess hooks per page after parse, in the order they're registered (core first, then plugins). The output AST flows into the transform.

Core contributions

  • Preprocess + postProcess hooks join corePipelineHooks in packages/runes/src/config.ts (via the existing createCorePipelineHooks factory introduced in WORK-253). The preprocess hook walks the AST, finds {% snippet %} tag nodes, resolves each one (read file, slice lines, infer language), and replaces it in-place with a fence node carrying the resolved content + data-snippet-* attributes. The postProcess hook walks the rendered tree, finds <pre> elements carrying data-snippet-source, and — when not nested under a known fence-consuming container — wraps in <figure class="rf-snippet"> with the appropriate caption.
  • Rune schema in packages/runes/src/tags/snippet.ts: a thin schema declaring the rune's attributes for tooling (validation, inspect output, snippet-rune docs). The transform function is unreachable in normal operation (preprocess replaces the tag before transform runs); if it does execute it throws a clear error pointing at core's preprocess hook registration. Schema is exported via packages/runes/src/index.ts so it joins the core tags map.
  • Theme config entry added to coreConfig in packages/runes/src/config.ts so refrakt inspect snippet works and the contracts generator picks it up.
  • File-reading helper in packages/runes/src/lib/read-file.ts — sandbox enforcement matching the Path Resolution section (absolute-path reject, traversal reject, symlink reject, existence check, line-range slicing). Designed for reuse by future file-reading runes.
  • CSS in packages/lumina/styles/runes/snippet.css covers .rf-snippet* selectors for the standalone form.

Per-build context for the preprocess hook

Preprocess hooks need file-system access — they run before transform, so the __sandboxReadFile-style variables that runes use at transform time aren't available yet. Core's preprocess hook receives the per-page context augmented with projectRoot and the existing SandboxHooks (read/list/exists) so the snippet resolver can do sandboxed file reads. Plugin-defined preprocess hooks get the same context — generic, not snippet-specific.

Shared utilities

  • Extension → language map in packages/runes/src/lang-map.ts (delivered in WORK-254), exported from @refrakt-md/runes. Used by core's preprocess hook for language inference.
  • Project-root resolution helper lives in packages/runes/src/lib/read-file.ts alongside the file-reading utility. Future file-reading runes (built-time includes, generated-content embeds) reuse it.

Pipeline integration

  • packages/types/src/pipeline.ts: add preprocess to PluginPipelineHooks.
  • packages/content/src/pipeline.ts: extend runPipeline (or a parse-time sibling) to call each registered preprocess hook per page after Markdoc.parse(...) and before Markdoc.transform(...) in site.ts. The hooks operate on a mutable AST (or return a replacement AST).
  • packages/content/src/site.ts: after the existing Markdoc.parse(escapeFenceTags(content)), run preprocess hooks in plugin order, then proceed with extractHeadings, firstH1, and the existing transform.

The wrap step (postProcess) is a regular cross-page pipeline postProcess hook — no new infrastructure required there.


Acceptance Criteria

  • {% snippet path="..." /%} reads the file from project root and renders it as a syntax-highlighted code block
  • Paths are resolved relative to the directory containing refrakt.config.json
  • Absolute paths (/etc/passwd) are rejected with a build error
  • Traversal paths (../../foo) escaping project root are rejected with a build error
  • Symlinks pointing outside project root are rejected with a build error
  • Missing files produce a build error naming the resolved path and the referencing source location
  • Directory paths are rejected with a build error
  • lines="10-25" extracts lines 10–25 inclusive
  • lines="10-" extracts from line 10 to end of file
  • lines="-20" extracts from line 1 to line 20
  • lines="10" extracts just line 10
  • Out-of-range end clamps to file length with a build warning
  • Out-of-range start (entirely past EOF) is a build error
  • Inverted range ("25-10") is a build error
  • Malformed range string is a build error with the input echoed
  • Language is inferred from file extension via the shared lang-map module at packages/runes/src/lang-map.ts
  • lang= overrides inferred language
  • Snippet schema declares no title attribute; title= on a snippet tag is rejected by the schema (or silently ignored at parse time, depending on Markdoc strictness). Captioned chrome is delegated to {% codegroup title="..." %}.
  • data-source-path is set on the standalone figure wrapper to the resolved path (relative to project root)
  • data-lines is set on the standalone figure wrapper when lines= is used
  • {% snippet path=$file.path /%} works end-to-end via the $file.path variable (project-root frame matches the sandbox)
  • CSS in Lumina covers .rf-snippet* selectors
  • Authoring docs cover the rune, sandbox rules, line range syntax, language inference table, and composition behavior
  • A dogfooded rune-catalog page lives at site/content/runes/snippet.md, linked from the _layout.md sidebar nav. The page includes a live example that loads a real file from refrakt's content/source tree, a view-source-of-itself example (path=$file.path), and live composition examples inside {% codegroup %} and {% diff %} — so the reader sees the rune working end-to-end against real content, not stubs.

Composition (pre-resolve)

  • PluginPipelineHooks gains a preprocess hook that runs per page on the parsed AST before the transform
  • The content pipeline calls registered preprocess hooks in plugin order between Markdoc.parse and Markdoc.transform
  • Core's preprocess hook (part of corePipelineHooks) replaces every {% snippet %} tag node in the AST with a fence node carrying the resolved file content, the resolved language, and data-snippet-source / data-snippet-lines attributes as appropriate
  • {% snippet %} inside {% codegroup %} renders as a tab containing the file's content, with the language inferred or set, and no <figure> wrapper applied
  • {% snippet %} inside {% diff %} (two snippets — before and after) renders as a diff with hunks computed from the two files' contents, and no <figure> wrapper applied
  • Mixed children — {% snippet %} and triple-backtick fences in the same {% codegroup %} or {% diff %} — work uniformly (containers don't distinguish them)
  • {% snippet %} at document level (not nested in a fence-consuming container) renders inside the <figure class="rf-snippet"> wrapper (no figcaption — snippet has no title attribute)
  • The wrap step suppresses the figure wrapper when the <pre data-snippet-source> is descended from a known fence-consuming container output (data-rune="code-group", data-rune="diff")
  • The snippet rune schema's transform function is unreachable in normal operation (preprocess replaces the tag before transform runs); if it does execute, it throws a clear error pointing the user at core's preprocess hook registration
  • Tests cover all the composition cases above with real file fixtures

Out of Scope

  • Remote file fetching (HTTPS URLs, github://, etc.) — file system only. Remote fetching has its own concerns (caching, network failures, build determinism) that don't belong here.
  • HMR for referenced files during dev — deferred to SPEC-068. In v1 the host page does not auto-refresh when a referenced source file changes outside the content tree; the author triggers a rebuild by saving any file inside the content tree (or restarting the dev server). Production builds are unaffected — every referenced file is read once at build time and committed to the output. Real dependency-tracked watching is a follow-up spec, intentionally informed by real usage shapes from this rune before committing to a per-adapter contract.
  • Multiple disjoint line ranges (lines="5-10,20-25") — YAGNI. One contiguous range is sufficient for the common case.
  • Dedent — trimming common leading whitespace. Useful when extracting from indented blocks (a method body inside a class). Defer; can be added as a dedent attribute later.
  • Diff rendering (showing two files side-by-side or as a diff) — different rune, different concern.
  • Editable code blocks (live-edit, playground-style) — way out of scope; that's a different primitive entirely.
  • Path namespacing via file roots (SPEC-063's namespace:filename syntax) — initially v1 is project-root-only. Whether to share the resolver with file roots is the most important Open Question below.
  • Nested composition inside other snippet-aware containers — beyond codegroup and diff, no specific containers are prioritized for v1. Future fence-consuming runes get composition for free via the pre-resolve model; the wrap step's container allowlist gets a new entry as each lands.
  • Generic preprocess-hook ecosystem — the new preprocess hook is added as part of this work item but no other plugins are migrated to use it in v0.15.0. Future macro / include / content-rewriting runes can adopt the same hook when they're designed.

Open Questions

Should path accept file-root namespacing once SPEC-063 lands? E.g. path="plan:SPEC-001-foo.md" reads from the plan namespace. Recommend yes — share one resolver so file-finding is consistent across runes. Implementation: ship snippet with project-root resolution in v1, extend to honor namespaces when SPEC-063 lands.

Should syntax highlighting happen at build or render time? Build (matches the existing code-block path; identity transform produces highlighted HTML). Render-time highlighting would require shipping the highlighter to the client, which is wasteful for static content.

What about very large files — should there be a max-size guard? Recommend a soft warning above ~100 KB and a hard error above ~1 MB. Embedding multi-megabyte files in docs is almost certainly a mistake worth flagging.

Should the rune emit a "view original" link to the file? Tempting (helpful for readers who want context beyond the embedded range), but the URL scheme isn't generic — it depends on the project's hosting (GitHub, GitLab, self-hosted). Defer to a theme-level or site-config concern; the rune sets data-source-path so a downstream consumer can build that link.

Caching: should file reads be memoized within a single build? Recommend yes (multiple snippet references to the same file are common — a getting-started page that shows the same package.json twice should read once). Cache key: resolved path; invalidated per build.

Cross-references with the page-source / reflection idea. With snippet shipped, the specific page-source rune dissolves into a one-line {% snippet path=$file.path lang="md" /%}. This spec confirms that direction — no separate page-source rune is shipped.


References

  • SPEC-060 — drawer rune (primary composition target for view-source)
  • SPEC-061 — content variable surface (provides $file.path for the view-source pattern)
  • SPEC-063 — configurable file roots (potential resolver share)
  • SPEC-068 — adapter HMR contract for arbitrary file dependencies (deferred follow-up that closes the dev-experience gap)
  • packages/lumina/styles/runes/code-block.css — existing code-block CSS to compose with
  • packages/runes/src/ — home of the shared lang-map module
  • Niwaki syntax highlighting documentation — the highlighting layer this rune feeds into

Content variable surface — completing $page, $file, and rationalizing the namespaces

SPEC-061shipped

Content variable surface — completing $page, $file, and rationalizing the namespaces

Audit and complete the author-facing Markdoc variable surface exposed by refrakt's content pipeline. The pipeline today exposes $frontmatter.*, a partial $page.*, and a partial $file.* — but $page.* is undersized (no dir, slug, title, and the existing filePath key is misnamed), and $file.* lacks a project-root file path that consumers like the snippet rune need for disk-relative resolution. Extend both namespaces, document the whole public surface clearly, and settle the convention that double-underscore-prefixed variables are pipeline internals.

The page-vs-file split matters: $page.* describes the page as a content artifact (its place in the content tree, its URL, its slug); $file.* describes the source file as a disk artifact (where it lives on disk, when it was committed). The two have different roots of reference — $page.path is relative to the content directory, $file.path is relative to the project root — and consumers reach for whichever frame matches their concern.

The headline motivation is the view-source pattern from SPEC-062 (which needs $file.path for snippet's project-root-sandboxed resolution), but the rationalization benefits every rune that wants page or file context.

pipelinecontenttransformvariables

Problem

The variable surface today is partial and inconsistent.

The $page.* namespace is undersized. It exposes url, filePath, and draft (packages/content/src/site.ts:154). It's missing the keys URL-aware runes actually want: the directory portion, the URL slug, and the page title. Authors have to do string manipulation in Markdoc expressions to derive them — when they're derivable trivially at the pipeline level.

$page.filePath is misnamed. Every other path-like field in refrakt uses path (resolver inputs, file-walker outputs, plan filenames). filePath reads as "the path to some file" rather than "this page's path." Renaming aligns with the broader convention and reads more naturally in attribute interpolation.

$file.* lacks a project-root path. It exposes created and modified — both about the file as a disk artifact — but not the file's actual disk location. Consumers like the snippet rune (SPEC-062) resolve paths from project root for sandbox enforcement, and need a variable that returns a path in that frame of reference. $page.path is content-root-relative (correct for the content artifact framing), so it can't be used directly for snippet's view-source pattern. The two paths exist because there are two valid frames; both need to be exposed.

The public surface isn't documented. $frontmatter.* and $file.* are reachable from any page but only mentioned in scattered docs (site/content/extend/plugin-authoring/authoring.md, plan rune docs). New users discovering the variable system have no central reference.

The internal/public boundary is informal. Some pipeline-internal variables use __ prefix (__source, __sourcePath, __sandboxReadFile, __securityPolicy, __icons), others don't (headings, generatedIds, path, urls, svg). The current state works but invites accidental collisions — any author writing {% $headings %} would get the pipeline's internal heading index.


Design Principles

Document what already exists; add only what's missing. This isn't a redesign; the existing namespaces ($frontmatter, $page, $file) are the right shape. The spec extends $page.* and settles naming.

Variables describe what already exists. Each variable mirrors data the pipeline already computes per page. No computed-on-demand fields, no side effects. Read-only views into pipeline state.

One namespace per data source. $frontmatter is raw frontmatter; $page is the page as a content artifact (URL, content-relative path, derived bits); $file is the source file as a disk artifact (project-relative path, version-control timestamps). Don't blur the boundaries — a frontmatter key bleeding into $page via auto-lift would create a debugging nightmare, and a single unified path variable would force every consumer to think about which frame they need.

Two paths, two frames. $page.path is relative to the content directory because the page lives inside the content tree. $file.path is relative to the project root because the file lives on disk. Consumers that care about content position (URL-aware logic, layout cascades, nav scope) reach for $page.path; consumers that care about disk location (sandboxed file reads, build-tooling integrations) reach for $file.path.

Per-page injection. Each page's transform receives a config with that page's variables populated. Layouts and partials rendered as part of that page see the host page's variables, not their own source file's.

Double-underscore prefix is the public/internal boundary. Author content sees variables without __; pipeline internals use __. Settle this as a convention so future additions are unambiguous.


Current Public Surface (Documented Baseline)

This section captures what's already exposed today. None of it changes; this spec just makes it official.

$frontmatter.*

The complete parsed YAML frontmatter as a flat object. Whatever keys the author writes in the page's frontmatter block are reachable as $frontmatter.{key}.

---
title: Auth system
author: Bjorn
description: How authentication works
---

# {% $frontmatter.title %}

Written by {% $frontmatter.author %}.

No schema enforced at the variable layer — the frontmatter object reflects exactly what's in the YAML, whatever shape that is. Frontmatter validation (if needed) happens elsewhere (per-rune schemas, registry hooks).

$page.*

Page-level metadata. Currently:

KeyTypeMeaning
$page.urlstringFinal URL of the rendered page
$page.filePathstringFile path relative to content root (renamed to $page.path — see below)
$page.draftbooleanWhether the page is marked draft in frontmatter

$file.*

File-system and version-control metadata. Describes the source file as a disk artifact.

KeyTypeMeaning
$file.createdstring | undefinedISO 8601 date (YYYY-MM-DD) of file creation. Sourced from git history when available; filesystem stat as fallback.
$file.modifiedstring | undefinedISO 8601 date (YYYY-MM-DD) of last modification. Same source/fallback strategy as created.

Both can be undefined for files that aren't in git and whose filesystem stat is unavailable (rare).

Plan runes (spec, work, bug, decision, milestone) consume $file.created and $file.modified as automatic defaults for their created and modified attributes — see site/content/runes/plan/index.md for the existing documentation.

$file.path (new — see Changes below) joins this namespace, giving consumers a project-root-relative path for the same disk artifact.


Changes

1. Rename $page.filePath → $page.path (breaking)

$page.filePath becomes $page.path. The old name is removed (not aliased).

Rationale: "path" matches the broader refrakt naming convention (path resolution, file walker, partial paths) and reads more naturally in attribute interpolation (path=$page.path reads cleanly; path=$page.filePath reads as a typo).

Breakage: a grep of site/content/ and the test corpus shows zero uses of $page.filePath in authored content. The only producer is packages/content/src/site.ts:154. The rename touches one line of production code. External consumers (users who've started writing $page.filePath in their own sites) will need to rename; document in changeset.

2. Add $page.path (new value, named replacement for filePath)

Path relative to content root, POSIX form (forward slashes). Same value as the renamed filePath, with one normalization commitment: POSIX-style slashes regardless of host OS.

"docs/themes/configuration.md"

3. Add $page.dir

Directory portion of $page.path. Useful for "render only on pages in this section" patterns.

"docs/themes"

For pages at the content root, $page.dir is the empty string "".

4. Add $page.slug

Last URL segment of $page.url. Useful for slug-based comparisons without writing string manipulation.

$page.url = "/docs/themes/configuration"
$page.slug = "configuration"

For index pages (docs/themes/index.md → /docs/themes/), $page.slug is the directory name ("themes"), matching nav-resolution conventions from SPEC-055.

5. Add $page.title

The page title, with a fallback strategy:

  1. frontmatter.title if present and non-empty after trimming whitespace
  2. Otherwise, the first H1 heading's text content from the AST (depth-first walk, including descending into rune children — see below)
  3. Otherwise, undefined

Why a derived title when $frontmatter.title already exists: pages often don't set title in frontmatter when the first H1 is the natural source of truth. Forcing every page to also declare frontmatter title would be duplication. $page.title is the right surface for "the page's title, however that's authored."

Why trim-and-check-non-empty for the frontmatter case: editors (CMS-style, form-based authoring tools) often store an empty string when the author leaves the title blank rather than removing the key. The author's mental model is "I left it blank, so fall back to the H1." Treating empty-after-trim as unset matches that expectation.

Walk strategy for the H1 fallback: depth-first traversal of the Markdoc AST, descending into both regular nodes and tag (rune) children. The first heading node with level: 1 wins. This matches the author's mental model of "the page title is the H1 the reader sees" — including the common case where an H1 is wrapped in a layout rune:

{% hero %}
# Authentication
A guide to OAuth flows.
{% /hero %}

Here $page.title is "Authentication", not undefined. Only markdown heading nodes count — H1 elements that runes emit structurally (e.g., a hero rendering an H1 from its title= attribute) are not in the AST as heading nodes and don't participate in the walk. Authors who want their rune-attribute-driven title to also appear as $page.title should set frontmatter.title (which has precedence anyway).

Scope inside partials and layouts: partials and layout files rendered as part of a host page see the host page's $page.title, not their own. (Same scoping rule as the rest of $page.*.)

6. Add $file.path

Project-root-relative path to the source file. POSIX-normalized (forward slashes regardless of host OS). The disk-frame counterpart to $page.path.

$page.path = "docs/themes/configuration.md"        # relative to content root
$file.path = "site/content/docs/themes/configuration.md"  # relative to project root

Sourced from the file walker, which already tracks the absolute file path for each page during content load. Computing project-root-relative form is path.relative(projectRoot, absoluteFilePath) then POSIX normalization.

Why a separate path instead of normalizing $page.path to project-root? Two reasons:

  1. The two frames have different natural consumers. URL-aware logic (nav scope, layout cascade, conditional content) cares about position in the content tree; sandboxed file consumers (snippet rune, future build-time include patterns) care about disk location. Forcing one frame on both consumer groups means every consumer needs string manipulation to get into the frame they want.

  2. Markdoc doesn't support string interpolation in attribute values. Patterns like path="site/content/$page.path" don't work — Markdoc resolves variables in attribute values but doesn't splice them into surrounding literals. Without a concat() helper (which is its own design choice and not currently registered), the only ergonomic path is to provide the variable in the right frame already.

Use cases:

{# Code-file from {% ref "SPEC-062" /%} — view-source pattern #}
{% snippet path=$file.path lang="md" /%}

{# Hypothetical future: "edit this page" link to GitHub #}
{% github-edit-link path=$file.path /%}

Scope inside partials and layouts: like $page.*, partials and layouts see the host page's $file.path, not their own source file's. The transform context is the host page's; partials and layouts inherit it.


Internal Variable Surface (Out-of-Scope Reference)

Pipeline internals also live in config.variables. Author content should treat anything in this list as off-limits — they may be removed, renamed, or restructured without breakage notice.

VariableOwnerPurpose
__sourcesandbox runeRaw page source as a string
__sourcePathnav runeSource file path for nav-relative slug resolution
__sandboxReadFile, __sandboxListDir, __sandboxDirExistssandbox runeFilesystem access hooks
__sandboxExamplesDirsandbox runeExamples directory location
__securityPolicysandbox runeResolved security policy
__iconsicon runeIcon registry (theme + user)
headingstoc/navExtracted heading index (missing __ prefix — see Open Questions)
generatedIdsid generatorShared ID dedup state (missing __ prefix)
pathimage nodesPage path (overlaps with $page.path!) (missing __ prefix)
urls, svgimage/svg nodesRegistries for asset resolution (missing __ prefix)

Convention going forward: all new pipeline-internal variables use __ prefix. The unprefixed entries above are historical and remain as-is for now; a future cleanup spec can migrate them. Author content writing {% $headings %} etc. is in undocumented territory and may break — $page.*, $frontmatter.*, and $file.* are the only stable public namespaces.


Engine Changes

Variable population

packages/content/src/site.ts:151–164 is the populate site. Extend the contentVariables object:

const contentVariables: Record<string, unknown> = {
  ...opts.variables,
  frontmatter,
  page: {
    url: route.url,
    path: posixPath(page.relativePath),           // renamed from filePath, POSIX-normalized
    dir: posixDirname(page.relativePath),         // new
    slug: lastUrlSegment(route.url),              // new
    title: frontmatter.title ?? firstH1(ast),     // new
    draft: route.draft,
  },
  file: {
    path: posixRelative(projectRoot, page.filePath),  // new — project-root-relative, POSIX
    created: fileTimestamps.created,
    modified: fileTimestamps.modified,
  },
  // ...internal variables unchanged
};

The projectRoot value is the directory containing refrakt.config.json — already known to the content loader (it resolved the config). Threading it into processContentTree is a small plumbing change.

First-H1 extraction

firstH1(ast) walks the Markdoc AST depth-first, descending into tag (rune) children, and returns the text content of the first heading node with level: 1, or undefined. Implementation lives alongside the existing extractHeadings helper since both walk the same tree.

Path normalization

posixPath(p) and posixDirname(p) convert backslashes to forward slashes for consistent cross-platform output. Use Node's path.posix.* where applicable. posixDirname special-cases the "." return value from path.posix.dirname to "" so callers don't have to handle the "no directory" case separately.

Layout and partial scoping

Layouts and partials are already rendered with the host page's config.variables (because they're transformed as part of the host page's render). No additional plumbing needed — the existing per-page transform call site provides the right scope automatically.


Acceptance Criteria

  • $page.path is exposed, replacing $page.filePath; POSIX-normalized
  • $page.filePath is removed (not aliased)
  • Changeset documents the rename as a breaking change
  • $page.dir resolves to the directory portion of $page.path; empty string for content-root pages
  • $page.dir is POSIX-normalized (forward slashes regardless of host OS) and has no trailing slash
  • $page.title resolves to $frontmatter.title when it is present and non-empty after trimming whitespace
  • $page.title falls back to the first H1 in the AST when $frontmatter.title is absent, empty, or whitespace-only
  • $page.title's H1 walk is depth-first and descends into tag (rune) children; the first markdown heading node with level: 1 wins
  • $page.title is undefined when neither source provides a title
  • $page.url, $page.draft continue to work (no regression)
  • $frontmatter.* access continues to work for all frontmatter keys (no regression)
  • $file.created, $file.modified continue to work (no regression)
  • $file.path resolves to the source file's project-root-relative path, POSIX-normalized
  • $file.path and $page.path are distinct (different frames of reference); both are documented with examples
  • Project root (config file directory) is threaded into processContentTree for $file.path computation
  • Variables work in attribute interpolation (path=$page.path)
  • Variables work in text interpolation ({% $page.title %})
  • Variables work in conditional tags ({% if equals($page.dir, "docs") %})
  • Partials included into a page see the host page's $page.*, not their own file's
  • Layouts cascaded into a page see the host page's $page.*, not the layout file's
  • New authoring docs page documents the full public variable surface ($frontmatter.*, $page.*, $file.*) with examples for each variable
  • Authoring docs explicitly explain the page-vs-file frame distinction (content-relative vs project-relative paths)
  • Authoring docs note the __ prefix convention as the public/internal boundary
  • Test fixture covers each new $page.* variable in a .md page that interpolates them

Out of Scope

  • $site.* namespace — site-level metadata (site name, base URL, theme, registered plugins, etc.) is its own future spec. Mentioned here to flag the future direction but no work in this spec.
  • Migrating unprefixed pipeline internals to __ prefix — the historical entries (headings, generatedIds, path, urls, svg) stay as-is. Future cleanup spec can rationalize them. This spec only establishes the convention for new additions.
  • Frontmatter schema validation — $frontmatter.* reflects whatever YAML the author writes. Validation, type coercion, schema enforcement are separate concerns.
  • Computed page variables ($page.wordCount, $page.readingTime) — these would require additional computation per page and don't have an obvious consumer yet. Defer.
  • Cross-page variables ($page.siblings, $page.parent, $page.children) — the cross-page pipeline registers this data into the EntityRegistry but it's not exposed as Markdoc variables. Different concern; would need its own design (when does the data become available? before or after postProcess?).
  • Custom user-defined variables in refrakt.config.json — site-level custom variables (variables: { foo: "bar" }) already work via the existing opts.variables plumbing. Documenting that surface is part of the docs deliverable above; no new mechanism.
  • $page.* access in YAML frontmatter — frontmatter is parsed before transform; variables don't resolve there. Out of scope.

Open Questions

Should the rename of $page.filePath be done as a hard break, or with a one-release deprecation alias? Recommend hard break — usage in the wild is essentially zero (grep of refrakt's own content corpus shows zero matches), and the changeset note is sufficient warning for external sites. A deprecation alias adds permanent surface for a vanishingly rare upgrade case.

Should $page.dir be the path-dir ("docs/themes") or the URL-dir ("/docs/themes")? Recommend path-dir (no leading slash, matches $page.path rather than $page.url). URL-dir is derivable; consistent naming with $page.path is more important than the slight extra typing.

For index pages, should $page.path be "docs/themes/index.md" or "docs/themes/"? Recommend the literal file path ("docs/themes/index.md"). It's the file path; the URL form is what $page.url gives. Two different views of the page, both consistent within their own namespace.

What's the right authoring docs home for the variable surface page? Probably site/content/extend/variables.md (alongside the rune-authoring and theme-authoring guides). Currently the variables are mentioned in scattered docs (plugin-authoring, plan rune docs); consolidating into one canonical page is the deliverable.

Should there be a runtime warning when an author writes {% $page.unknown %} (referencing a non-existent variable)? Markdoc resolves missing variables to undefined silently. Adding warnings would catch typos but might be noisy. Defer — improve later if real cases of "I typo'd a variable name and got a blank page" surface.


References

  • SPEC-055 — nav slug resolution (URL conventions, slug semantics)
  • SPEC-062 — snippet rune (primary consumer of $page.path)
  • packages/content/src/site.ts:84–164 — current variable population
  • packages/content/src/timestamps.ts — $file.* source
  • site/content/extend/plugin-authoring/authoring.md:181–182 — existing $file.* documentation
  • site/content/runes/plan/index.md:83 — existing $file.* usage in plan runes

Configurable file roots

SPEC-063shipped

Configurable file roots

A generic mechanism for registering named directories (file roots) in refrakt.config.json and from plugins. Once registered, file-reading runes can resolve paths via a namespace:filename syntax that anchors at the named root. Backwards-compatible: unprefixed paths keep their existing per-consumer resolution (Markdoc partials from each site's _partials/, snippet from the project root); prefixed paths route through the file-roots registry.

The v1 consumer is Markdoc partials, extending {% partial %} to resolve namespaced files. The mechanism is deliberately not partial-specific — the snippet rune (SPEC-062) plans to consume the same resolver when its v2 lands, and any future file-reading rune (build-time includes, generated-content embeds, etc.) plugs into the same surface. One resolver, multiple consumers.

The motivating use cases are (a) sharing partials across sites in a multi-site monorepo, and (b) enabling plugins to ship content files reachable from user sites — the latter is what unblocks the expand rune in SPEC-066 (via the plan plugin's registration work in SPEC-064), since plan content lives outside the site content tree.

contentpluginsconfigresolution

Problem

Partials today resolve from a single _partials/ directory at each site's content root (packages/content/src/content-tree.ts:124). That works for site-local reuse but fails three patterns:

Multi-site shared chrome. A monorepo with docs/, marketing/, and blog/ as separate sites can't share a footer.md or cta.md partial. Current options: duplicate the file (three copies drift), use a symlink (Windows-hostile, fragile), or use a git submodule (heavyweight for a 10-line partial).

Plugin-shipped content files. A plugin (the plan plugin, design-system docs, etc.) may want to expose content files that user sites embed via partial-style transclusion. With no extension point for "additional file roots," plugins can't participate in inclusion at all — they'd need a parallel rune.

Project-wide partials outside the site tree. Things like legal boilerplate, shared examples, or — for the expand-rune case — the plan/ directory itself. None of these live under a site's _partials/, but they're conceptually the same primitive: "a Markdown file you can pull into another."

The right shape is one inclusion system with a broader resolution domain, not a parallel mechanism that competes with {% partial %}.


Design Principles

Backwards compatible. Existing {% partial file="footer.md" /%} calls continue to resolve from the site's _partials/. No migration needed. The new feature is additive.

One mechanism, broader reach. File roots are a generic resolution layer, not a partial-specific feature. The {% partial %} rune is the v1 consumer; the snippet rune is the next consumer; future file-reading runes plug into the same surface. If we ever ship a different primitive (e.g. structured includes with parameter binding), it should be a clear different concern — not a duplicate of this.

Explicit namespaces beat ambient resolution. Prefixing the partial reference (shared:footer.md) makes the resolution root part of the source. No "where did this come from?" mystery when reading the file.

Plugins as registration source. Plugins should be able to declare file roots without user configuration. A plugin that ships content files brings its own namespace; users get it free by installing the plugin.

User config wins collisions. If user config and a plugin both register shared, the user's wins. The plugin's registration is silently ignored (with a build-time dev warning naming the conflict).


Authoring Surface

User configuration

refrakt.config.json:

{
  "sites": { "docs": { ... }, "marketing": { ... } },
  "fileRoots": {
    "shared": "_shared-partials",
    "legal": "../legal-snippets"
  }
}

Keys are namespace names. Values are paths relative to the config file (i.e. project root). Paths must point to directories.

Plugin registration

A plugin exports fileRoots on its Plugin object:

import type { Plugin } from "@refrakt-md/types";

export const plugin: Plugin = {
  name: "@refrakt-md/plan",
  // ...
  fileRoots: {
    plan: "../../plan",  // relative to the plugin's package directory
  },
};

Paths are relative to the plugin package's directory. The loader resolves to absolute paths before merging into the registered-roots map.

Usage

{# Unprefixed — current behavior, resolves from site's _partials/ #}
{% partial file="footer.md" /%}

{# Prefixed — user-registered shared root #}
{% partial file="shared:footer.md" /%}

{# Plugin-registered namespace #}
{% partial file="plan:SPEC-001-foo.md" /%}

{# Subdirectories within a root #}
{% partial file="shared:legal/terms.md" /%}

Resolution Rules

InputResolution
footer.md (no colon)Site-local _partials/footer.md (current behavior unchanged)
shared:footer.mdLook up shared in merged file-roots map; resolve footer.md from that directory
unknown:foo.mdBuild error: namespace not registered; list available namespaces
shared:missing.mdBuild error: file not found in named root
shared:../escape.mdBuild error: path escapes the named root
shared:/abs.mdBuild error: absolute paths not permitted in namespaced references

Collision rules

When merging user config and plugin registrations:

  • User config wins if both register the same namespace. Plugin's registration is ignored with a dev-mode warning naming the plugin and the namespace.
  • Plugin vs. plugin collision is a build error at plugin load. Error names both plugins and the conflicting namespace. Plugins can rename their roots; users can disambiguate by configuring a wrapper namespace.

Reserved namespaces

  • site — reserved for future site-level resolution (e.g. cross-site references in a multi-site project). Build error if user or plugin tries to register it.
  • Empty namespace (":foo.md") — build error (invalid syntax).

Path validation in roots

Each registered root path is validated at load time:

  • Must resolve to an existing directory (build error if not)
  • Must be readable
  • Symlinks resolving outside the apparent root are flagged with a dev warning but not blocked (some users intentionally symlink content from outside)

Engine Changes

Plugin interface

packages/types/src/package.ts:

export interface Plugin {
  name: string;
  // ... existing fields ...
  fileRoots?: Record<string, string>;  // namespace → path relative to plugin package
}

Loader

packages/runes/src/plugins.ts:

  • loadPlugin reads the plugin's fileRoots, resolves each path against the plugin's directory, returns absolute paths.
  • mergePlugins collects all plugins' file roots into a single map, checks for plugin-vs-plugin collisions.

Content tree

packages/content/src/content-tree.ts:

  • Extend readPartials (or add a sibling readNamespacedPartials) that takes the merged file-roots map and scans each named root.
  • Returns a map keyed by namespace:filename (e.g. "shared:footer.md"), in addition to the existing unprefixed site-local entries.

Site / Markdoc config

packages/content/src/site.ts:

  • Build the Markdoc config.partials object with both unprefixed (site-local) and prefixed (namespace:filename) entries.
  • Markdoc's partial-resolution by-name handles the rest — the {% partial file="x" /%} tag looks up the literal string in config.partials.

Errors

  • Unknown namespace, missing file, escape attempt: thrown at content-load time with source-file context where possible (which .md file's partial reference failed).
  • Plugin collisions: thrown at plugin load.

Acceptance Criteria

  • Unprefixed {% partial file="foo.md" /%} resolves from the site's _partials/ (no regression)
  • refrakt.config.json accepts a fileRoots: { namespace: path } map
  • User-config root paths resolve relative to the config file's directory
  • Prefixed {% partial file="namespace:foo.md" /%} resolves from the named root
  • Subdirectory access within a root works (namespace:subdir/foo.md)
  • Unknown namespace fails the build, naming the namespace and listing all registered namespaces
  • Missing file in a known namespace fails the build, naming the resolved path
  • Traversal attempts (namespace:../escape.md) are rejected with a build error
  • Absolute paths in namespaced references (namespace:/abs.md) are rejected
  • Plugin interface gains an optional fileRoots: Record<string, string> field
  • Plugin fileRoots paths are resolved relative to the plugin package directory
  • Plugins' file roots are merged into the resolved roots map at load time
  • User config wins user-vs-plugin namespace collisions; plugin registration emits a dev warning
  • Plugin-vs-plugin namespace collision fails plugin load with both plugins named
  • Reserved namespace site is rejected (registration error)
  • Empty namespace (":foo.md") is rejected as invalid syntax
  • Root path validation: non-existent directory at load time is a clear build error
  • Documentation covers user config syntax, plugin registration, resolution rules, and reserved namespaces
  • Type definitions updated for Plugin.fileRoots
  • At least one existing plugin (likely plan) registers a file root to exercise the path end-to-end

Out of Scope

  • Runtime / dynamic file roots — all roots are resolved at content-load time. No live registration.
  • Per-site file-root filtering — file roots are project-global. If a file root should only be visible to one site, that's a future feature; design pattern for now is to use a clear namespace name.
  • Glob patterns in root paths — values in fileRoots are directories, not patterns.
  • HTTP / remote file roots — file system only. Remote resolution introduces caching and network-failure concerns out of scope here.
  • Re-exporting / aliasing namespaces — no fileRoots: { "shared": "@plan" } alias indirection. Plugins own their namespace names; users add their own; no third layer.
  • Cross-site referencing (e.g. docs-site:foo.md resolving to another site's content) — out of scope. Reserved for future under the site namespace.

Open Questions

Plugin-registered root path: relative to the plugin package or to project root? Recommend plugin package (plugins own their files, paths should be authored against the plugin's own tree). Resolved to absolute by the loader before merging.

Should plugin-registered file roots be visible to refrakt plugins list / MCP plugin introspection? Recommend yes — useful for debugging "where did this namespace come from?" Add to the existing plugin-list output as fileRoots field. Low-risk addition.

What about a __ (dunder) convention for "private" plugin partials not meant for direct user reference? Probably overkill. Plugins document their public namespace surface; private files just live elsewhere in the package without being registered.

How does this interact with file caching / HMR? Deferred to SPEC-068. In v1, files in registered roots are not watched — editing one doesn't trigger a rebuild of pages that include it (the author saves any file inside the content tree, or restarts the dev server, to trigger re-resolution). Production builds are unaffected. Real dependency-tracked watching for registered file roots is a follow-up spec shared with snippet from SPEC-062, intentionally deferred so the contract can be informed by real usage from both consumers.

Should there be a dependsOn field for partials that themselves include other partials? Markdoc handles transitive includes natively (a partial can {% partial file="x" /%} another partial). Make sure cross-namespace transitive includes work (a plan:foo.md partial that includes shared:bar.md).


References

  • SPEC-064 — plan plugin unconditional entity registration (downstream consumer for the plan: namespace)
  • SPEC-066 — expand rune (the rune that consumes plan-registered entities for inline embedding)
  • SPEC-068 — adapter HMR contract for arbitrary file dependencies (deferred follow-up covering HMR for registered roots)
  • packages/content/src/content-tree.ts:124 — current _partials/ loading
  • packages/content/src/site.ts:131 — current Markdoc partial registration
  • packages/runes/src/plugins.ts — loadPlugin / mergePlugins extension points
  • packages/types/src/package.ts — Plugin interface definition

Plan plugin — unconditional entity registration

SPEC-064shipped

Plan plugin — unconditional entity registration

Extend the plan plugin's pipeline register hook to scan the configured plan directory at content-load time and register every plan entity into the EntityRegistry, regardless of whether plan files are part of the site content tree. Each registration includes the source file path and an extractor function — enough information for both xref (linking) and expand (inline substitution, see SPEC-066) to operate uniformly via the registry.

This is the companion implementation work for the expand rune (SPEC-066). expand needs every embeddable entity to be in the registry; for plan content that lives outside the site's content tree, the registry is empty today, so without this change expand can't function for plan IDs in projects that don't publish plan to their site.

planpluginsregistrypipeline

Problem

The plan plugin's existing register pipeline hook fires only for pages loaded into a site's content tree (plugins/plan/src/pipeline.ts:208). For each loaded plan page (e.g. a .md file inside site/content/plan/specs/), the hook reads the top-level plan rune and registers an entity.

This works for projects that publish plan content as part of their site — refrakt's docs site setup is not one of those; refrakt's own plan content lives in plan/ at the project root, outside any site's content directory. As a result, refrakt's site registry contains zero plan entities, and {% ref "SPEC-058" /%} only works because we explicitly publish certain plan content separately. Most users of the plan plugin face the same gap.

Three concrete consequences:

  • xref to plan content from non-plan pages fails or falls through to patterns. Without registry entries, refs go straight to the SPEC-065 pattern layer or render as unresolved. The registry can't help even when it logically could.
  • {% expand "SPEC-023" /%} from SPEC-066 can't function — expand's primary lookup is the registry, and without registrations there's nothing to find.
  • Tooling that consults the registry (the inspect machinery, future graph views, etc.) sees a partial picture.

The plan plugin already knows the answer to "what plan entities exist?" — it scans plan.dir from the top-level refrakt config (via _planDir plumbing at plugins/plan/src/pipeline.ts:156). The fix is to wire that scan into the register hook unconditionally.


Design Principles

Plan content always registers, regardless of site membership. The plan corpus is project-scoped data. Whether or not a particular site within the project happens to publish plan pages doesn't change the fact that the entities exist. The registry should reflect them either way.

Site-published plan content still wins. If a plan file is part of a site's content tree, the existing register-on-page-load path takes precedence — that registration has a real sourceUrl (the page's URL), enabling xref to produce a working local link. The unconditional-scan path provides registrations only for plan content that isn't otherwise registered.

Registrations include both linking and embedding info. Each registered plan entity gets:

  • sourceUrl — set when plan content is also site content; undefined otherwise (xref falls through to SPEC-065 patterns).
  • sourceFile — always set (project-root-relative path to the plan file).
  • extract — always set (function returning the top-level plan rune AST node).

This means a single registration serves both xref (via sourceUrl-or-patterns) and expand (via sourceFile + extract).

Silent no-op when no plan content exists. Projects without a plan/ directory shouldn't see any errors at content load. The plugin's scan returns empty; nothing is registered; xref and expand for plan IDs behave as if the plan plugin weren't installed at all (refs unresolved, expand fails with "entity not found").

Light-touch on existing pipeline. The scan happens once per content-load. Existing tests of the register hook (pages loaded as site content) continue to pass; new tests cover the unconditional-scan path.


Scan and Register

When the scan runs

The plan plugin's register pipeline hook receives the host site's pages array. The hook now does two things:

  1. Existing behavior: walk pages for plan runes and register those entities (with sourceUrl set to the page's URL).
  2. New behavior: independently scan plan.dir (resolved via _planDir). For each .md file found:
    • If the file's resolved path corresponds to a page already processed in step 1 (the same plan file is also part of the site content), skip — step 1 already registered it.
    • Otherwise, parse the file, locate the top-level plan rune, extract metadata (ID, type, title, status, source, etc.), and register the entity with sourceUrl: undefined, sourceFile: <relative-to-project-root>, extract: <function>.

Directory scan

plan.dir is resolved from the top-level refrakt config. The scan recursively walks the directory, picking up .md files in known subdirectories:

  • specs/ → entity type spec
  • work/ → entity type work
  • bug/ → entity type bug
  • decisions/ → entity type decision
  • milestones/ → entity type milestone

Filename convention from SPEC-022:

  • Auto-ID files: {ID}-{slug}.md (e.g. SPEC-023-auth-system.md)
  • Milestone semver files: v1.0.0.md

Files that don't match either convention are still parsed; if a valid top-level plan rune is present (with a declared id= attribute), the entity is registered. The rune is the source of truth for the entity's identity; filename convention is a hint, not a filter. Only files that contain no parseable plan rune at all are skipped (with a debug-level warning, accommodating user-authored auxiliary content like READMEs in the plan directory).

Extractor signature

const extract = (parsed: Markdoc.Node): Markdoc.Node | null => {
  // Locate the top-level plan rune node (Tag with name in
  // ["spec", "work", "bug", "decision", "milestone"]).
  // Return the tag itself (so expand substitutes the whole rune).
  // Return null if the file's structure has been edited away
  // from the expected shape.
};

The extractor is generated by the plan plugin per registration, closing over the entity's expected ID and type. Cached and reused if the same source file's extractor is called multiple times (unlikely for plan's 1:1 file-to-entity convention, but the caching is general from SPEC-066).

Conflict resolution

If both paths (site-load and unconditional-scan) would register the same (type, id):

  • Same content: the site-load registration is the canonical one. The unconditional-scan path detects via path comparison that the file is already represented and skips.
  • Different content (same ID in two different files — which shouldn't happen but might if a user accidentally creates a duplicate): the plan plugin's existing duplicate-detection error fires at scan time, naming both file paths.

Metadata population

Registration data field receives the plan entity's attributes from its rune declaration:

registry.register({
  id: 'SPEC-023',
  type: 'spec',
  sourceUrl: undefined,                                  // pattern fallback
  sourceFile: 'plan/specs/SPEC-023-auth-system.md',
  extract: <generated function>,
  data: {
    title: 'Auth system',                                // from H1
    status: 'accepted',                                  // from rune attribute
    tags: ['frameworks', 'adapters'],                    // from rune attribute (parsed)
    source: 'SPEC-058',                                  // from rune attribute
    created: '2026-01-15',                               // from $file.created
    modified: '2026-05-19',                              // from $file.modified
  },
});

title extraction uses the same first-H1 walk introduced in SPEC-061.


Engine Changes

plugins/plan/src/pipeline.ts

  • Extend the register hook to perform the unconditional scan after processing site-loaded pages
  • Add path-comparison logic to skip already-registered entities
  • Generate per-entity extractor functions
  • Wire sourceFile and extract fields into the registration call site

plugins/plan/src/scanner.ts (or scanner-core.ts)

The existing scanner already enumerates plan files for other tooling (MCP resources, CLI). Likely the right place to extend with metadata-parsing for registration purposes, sharing parse work with whatever currently happens.

EntityRegistration interface

If not already extended by SPEC-066, add optional sourceFile: string and extract: (parsed) => Markdoc.Node | null fields. The plan plugin populates them; consumers (xref, expand) reach for them.

Dev / HMR

When plan.dir changes during dev (file added/modified/removed), the content pipeline already re-runs (per existing plan-aware HMR). Confirm the unconditional-scan integrates cleanly: new plan files appear in the registry after the rebuild; deleted ones drop out.


Acceptance Criteria

  • Plan plugin's register hook performs an unconditional scan of plan.dir after processing site-loaded pages
  • All plan entities (spec, work, bug, decision, milestone) found in plan.dir are registered into the EntityRegistry
  • Registrations include sourceFile (project-root-relative path) and extract (function returning the top-level plan rune AST or null)
  • Registrations include the standard data fields (title, status, tags, source, created, modified)
  • When a plan file is both in plan.dir and part of a site's content tree, the site-load registration wins (with real sourceUrl); the unconditional scan skips to avoid duplicate registration
  • When plan.dir doesn't exist or is empty, the scan is a silent no-op (no error)
  • Duplicate IDs across different plan files fail content load with both file paths named
  • Files in plan.dir whose filenames don't match the auto-ID or milestone-semver convention are still parsed; if a valid top-level plan rune (with id=) is present, the entity is registered (filename is a hint, not a filter)
  • Files in plan.dir that contain no parseable top-level plan rune are skipped with a debug-level warning
  • Existing register-hook tests for site-loaded plan content continue to pass
  • New tests cover: unconditional scan registers entities; duplicate detection across paths; missing-directory silence
  • xref for plan IDs in non-plan-publishing sites finds the entity in the registry; falls through to SPEC-065 patterns when sourceUrl is undefined
  • expand (SPEC-066) for plan IDs in non-plan-publishing sites finds the entity in the registry and substitutes its content
  • Plan-rune schemas are unchanged — the registration path is the only change

Out of Scope

  • Scanning plan content from external sources (HTTP, git submodules in different repos, third-party hosts like trace). File system scan of the local plan.dir only.
  • Custom plan-content directories per site in a multi-site monorepo. One project-level plan.dir; if separate sites need separate plan corpora, that's a future configuration option.
  • The expand rune itself. This spec covers the registration work; the rune lives in SPEC-066.
  • Synthetic / virtual entity registrations (entities defined inline in refrakt config rather than backed by files). Out of scope; if needed, separate spec.
  • Live cross-host registration (e.g. fetching the registry from a remote refrakt project). File-system scan only.
  • Persistence across builds (caching the parse results to disk). Per-build re-scan; the existing plan-history cache is a precedent for if we ever want this, but not in scope here.

Open Questions

Should the unconditional scan run for every site in a multi-site project, or once globally? Recommend once globally — the plan corpus is project-scoped, not site-scoped. Each site's pipeline gets the same registry contributions for plan entities. Implementation: the plan plugin's pipeline hook is per-site today; the scan would either dedup across sites or move out of the per-site hook into a project-load phase. Defer the implementation detail until we're building.

What happens to a plan file with no valid top-level plan rune (e.g. someone authored a .md file in plan/specs/ with just prose, no {% spec %} wrapper)? Recommend: skip with a content-load warning naming the file. Not a fatal error because the file might be intentional auxiliary content (a README, an index page).

Cross-plugin coordination — does this work make plan entities visible to other plugins that might consume the registry? Yes, by construction. If a future plugin wants to "show all plan entities that mention this character," it consults the registry the same way everything else does. Good outcome.

Heading-extraction caching. The unconditional scan parses every plan file at content load. The expand resolver in SPEC-066 also parses files (cached per build). Can we share the cache? Probably yes — the scan parses to extract title and metadata; expand parses to extract the rune subtree. Same parse output; we can deposit it in the shared file-content cache.

What about plan files added during a watch session? HMR should pick up the new file via the standard content-pipeline watcher. Worth confirming during implementation that plan.dir is watched (it probably is via _planDir already, but check the SvelteKit Vite plugin and Eleventy adapter paths).

Should extract errors be surfaced eagerly (at scan time) or lazily (when something tries to expand)? Recommend lazy — scan-time errors would block content load for files that nothing actually references. Lazy means a malformed plan file is silently registered but fails when first expanded. The expand error message is clear enough to debug from.


References

  • SPEC-021 — plan runes (the rune definitions whose entities this spec registers)
  • SPEC-022 — plan CLI (filename conventions parsed by the scanner)
  • SPEC-066 — expand rune (primary consumer of the sourceFile + extract registration fields)
  • SPEC-065 — configurable xref resolution (used when sourceUrl is undefined)
  • SPEC-061 — page variables ($file.created / $file.modified, source of the entity timestamps)
  • plugins/plan/src/pipeline.ts:156 — _planDir plumbing
  • plugins/plan/src/pipeline.ts:208 — existing register hook to extend
  • plugins/plan/src/scanner.ts — existing file enumeration logic to share

Configurable xref resolution

SPEC-065shipped

Configurable xref resolution

Extend the xref postProcess resolver with a configurable URL-template layer so refs to entities outside the local EntityRegistry can resolve to real links via project config. Backwards-compatible with the existing registry-based resolution; entirely declarative and third-party-neutral. Refrakt itself stays out of the business of knowing about specific external systems — users supply their own patterns.

The motivating case is plan content hosted externally (refrakt trace, self-hosted plan publishers, hypothetical competitors), but the same mechanism cleanly handles GitHub issues, Linear tickets, RFCs, npm packages, Wikipedia entries, DOI citations, and anything else with a deterministic URL scheme. The expand rune from SPEC-066 also consumes this chain for its canonical-link affordance — entities embedded inline get a "View canonical" link resolved by the same patterns that drive {% ref %}.

runesxrefpipelineconfigcore

Problem

The xref postProcess resolver (packages/runes/src/xref-resolve.ts) only consults the local EntityRegistry. That's the right primary source — entities authored in the project, registered by core (page, heading) or plugins (spec, character, organization, etc.) — but it has a hard ceiling.

Cases that fall outside today:

  • External plan hosts. A user publishes their plan content via refrakt trace at trace.refrakt.md/{user}/{repo}/specs/SPEC-023 (or via a self-hosted CLI, or via a competing tool). Their site's docs reference SPEC-023; the local registry has no entry; the ref renders as unresolved.
  • Issue trackers. GH-123 should link to a GitHub issue; today it can't.
  • Standards / RFCs. RFC-7231 should link to the IETF datatracker; today it can't.
  • Package registries. npm:lodash, pypi:requests, etc.
  • Cross-site monorepos. A site refs an entity in a sibling site's content; today the registry is per-site.
  • Custom domain knowledge (chemical compounds, legal citations, academic DOIs, anything with stable URLs).

The pattern in all of these: a stable ID shape maps deterministically to a URL. Adding a configurable resolver layer turns xref from "links to local entities" into "links to anything with a URL pattern."

Critical constraint discovered in the design conversation: refrakt must stay third-party-neutral. Even though refrakt-md ships its own external products (refrakt trace), refrakt the framework should not bake in knowledge of any specific external system. Users author their own patterns; refrakt's docs can show recipes; the engine itself stays generic.


Design Principles

Third-party neutral. No hardcoded knowledge of trace, GitHub, Linear, or any other system in refrakt itself. Recipes can ship in docs, but the engine resolves whatever the user configures.

Declarative, not programmatic. Pattern + URL template, no code required. Users edit JSON; the resolver does the rest.

Backward compatible. Existing {% ref "SPEC-023" /%} continues to resolve via the registry when SPEC-023 is registered. Patterns are a fallback for unregistered IDs. No migration required.

Pattern style over namespace style. Refs stay unnamespaced (SPEC-023, not plan:SPEC-023). The resolver figures out which pattern applies. Trades one syntactic decision per ref for pattern-ordering discipline at config time.

Registry-first resolution. Local content beats external. If SPEC-023 is in the registry (the user publishes plan content locally), the registry URL wins. If it's not, the pattern resolves. This biases toward "use the canonical local form when available" and lets external resolution be a graceful fallback.

One config surface. Top-level xrefs key in refrakt.config.json. Not per-plugin, not per-site (in v1) — because external-system patterns aren't owned by any plugin and apply project-wide.


Authoring Surface

Configuration

refrakt.config.json:

{
  "sites": { ... },
  "plugins": [ ... ],
  "xrefs": [
    {
      "match": "^SPEC-\\d+$",
      "template": "https://trace.refrakt.md/myuser/myrepo/specs/{id}",
      "type": "spec",
      "label": "{id}"
    },
    {
      "match": "^GH-(?<num>\\d+)$",
      "template": "https://github.com/myuser/myrepo/issues/{num}",
      "type": "github-issue",
      "label": "GitHub #{num}"
    },
    {
      "match": "^RFC-(?<num>\\d+)$",
      "template": "https://datatracker.ietf.org/doc/html/rfc{num}",
      "type": "rfc",
      "label": "RFC {num}"
    },
    {
      "match": "^npm:(?<pkg>[a-z0-9-/@.]+)$",
      "template": "https://www.npmjs.com/package/{pkg}",
      "type": "npm",
      "label": "{pkg}"
    }
  ]
}

Pattern entry shape

FieldTypeRequiredMeaning
matchstringyesRegex pattern. Compiled at config load. Anchored implicitly to whole-string match (^ and $ auto-applied if absent).
templatestringyesURL template. Supports {id} (full matched ID) and {name} references to named groups in the regex. Values are encoded per URL segment before insertion — see Pattern resolution mechanics below.
typestringnoCSS modifier class assigned as rf-xref--{type}. Defaults to "external".
labelstringnoTemplate for the rendered link text. Same placeholder syntax as template. Defaults to "{id}".

Usage

No syntax change to xref/ref usage. Authors write {% ref "SPEC-023" /%} exactly as before; resolution behavior is what changes.

- Implementing per {% ref "SPEC-058" /%}                  {# local registry, if published #}
- Externally hosted: {% ref "SPEC-023" /%}                {# pattern resolves to trace #}
- Tracking in {% ref "GH-123" /%}                          {# GitHub issue #}
- Conforming to {% ref "RFC-7231" /%} section 4           {# RFC standard #}
- Built on {% ref "npm:lodash" /%}                         {# npm package #}

The label= attribute on the rune itself still overrides any computed label:

{% ref "GH-123" label="see the original report" /%}

Resolution

Resolution chain

Entity lookup and URL resolution are separable. For each xref placeholder found in postProcess:

  1. Entity lookup — registry.getById(typeHint?, id) (exact-ID, optionally type-filtered), then case-insensitive name/title match. If found, capture the entity's metadata (label, type, kind) for use in rendering.
  2. URL resolution — if the matched entity has a sourceUrl that is present and non-empty, use it directly (data-xref-source="registry"). Otherwise, iterate xrefs patterns in array order; the first regex that matches the ID provides the URL (data-xref-source="pattern").
  3. Unresolved fallback — if neither the entity's sourceUrl nor any pattern produces a URL, render rf-xref--unresolved span with build warning.

The split lets entity-lookup and URL-resolution succeed independently. The common case where this matters: SPEC-064's unconditional plan-content registration produces entities with sourceUrl: undefined (plan content that isn't published to the local site). Entity metadata (title, type) comes from the registry; URL comes from a configured pattern (e.g., trace, an external plan host). The rendered anchor gets the right label and a working href.

It also closes the empty-sourceUrl footgun: a registry-found entity without a usable URL never produces <a href=""> — the resolver falls through to patterns instead, and worst case to the unresolved fallback.

Normalization at registration time: when an entity is registered with sourceUrl: "" (empty string), the registry normalizes to sourceUrl: undefined. Distinguishing explicit-empty from accidental-empty isn't useful; both behave the same.

Pattern resolution mechanics

When a pattern matches:

  1. Extract match groups from the regex result. {id} is the full matched string; named groups ((?<name>...)) are available as {name} in templates.
  2. Encode each substituted value per URL segment: split the value on /, apply encodeURIComponent to each segment, rejoin with /. Single-segment values are unaffected (the split is a no-op); multi-segment paths preserve their structure with each segment correctly encoded.
  3. Render the resolved anchor:
<a class="rf-xref rf-xref--{type}"
   href="{computed-url}"
   data-xref-id="{matched-id}"
   data-xref-source="pattern">
  {computed-label}
</a>

The per-segment encoding handles the common case where a pattern captures a path-shaped value:

{
  "match": "^docs:(?<path>[a-z0-9/-]+)$",
  "template": "https://example.com/docs/{path}"
}

A ref to docs:guide/intro resolves to https://example.com/docs/guide/intro (slashes preserved, each segment encoded), not https://example.com/docs/guide%2Fintro (which most servers route to a different path entirely).

If an author ever needs to encode a literal / within a single segment (a vanishingly rare case), that's a future open question — RFC 6570-style placeholder variants ({+name} for reserved expansion, etc.) could be added if real demand surfaces.

The data-xref-source attribute reflects URL provenance: "registry" when the URL came from the entity's sourceUrl, "pattern" when it came from a pattern. Entity metadata may have come from the registry in either case.

Self-reference detection

If the resolved href equals the current page URL (after normalization), emit the same info-level "references itself" message as the registry path. Applies to pattern-resolved refs too.

Match anchoring

Patterns are anchored to whole-string match by default — the resolver effectively wraps match in ^(?:...)$ if ^ and $ aren't present at the start/end. This catches the common case where a pattern like SPEC-\d+ shouldn't match MY-SPEC-001.

Authors who want partial-match semantics can write explicit anchors elsewhere or use lookahead/lookbehind.


Validation

At config load time:

  • Each match value is compiled. Invalid regex → build error naming the entry index (xrefs[2]) and the regex parser's message.
  • Templates are parsed for placeholders. Any {name} that isn't {id} and isn't a named group of the corresponding regex → build error. Templates can omit placeholders entirely (constant URL is valid; produces the same link for every match).
  • Duplicate match strings across entries → build warning. Doesn't fail (the first-wins ordering still produces deterministic behavior) but flags redundancy.
  • Empty xrefs array or missing key → no behavior change vs. baseline.
  • Reserved type values — unresolved is reserved for the failure state; using it in a pattern entry's type is a build error.

Engine Changes

Config types

packages/types/src/config.ts (or wherever the refrakt config interface lives):

export interface XrefPattern {
  match: string;
  template: string;
  type?: string;
  label?: string;
}

export interface RefraktConfig {
  // ... existing fields
  xrefs?: XrefPattern[];
}

Compiled-pattern shape

interface CompiledXrefPattern {
  match: RegExp;
  template: string;
  type: string;       // resolved default if omitted
  label: string;      // resolved default if omitted
  groupNames: string[];
}

Compiled once at config load (packages/content/src/site.ts or equivalent), passed through the pipeline as part of processContentTreeOptions.

Resolver extension

packages/runes/src/xref-resolve.ts gains a patterns: CompiledXrefPattern[] parameter:

export function resolveXrefs(
  renderable: unknown,
  pageUrl: string,
  registry: Readonly<EntityRegistry>,
  patterns: CompiledXrefPattern[],
  ctx: PipelineContext,
): unknown

After the entity-lookup attempts (and before the unresolved fallback), if the matched entity has no usable sourceUrl (or no entity was found), iterate patterns and try each match. First hit wins.

Plumbing

  • processContentTreeOptions gains xrefPatterns?: CompiledXrefPattern[]
  • Compilation happens once per site build
  • Core postProcess hook passes patterns through to resolveXrefs

CSS scaffolding

packages/lumina/styles/runes/xref.css:

  • Add .rf-xref--external as a generic external-link treatment (default type for pattern-resolved refs)
  • Existing per-entity-type modifiers (.rf-xref--spec, .rf-xref--character, etc.) unchanged
  • Authors can target arbitrary custom types in their own theme CSS (.rf-xref--github-issue { … })

Acceptance Criteria

  • refrakt.config.json accepts a top-level xrefs: XrefPattern[] array
  • Pattern entries validate at config load: match, template, optional type, optional label
  • Invalid regex in match fails config load with entry index and regex error message
  • Templates referencing a non-existent named group fail config load with the entry index and the unknown placeholder name
  • Templates referencing {id} always validate (no named group required)
  • Duplicate match patterns emit a build warning (don't fail)
  • Reserved type value unresolved fails config load
  • match patterns are anchored to whole-string by default (^(?:...)$ auto-applied unless explicit anchors present)
  • Resolution chain: entity lookup (registry exact-ID → registry name) captures entity metadata; URL resolution uses entity sourceUrl if present and non-empty, else falls through to patterns (first match), else unresolved
  • Registry entities with sourceUrl: undefined or sourceUrl: "" never produce <a href="">; the resolver always falls through to patterns or to the unresolved state
  • At registration time, sourceUrl: "" is normalized to sourceUrl: undefined
  • Patterns evaluated in array order; first match wins for any ID
  • Named groups in regex are accessible as {name} in templates
  • All placeholder values are encoded per URL segment: split on /, encode each segment via encodeURIComponent, rejoin with / (so path-shaped captures preserve their slashes)
  • Single-segment captures are encoded the same as full encodeURIComponent would produce
  • type field assigns rf-xref--{type} CSS modifier (default external)
  • label field templates the link text (default {id})
  • label= attribute on the rune still overrides any computed label
  • Rendered anchor includes data-xref-id="{matched-id}" and data-xref-source="registry" (URL from entity) or data-xref-source="pattern" (URL from pattern)
  • Self-reference warning fires when resolved href equals current page URL
  • Existing refs unaffected when no xrefs config present (no regression)
  • Unresolved xrefs (no registry hit, no pattern match) still render as rf-xref--unresolved
  • Patterns compile once per build; compiled forms cached across page resolutions
  • Lumina ships baseline .rf-xref--external styling
  • Authoring docs cover config syntax, regex anchoring, placeholder semantics, URL encoding behavior, and recipe examples (trace, GitHub, RFC, npm at minimum)

Out of Scope

  • Curated preset library (presets: ["github", "trace", "rfc"] as shortcuts to pre-baked patterns). Powerful and friendly but couples refrakt to specific third parties — exactly what this spec is trying to avoid. Recipes in docs cover the discoverability concern. Reconsider in a future spec if the docs-recipes approach proves insufficient.
  • Per-pattern precedence flag (precedeRegistry: true to override registry on a per-pattern basis). Useful for cases where the canonical URL must always be the external one regardless of local publishing, but adds learning surface for v1. Patterns-as-fallback is the simpler model; revisit if real demand surfaces.
  • Namespace-style refs ({% ref "gh:123" /%}). Pattern style was chosen for backward compat and authoring ergonomics. Namespace-style would require migration of existing refs and adds a second syntax.
  • Git-remote auto-detection for inferring {user}/{repo} in trace or GitHub templates. Explicit user config only — keeps build determinism and avoids "works locally, fails in CI" surprises.
  • target="_blank" / rel="noopener" automation for external URLs. Theme concern — themes can target .rf-xref--external (or any non-local type modifier) and add link target attributes in their CSS-via-JS layer or document the recommendation for authors.
  • Conditional resolution by environment / site / build context (dev vs prod, multi-site disambiguation). Single config, single resolution behavior. Users needing env-specific behavior manage that in their config build pipeline.
  • Pattern composition or inheritance (one entry extending another). Each entry stands alone. Duplicating template strings across similar patterns is acceptable cost for declarative clarity.
  • Template helper functions ({name|lowercase}, {name|slugify}). Adds expression-language surface; named-group captures usually cover the same need. Defer until concrete demand surfaces.
  • Resolution from within frontmatter or other non-body contexts. xref is a body-content rune; the resolver runs in the postProcess phase of the content pipeline only.
  • Inline ref customization beyond label= (custom CSS class, custom data attributes per ref). Use raw markdown links if you need arbitrary anchor markup.

Open Questions

Should pattern entries support a description field for self-documenting config? Tempting (config files get long when many integrations are configured), but YAGNI for v1 — JSONC comments handle this. Revisit when we know whether configs grow to dozens of patterns.

Should named-group extraction validate that template references all captured groups? I.e., warn if a regex captures (?<num>\d+) but the template never uses {num}. Recommend no — silent unused captures are fine; over-strict validation adds friction without saving real bugs.

How does the rune's type= hint interact with pattern resolution? Today type= filters registry lookups. Should it also constrain pattern matching (e.g., only try patterns whose type field matches)? Recommend no — patterns are matched by regex on the ID, and the pattern's type is an output (CSS class) not a filter. If two patterns could match the same ID and the author wants the spec-typed one over the work-typed one, ordering handles it.

What about pattern matching against full URLs (e.g., {% ref "https://github.com/x/y/issues/5" /%} resolving to GitHub-style rendering)? Recommend no — that's already a regular markdown link in disguise. Refs are for IDs, not URLs.

Should pattern-resolved refs have a target="_blank" baked into rendered HTML? Strongly leaning toward no — target attributes are accessibility and UX choices that belong in themes, not the resolver. Themes can target .rf-xref--external or non-local types via JS / CSS-managed link behavior.

Caching and incremental build correctness. Patterns compile once per config load; recompilation only happens on full reload. For dev-mode HMR, if refrakt.config.json changes, the dev server triggers a full reload anyway — so no special invalidation logic needed in v1. Worth confirming in the implementation pass.

Performance: O(patterns × xref-occurrences × pages). For typical sites (single-digit pattern count, dozens of xrefs per page, hundreds of pages), this is negligible. If anyone hits a real performance ceiling later, profile then optimize — likely with a fast-path prefix check before regex execution.


References

  • SPEC-066 — expand rune (consumes this chain for canonical-link affordance)
  • SPEC-064 — plan plugin unconditional registration (motivating use case for non-locally-published plan content)
  • packages/runes/src/tags/xref.ts — current xref rune implementation
  • packages/runes/src/xref-resolve.ts — current postProcess resolver this spec extends
  • packages/types/src/registry.ts — EntityRegistry interface (existing first-pass resolution source)