SPEC-062
Setting up your dashboard 0 entities found · 9/32 branches scanned
ID:SPEC-062Status:draft

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 /%}.

Lives in @refrakt-md/docs — code-embed-from-disk is a docs concern. Composes with {% drawer %} from SPEC-060 for the side-panel view-source pattern, and depends on $file.path from SPEC-061 for the self-referential case. (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.)

claude/modal-drawer-pattern-a1Wgb View source
Implemented by 4
Related 12
Branches 3
claude/modal-drawer-pattern-a1Wgb current draft
claude/v0.15.0-phase-1 draftclaude/v0.15.0 draftmain draft
History 5
  1. 23fe315
    Content editedby bjornolofandersson
  2. 1572fac
    Content editedby bjornolofandersson
  3. 33ab4df
    Content editedby bjornolofandersson
  4. a523491
    Content editedby bjornolofandersson
  5. 6749e13
    Created (draft)by bjornolofandersson

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.

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" title="This page" /%}

{# 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.
titlestringOptional caption above the code block (filename, description).

Output Contract

<figure class="rf-snippet" data-rune="snippet" data-source-path="examples/button.svelte">
  <figcaption class="rf-snippet__title">examples/button.svelte</figcaption>
  <!-- existing code-block rendering output -->
  <pre class="rf-code-block" data-lang="svelte"><code>...</code></pre>
</figure>

BEM:

  • .rf-snippet — outer wrapper
  • .rf-snippet__title — caption (only present when title= set)
  • Inner pre/code produced by existing code-block rendering

Data attributes:

  • 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

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.
  • Directorypath="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 in plugins/docs/, 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 rune schema in plugins/docs/src/runes/snippet.ts
  • Plugin's theme.runes adds the Snippet config entry
  • CSS in plugins/docs/styles/snippet.css
  • File-reading utility in plugins/docs/src/lib/read-file.ts — sandbox enforcement, line-range slicing
  • Project-root resolution helper, shared with future file-roots resolution from SPEC-063
  • Extension → language map in packages/runes/src/lang-map.ts, exported from the package index
  • The rune produces the same internal Markdoc node shape as a fenced code block (so existing rendering picks it up) plus the snippet wrapper

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
  • title= renders as a caption in .rf-snippet__title
  • data-source-path is set on the wrapper to the resolved path (relative to project root)
  • data-lines is set when lines= is used
  • {% snippet path=$file.path /%} works once SPEC-061 lands (project-root frame matches the sandbox)
  • CSS in the docs plugin covers .rf-snippet* selectors
  • Authoring docs cover the rune, sandbox rules, line range syntax, language inference table

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.

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