@docvia/renderer-core
The framework-agnostic rendering engine that walks a parsed IR document into a serializable RenderOutput tree.
@docvia/renderer-core is docvia's rendering engine. It takes a parsed IRDocument (produced by @docvia/ir) and walks it into a serializable RenderOutput tree, a plain JSON structure made of element, text, html, component, and fragment nodes. It has no dependency on React, Svelte, or the DOM, which is what allows every framework adapter to build on a single shared core.
The package also ships a default renderer map covering all standard markdown node types, a structured RenderError class, and a generic island hydrate() helper. It depends only on @docvia/ir.
Why a framework-agnostic core
Every docvia framework adapter (@docvia/renderer-react, @docvia/renderer-svelte, and any future adapter) shares the same job: traverse the IR, highlight code, resolve components, and emit something a renderer can consume. renderer-core factors that shared work into one place.
The output is intentionally pure data. A RenderOutput tree contains no functions, no class instances, and no framework primitives, only JSON-serializable values. This means a compiled page can be:
- serialized to a JS module at build time,
- transmitted over the wire,
- and re-rendered by any adapter on any runtime (RSC, SSR, browser).
Installation
npm install @docvia/renderer-core
@docvia/ir is a required peer of the rendering pipeline and is normally already present in a docvia project.
Exports
This package exposes a single entry point.
| Subpath | Purpose |
|---|---|
. | The complete public API. Re-exports the default renderer map, the RenderError class, the generic hydrate() helper, the renderDocument/renderNodes functions, and all rendering types. |
import {
createDefaultRendererMap,
renderDocument,
renderNodes,
hydrate,
RenderError,
type RenderOutput,
type RenderContext,
type RenderResult,
type RendererMap,
type NodeRenderer,
type ComponentRegistry,
type SyntaxHighlighter,
type HydrationManifest,
type HydrationEntry,
} from "@docvia/renderer-core";
The RenderOutput tree
RenderOutput is the discriminated union every node of the rendered tree belongs to. The kind field is the discriminant.
type RenderOutput =
| {
kind: "element";
tag: string;
props?: Record<string, unknown>;
children?: RenderOutput[];
id?: string;
}
| { kind: "text"; value: string }
| { kind: "html"; value: string }
| {
kind: "component";
name: string;
props?: Record<string, unknown>;
children?: RenderOutput[];
hydrate?: HydrationMode;
id: string;
}
| { kind: "fragment"; children: RenderOutput[] };
| Kind | Meaning |
|---|---|
element | A host HTML element. tag is the tag name, props carry attributes (including class), children are nested outputs, and id, when present, is the hydration anchor copied to data-hid. |
text | A literal text run. value is rendered verbatim. |
html | A raw HTML string, e.g. syntax-highlighted code emitted by the highlighter. Adapters inject this directly (React via dangerouslySetInnerHTML, Svelte via {@html}). |
component | An interactive or directive component. name resolves through the ComponentRegistry, id is the mandatory hydration anchor, and hydrate declares the island strategy. |
fragment | A transparent wrapper holding a list of children with no host element of its own. The root of a rendered document is always a fragment. |
The
idfield onelementis optional, but oncomponentit is required, because every component island must have a stable id so the hydration manifest can locate its[data-hid]anchor in the DOM.
Core interfaces and types
RenderContext
The context object threaded through every renderer call. It carries everything a NodeRenderer needs.
interface RenderContext {
readonly slug: string;
readonly meta: PageMeta;
readonly registry: ComponentRegistry;
readonly highlighter?: SyntaxHighlighter;
readonly manifest: HydrationManifest;
readonly onError?: (err: RenderError) => void;
}
| Field | Type | Description |
|---|---|---|
slug | string | The slug of the page currently being rendered. |
meta | PageMeta | Page metadata (title, description, headings, tags, order, content hash) from @docvia/ir. |
registry | ComponentRegistry | Resolver used to look up custom components by name. |
highlighter | SyntaxHighlighter (optional) | Optional render-time highlighter, consulted only as a fallback for code blocks not already pre-highlighted by a build-time plugin. When omitted, such blocks render as plain <pre>. |
manifest | HydrationManifest | The mutable array that hydratable components are pushed onto during the walk. |
onError | (err: RenderError) => void | Optional callback invoked for every render error instead of throwing. |
renderDocument accepts an Omit<RenderContext, "manifest"> and creates the manifest internally; callers never construct the manifest themselves.
NodeRenderer and RendererMap
type NodeRenderer = (node: IRNode, ctx: RenderContext) => Promise<RenderOutput>;
interface RendererMap {
[K: string]: NodeRenderer;
}
A RendererMap maps an IR node type string to the async function that turns that node into a RenderOutput. The key unknown is special: it is the fallback used whenever no renderer is registered for a node's type.
ComponentRegistry
interface ComponentRegistry {
resolve(name: string):
| { component: unknown; hydrate?: boolean; defaultProps?: Record<string, unknown> }
| null;
}
The registry resolves a component name to a framework component reference. component is typed as unknown deliberately, because the core never touches the actual component, keeping it framework-agnostic. resolve returns null for an unknown name. defaultProps are merged underneath the directive's own attributes by the default component renderer.
SyntaxHighlighter
interface SyntaxHighlighter {
highlight(code: string, lang: string): Promise<{ html: string }>;
}
A minimal contract for code highlighting. Highlighting is normally a build-time plugin (@docvia/plugin-shiki) that bakes highlighted HTML onto code-block nodes; the SyntaxHighlighter contract is only used for an optional render-time fallback.
HydrationEntry and HydrationManifest
interface HydrationEntry {
id: string;
name: string;
props: Record<string, unknown>;
hydrate: HydrationMode;
}
type HydrationManifest = HydrationEntry[];
The manifest is the build-time list of interactive islands on a page. Each entry records the anchor id, the component name to resolve, the props to pass, and the hydrate mode (HydrationMode from @docvia/ir: "client:load", "client:idle", "client:visible", or "none").
RenderResult
interface RenderResult {
output: RenderOutput;
manifest: HydrationManifest;
}
The return value of renderDocument. output is always a fragment rooting the page; manifest lists every hydratable component discovered during the walk.
RenderError
class RenderError extends Error {
constructor(code: string, message: string, node: IRNode);
readonly code: string;
readonly node: IRNode;
name = "RenderError";
}
A structured error carrying the failing IR node and a machine-readable code. Codes currently emitted by the core:
| Code | Raised when |
|---|---|
UNKNOWN_NODE | A renderer threw a non-RenderError exception; the original error is wrapped with this code. |
HIGHLIGHT_ERROR | The code-block renderer's call to the highlighter failed. |
Render errors do not abort the walk. When a renderer throws, renderNodes catches it, routes it to ctx.onError, and substitutes an inline error element:
{
kind: "element",
tag: "div",
props: { class: "docvia-render-error" },
children: [{ kind: "text", value: "Render error: <message>" }],
}
API reference
createDefaultRendererMap()
function createDefaultRendererMap(): RendererMap;
Builds a RendererMap covering every standard IR node type. The returned map handles:
paragraph, heading, text, emphasis, strong, code-block, inline-code, image, link, list, list-item, table, table-row, table-cell, blockquote, thematic-break, component, component-inline, element, and unknown (the fallback).
Notable behaviours:
headingemitsh1toh6fromnode.props.depthand copiesnode.props.idso anchored links work.code-blockemits a node's pre-highlightedprops.htmldirectly when present (set by a build-time plugin such as@docvia/plugin-shiki). Otherwise, ifctx.highlighteris set, it callshighlight()and wraps the result in<div class="docvia-code-block">; if no highlighter is configured, it emits a plain<pre><code>block. A failing highlight call reports aHIGHLIGHT_ERRORand falls back to a plain<pre>.listemitsol(carryingstart) orulbased onnode.props.ordered.table-cellemitsthortdbased onnode.props.tag.componentandcomponent-inlineresolve the name through the registry, mergedefaultPropsunder the directive attributes, read thehydratemode (defaulting to"none"), and emit acomponentoutput.component-inlinealways has empty children.unknownemits a<div data-unknown-type="…">placeholder rather than throwing.
The map is self-referential: every renderer recurses through the same map, so a custom map can be built by spreading the default and overriding individual keys.
renderDocument()
function renderDocument(
doc: IRDocument,
map: RendererMap,
ctx: Omit<RenderContext, "manifest">,
): Promise<RenderResult>;
The top-level entry point. It creates a fresh HydrationManifest, completes the RenderContext, walks doc.children, and returns a RenderResult whose output is a fragment rooting the page. This is the function build-time adapters call once per document.
renderNodes()
function renderNodes(
nodes: readonly IRNode[],
map: RendererMap,
ctx: RenderContext,
): Promise<RenderOutput[]>;
Renders a list of sibling nodes. Used internally by renderDocument and by individual renderers to recurse into children. Key behaviours:
- Concurrent. All siblings are rendered with
Promise.all; they have no ordering dependency. - Error isolation. A throwing renderer is caught, wrapped in a
RenderError, routed toctx.onError, and replaced by an inline error element. One bad node never breaks the page. - Manifest collection. After all renders settle, any
componentoutput with ahydratemode other than"none"is pushed ontoctx.manifestin document order.
hydrate()
function hydrate(
manifest: HydrationManifest,
registry: ComponentRegistry,
): Promise<void>;
A generic, Svelte-style island hydration helper. It walks the manifest and mounts each interactive component at its [data-hid] anchor.
- No-ops immediately when
windowis undefined (server / build). - Honours each entry's
hydratemode:client:load: hydrate immediately.client:idle: hydrate onrequestIdleCallback(with asetTimeout(…, 200)fallback).client:visible: hydrate when the anchor scrolls into view viaIntersectionObserver.
- Idempotent. Every hydrated id is tracked in a module-level set, so repeated calls never double-mount.
- Instantiates the resolved component with
new Component({ target, props, hydrate: true }), the Svelte-component calling convention.
This helper assumes the Svelte component instantiation API. The React adapter ships its own DOM-aware
hydrate()in@docvia/renderer-react/clientthat useshydrateRoot/createRootinstead.
Usage
Rendering a document with the default map
import {
createDefaultRendererMap,
renderDocument,
type ComponentRegistry,
type SyntaxHighlighter,
} from "@docvia/renderer-core";
import type { IRDocument } from "@docvia/ir";
const registry: ComponentRegistry = {
resolve: () => null, // no custom components
};
const highlighter: SyntaxHighlighter = {
async highlight(code) {
return { html: `<pre><code>${code}</code></pre>` };
},
};
async function render(doc: IRDocument) {
const { output, manifest } = await renderDocument(
doc,
createDefaultRendererMap(),
{
slug: doc.slug,
meta: {
slug: doc.slug,
title: doc.frontmatter.title,
description: doc.frontmatter.description,
headings: doc.headings,
contentHash: doc.contentHash,
lastModified: Date.now(),
},
registry,
highlighter,
onError: (err) => console.error(err.code, err.message),
},
);
return { output, manifest };
}
Extending the renderer map
Because the default map is self-referential, you can override one node type while keeping the rest of the pipeline intact:
import { createDefaultRendererMap, type RendererMap } from "@docvia/renderer-core";
function createCustomMap(): RendererMap {
const map = createDefaultRendererMap();
// Wrap every blockquote in a callout element.
const base = map.blockquote;
map.blockquote = async (node, ctx) => {
const out = await base(node, ctx);
return {
kind: "element",
tag: "aside",
props: { class: "callout" },
children: [out],
};
};
return map;
}
Consuming the output in an app
The RenderOutput tree is plain JSON, so any framework can walk it. A minimal consumer:
import type { RenderOutput } from "@docvia/renderer-core";
function toHtml(node: RenderOutput): string {
switch (node.kind) {
case "text":
return node.value;
case "html":
return node.value;
case "fragment":
return node.children.map(toHtml).join("");
case "element": {
const inner = (node.children ?? []).map(toHtml).join("");
return `<${node.tag}>${inner}</${node.tag}>`;
}
case "component":
return `<div data-hid="${node.id}"></div>`;
}
}
In practice you would use @docvia/renderer-react or @docvia/renderer-svelte, which provide complete, hydration-aware renderers for the same tree.