Render component trees
Your product description or assistant answer needs a real callout, card, or chart inside rich text. Use a render tree when the content shape is data but the components it may call are pre-approved by your app.
import { component, type ComponentChild } from '@kovojs/core';
import { renderRegistry } from '@kovojs/server/render-tree';
import { s } from '@kovojs/server';
const Callout = component({
render: ({ tone = 'info' }: { tone?: string }, _state, slots: { children?: ComponentChild }) => (
<aside data-tone={tone}>{slots.children ?? ''}</aside>
),
});
export const registry = renderRegistry({
'kovo-callout': { component: Callout, props: s.object({ tone: s.string().optional() }) },
});The registry is the boundary. A tag that is not in renderRegistry(...) can never dispatch to a
component. The XML string becomes a plain AST first; renderTree(...) walks that AST server-side,
escapes text, validates attributes, and renders only registered components.
Register allowed components#
Give model/CMS tags boring names that are not ordinary HTML, then pair each tag with a component and the props schema it accepts:
import { component } from '@kovojs/core';
import { renderRegistry } from '@kovojs/server/render-tree';
import { s } from '@kovojs/server';
const ProductBadge = component({
render: ({ sku, label }: { sku: string; label: string }) => (
<a href={`/products/${sku}`} data-product-badge>
{label}
</a>
),
});
export const marketingRegistry = renderRegistry({
'kovo-product': {
component: ProductBadge,
props: s.object({ sku: s.string(), label: s.string() }),
},
});Attributes arrive as decoded strings from XML. The schema is where you coerce, default, or reject them. Without a schema, attributes pass through as strings, but they still go through JSX attribute escaping and URL-scheme checks when the component emits HTML.
Parse before storage when you can#
parseComponentXml(...) is pure, so you can validate CMS input before publishing it:
import {
ComponentXmlError,
parseComponentXml,
type ComponentNode,
} from '@kovojs/server/render-tree';
export function parsePublishedBody(source: string): ComponentNode[] {
try {
return parseComponentXml(source);
} catch (error) {
if (error instanceof ComponentXmlError)
throw new Error(`Body is not well-formed XML: ${error.message}`);
throw error;
}
}Store the source string or the parsed AST, depending on your authoring workflow. The important part: malformed markup is rejected before it becomes a page-rendering surprise.
Render at the sink#
Render the parsed tree where you would otherwise render rich text:
import { renderTree, type ComponentNode, type ComponentRegistry } from '@kovojs/server/render-tree';
import { trustedHtml } from '@kovojs/browser';
declare const marketingRegistry: ComponentRegistry;
declare function parsePublishedBody(body: string): ComponentNode[];
export async function ProductDescription({ body }: { body: string }) {
const html = await renderTree(marketingRegistry, parsePublishedBody(body), {
unknownTag: 'text',
});
return (
<section>
{trustedHtml(html, {
reason: 'renderTree escaped the rich-text tree server-side',
source: 'rich-text/render.ts',
})}
</section>
);
}renderTree(...) returns ordinary string bytes, not a branded value. That is intentional. The
walker already escaped text and kept dispatch inside your closed registry, but the raw-HTML sink
still needs an explicit trustedHtml(...) call so the trust decision stays visible in source and
kovo explain.
unknownTag: 'text' drops an unknown wrapper and keeps its children. Use unknownTag: 'drop' when
unknown tags should remove the whole subtree.
Run it#
Start with one small XML string and inspect the exact emitted HTML:
const body = '<kovo-callout tone="info">Ships today</kovo-callout>';Rendered through the registry above, the output should be the real sink bytes:
<aside data-tone="info">Ships today</aside>That is the useful proof moment here: one authored tag in, one reviewed HTML shape out.
Know the failure posture#
Render trees fail soft during rendering:
- Unknown tags render as children-only text by default, or drop entirely with
unknownTag: 'drop'. - Invalid optional attributes are stripped and the component renders with schema defaults.
- Missing required attributes make that element fall back to the unknown-tag posture.
- Text nodes are escaped by the walker before child HTML is composed into the registered component.
This is for bounded rich text, not arbitrary HTML. If the author needs raw markup, use
safeRichHtml(...) with an explicit sanitizer and audit reason instead.
Next#
- Components - author server components that can be registered.
- Security & authorization - source/sink review for HTML and trusted output.
- Wire protocol - inspect the fragment/query vocabulary after rendering.
- Server API reference - generated reference for
renderTree,renderRegistry, andparseComponentXml.
Spec & diagnostics
Registry-bounded dynamic rendering, the closed registry boundary, server-side one-shot rendering, well-formed XML parsing, unknown-tag posture, attribute validation, and text escaping are specified by SPEC §4.10. Component composition and server-rendered child posture come from SPEC §4.5. Output safety and trusted HTML boundaries come from SPEC §4.8. Schema validation follows SPEC §6.3.
API reference: @kovojs/core, @kovojs/server.