Post-identity-transform hook for rune packages
Context
The identity transform produces the final serialized tree with BEM classes, data attributes, and structural elements. After this step, certain runes need a tree-wide post-processing pass — walking the entire tree to find specific markers and replacing or enriching nodes.
Two concrete cases exist today:
Syntax highlighting (
@refrakt-md/highlight): walks the post-identity-transform tree, finds nodes withdata-language, extracts text content, runs it through Shiki, and replaces the children with highlighted HTML. Also contributes CSS (theme tokens).Math rendering (proposed): would walk the same tree, find nodes with
data-math, extract LaTeX content, run it through KaTeX, and replace children with rendered math HTML. Also contributes CSS.
Currently the highlighter is manually composed in the page load function:
const renderable = hl(transform(serialized));
Adding math rendering would nest further: math(hl(transform(serialized))). Each additional post-processor adds another layer of manual wiring. Community packages that need this pattern have no hook point at all — users would need to manually integrate each transform function into their rendering pipeline.
Why existing hooks don't work
| Hook | Problem |
|---|---|
RuneConfig.postTransform | Runs inside the identity transform, per-rune. Sees a single rune node, not the full tree. Cannot do tree-wide walks like "find all data-language anywhere." |
PackagePipelineHooks.postProcess | Runs on TransformedPage before serialization and identity transform. The BEM-enhanced tree with data attributes doesn't exist yet. |
Neither hook operates at the right point in the pipeline.
Decision
Option 2: Post-identity-transform hook on RunePackage.
Rationale
RunePackage is already the unit of distribution for community runes. A math package ships its Markdoc schema, engine config, and now its post-identity-transform hook as one cohesive unit. This follows the existing pattern where PackagePipelineHooks lives on RunePackage — post-identity hooks are simply another kind of package-scoped behavior.
The init() factory pattern is essential because real-world post-processors like Shiki require async initialization (loading language grammars, creating highlighter instances). The factory is called once during setup, and the returned TreeTransformFn is called per-page — matching the existing createTransform() pattern.
Option 1 (status quo) breaks down as soon as a second post-processor appears — which is happening now with math. Option 3 misplaces the hook on the wrong abstraction. Option 4 is premature generalization.
Ordering is resolved by convention: core packages run first, then community packages in the order they appear in refrakt.config.json. This matches how PackagePipelineHooks already orders execution. If explicit ordering becomes necessary in the future, a priority field can be added without breaking changes.
Consequences
@refrakt-md/highlightbecomes aRunePackage(or augments one). Instead of exporting a standalone function, it exports a package with apostIdentityTransformhook. The existing standalone API can be preserved as a convenience wrapper.createTransform()gains hook awareness. It accepts collected post-identity hooks and chains them after the identity transform pass. The return type stays(tree: RendererNode) => RendererNodebut the function does more internally.CSS aggregation moves into the transform. The transform function (or a companion) exposes collected CSS from all hooks, replacing the current
hl.csspattern.SvelteKit plugin collects hooks automatically.
packages/sveltekit/already loadsRunePackageobjects from config — it would additionally collect theirpostIdentityTransformhooks and pass them tocreateTransform().Page load functions simplify. Instead of
hl(transform(serialized)), it becomes justtransform(serialized). The composition is internal.Math rune package can be implemented as a community package under
runes/with apostIdentityTransformhook that applies KaTeX — no special wiring needed.