` (default `false`).
*
* The wrapper is what makes the emitted classes render like a read-only
* editor: the scoped preflight (`src/styles/preflight.css`) applies its
* box-sizing, margin, padding and border resets ONLY under
* `[data-blok-interface]`, and the token/colour layers key on the same bare
* attribute. Without it, identical classes compute differently.
*
* The value is deliberately `view`, not `blok` — `all: initial !important` is
* keyed on `[data-blok-interface=blok]` (`src/styles/isolation.css`), which
* would also block host typography from cascading in and would match
* editor-targeting selectors.
*
* Opt-in rather than default-on because switching it on would add an element
* to every existing consumer's output — a breaking change to this published
* API. `
` stamps the attribute onto the wrapper it already renders,
* so React consumers get parity without setting this.
*/
root?: boolean;
/**
* Stamp each block root with the same presentational classes the editor's
* tools apply (default `false`).
*
* This is the mechanism behind visual parity: the classes come from
* `src/shared/tool-classes/*`, the single source both the tools' `render()`
* and this renderer read, so the two cannot drift (enforced by the golden
* harness's class-parity guarantee). They need `@bloklabs/core/view.css` — and
* the `root` wrapper, or an equivalent `[data-blok-interface]` ancestor — to
* actually paint.
*
* Opt-in for the same reason as {@link root}: switching it on by default would
* change the markup every existing consumer of this published API receives.
* `` enables it internally, so React consumers get parity for free.
*/
classes?: boolean;
}
/**
* The assembled renderer for one document: exposes the internals that
* `blocksToPlainText` reuses for custom renderers.
*/
export interface HtmlRenderer {
/** Render the document's top-level blocks. */
renderTopLevel(): string;
/** Render a sibling run of normalized blocks (applies list grouping). */
renderList(blocks: ViewBlock[]): string;
/** Build the custom-renderer context for a block. */
ctxFor(block: ViewBlock): ViewRenderContext;
}
/**
* Assemble a renderer over a document model.
* @param model - document model for the run
* @param options - render options
*/
/**
* Tool types whose emitter renders children bare (no root element of their
* own — {@link builtinEmitters} maps them to `childrenOnly`). Stamping the
* `data-blok-tool` marker onto their output would land it on the first CHILD's
* element, mislabeling it, so they are skipped. A new bare emitter must be
* added here.
*/
const BARE_CONTAINER_TOOLS = new Set(['database', 'database-row']);
/**
* Tools whose emitter places its own presentational classes via
* `env.classAttr()`, so the dispatcher must NOT stamp them onto the first
* opening tag.
*
* These are the emitters that WRAP their styled element: a toggleable header
* emits ``, and stamping the first tag would put the
* heading typography on the `` — where ``-`` UA font-size and
* weight then override it. A new wrapping emitter must be added here.
*/
const SELF_STAMPING_TOOLS = new Set(['header', 'divider', 'code']);
/** One rendering unit of a sibling run: a block, or a grouped run of `list` blocks. */
type Segment = { kind: 'block'; block: ViewBlock } | { kind: 'list'; run: ViewBlock[] };
/**
* Insert a `name="value"` attribute onto the first opening tag of
* Blok-generated markup. Operates only on our own emitter output (which always
* opens with ` {
return html.replace(/^\s*<[a-z][a-z0-9]*/i, (openTag) => `${openTag} ${name}="${escapeHtml(value)}"`);
};
/**
* Merge classes onto the first opening tag of Blok-generated markup.
*
* Operates only on our own emitter output (which always opens with ` {
if (classes.length === 0) {
return html;
}
const added = escapeHtml(classes.join(' '));
const withExistingClass = /^(\s*<[a-z][a-z0-9]*[^>]*?)\sclass="([^"]*)"/i;
if (withExistingClass.test(html)) {
return html.replace(withExistingClass, (_match, head: string, prior: string) => `${head} class="${prior} ${added}"`);
}
return html.replace(/^\s*<[a-z][a-z0-9]*/i, (openTag) => `${openTag} class="${added}"`);
};
export const createHtmlRenderer = (model: DocumentModel, options: BlocksToHtmlOptions): HtmlRenderer => {
const renderers = options.renderers ?? {};
const onUnknownBlock = options.onUnknownBlock ?? 'skip';
const toolAttributes = options.toolAttributes === true;
const blockIds = options.blockIds === true;
const classes = options.classes === true;
const transformUrl = options.transformUrl;
/**
* The composed inline allowlist: the schema's baseSanitize wins over the
* default inline map, so a viewer configured via `defineBlokSchema` displays
* under the exact composition that produced the document.
*/
const inlineConfig: SanitizerConfig = {
...(INLINE_TEXT_SANITIZE as Record),
...(options.schema?.baseSanitize ?? {}),
} as SanitizerConfig;
/** Ids currently on the render stack — breaks parent-reference cycles. */
const active = new Set();
/** Inline-anchor URL rewrite bridge (blockType unknown at the inline layer). */
const inlineUrlTransform = transformUrl === undefined
? undefined
: (url: string, attr: 'href' | 'src'): string => transformUrl(url, { attr, blockType: undefined });
/**
* Inline renderers run on the SANITIZED fragment, so a renderer can only see
* what the allowlist kept. Skipped entirely when none are configured — the
* pass costs a parse/serialize round trip per inline field.
*/
const inlineRenderers = options.inlineRenderers ?? {};
const hasInlineRenderers = Object.keys(inlineRenderers).length > 0;
const env: EmitterEnv = {
inline: (value) => {
const sanitized = sanitizeHtmlFragment(typeof value === 'string' ? value : '', inlineConfig, inlineUrlTransform);
return hasInlineRenderers ? applyInlineRenderers(sanitized, inlineRenderers) : sanitized;
},
escape: (value) => escapeHtml(typeof value === 'string' ? value : ''),
childrenOf: (id) => model.childrenOf(id),
blocksById: (ids) => {
if (!Array.isArray(ids)) {
return [];
}
return ids.flatMap((id) => {
const block = typeof id === 'string' ? model.byId.get(id) : undefined;
return block === undefined ? [] : [block];
});
},
renderList: (blocks) => renderList(blocks),
url: (name, value, blockType) => {
if (typeof value !== 'string' || value === '') {
return '';
}
const resolved = transformUrl === undefined ? value : transformUrl(value, { attr: name, blockType });
if (typeof resolved !== 'string' || resolved === '' || hasUnsafeUrlProtocol(resolved, name)) {
return '';
}
return ` ${name}="${escapeHtml(resolved)}"`;
},
idAttr: (block) => (blockIds && block.id !== undefined ? ` data-blok-id="${escapeHtml(block.id)}"` : ''),
rootAttrs: (block) => {
const list = classes ? classesFor(block.type, block.data) : [];
const classPart = list.length === 0 ? '' : ` class="${escapeHtml(list.join(' '))}"`;
const toolPart = toolAttributes ? ` data-blok-tool="${escapeHtml(block.type)}"` : '';
const idPart = blockIds && block.id !== undefined ? ` data-blok-id="${escapeHtml(block.id)}"` : '';
return `${classPart}${toolPart}${idPart}`;
},
classList: (list) => (classes && list.length > 0 ? ` class="${escapeHtml(list.join(' '))}"` : ''),
classesEnabled: classes,
};
const ctxFor = (block: ViewBlock): ViewRenderContext => ({
sanitizeInline: (html) => env.inline(html),
plainText: (html) => htmlTextContent(typeof html === 'string' ? html : ''),
renderBlocks: (blocks) => {
const normalized = Array.isArray(blocks)
? blocks.map(normalizeViewBlock).filter((candidate): candidate is ViewBlock => candidate !== null)
: [];
return renderList(normalized);
},
renderChildren: () => renderList(model.childrenOf(block.id)),
});
/**
* Comment-safe tool name: collapse dash runs (no `--` inside a comment) and
* entity-escape the rest.
* @param type - raw tool name
*/
const commentSafeType = (type: string): string => escapeHtml(type.replace(/-+/g, '-'));
const renderBlock = (block: ViewBlock): string => {
const custom = renderers[block.type];
if (custom !== undefined) {
return custom(block.data, ctxFor(block));
}
const emitter = builtinEmitters[block.type];
if (emitter !== undefined) {
const bare = BARE_CONTAINER_TOOLS.has(block.type);
const base = emitter(block, env);
/** Self-stamping emitters placed all root attributes via env.rootAttrs(). */
if (SELF_STAMPING_TOOLS.has(block.type)) {
return base;
}
const withId = blockIds && !bare && block.id !== undefined
? stampAttr(base, 'data-blok-id', block.id)
: base;
const withTool = toolAttributes && !bare ? stampAttr(withId, 'data-blok-tool', block.type) : withId;
/**
* Bare containers emit no root of their own, and self-stamping emitters
* have already placed their classes — so neither is stamped here.
*/
const centrallyStamped = classes && !bare && !SELF_STAMPING_TOOLS.has(block.type);
return centrallyStamped ? stampClass(withTool, classesFor(block.type, block.data)) : withTool;
}
/** Unknown tool: the block is skipped/commented, its children still render. */
const children = renderList(model.childrenOf(block.id));
return onUnknownBlock === 'comment'
? `${children}`
: children;
};
/**
* Wrap one block's markup in the core's `holder → content` scaffolding.
*
* Reproducing this is REQUIRED for parity, and it is not per-tool styling: the
* appearance of links, bold and italic inside every block comes from the
* holder's descendant selectors (`[&_a]:text-link`, `[&_b]:font-bold`,
* `[&_i]:italic`), and the centred measure from the content element's
* `mx-auto max-w-blok-content`. A view emitting only tool classes renders
* unstyled links at full width.
*
* Two extra elements per block is a structural change to this renderer's
* published output, so it happens only when `classes` asked for parity.
* `data-blok-element` marks the holder, matching the editor's own marker.
* @param html - the block's own rendered markup
*/
const scaffold = (html: string): string => {
if (!classes || html === '') {
return html;
}
const wrapper = escapeHtml(BLOCK_WRAPPER_CLASSES.join(' '));
const content = escapeHtml(BLOCK_CONTENT_CLASSES.join(' '));
return ``;
};
const renderGuarded = (block: ViewBlock): string => {
if (block.id !== undefined) {
if (active.has(block.id)) {
return '';
}
active.add(block.id);
}
try {
/** Bare containers contribute no block of their own, so they get no holder. */
return BARE_CONTAINER_TOOLS.has(block.type) ? renderBlock(block) : scaffold(renderBlock(block));
} finally {
if (block.id !== undefined) {
active.delete(block.id);
}
}
};
/**
* A sibling run split into what renders as one unit: a single block, or a
* group of consecutive `list` blocks that share one nested /.
* @param blocks - sibling run
*/
const segmentsOf = (blocks: ViewBlock[]): Segment[] => {
return blocks.reduce((segments, block) => {
const grouped = block.type === 'list' && renderers.list === undefined;
const last = segments[segments.length - 1];
if (grouped && last?.kind === 'list') {
last.run.push(block);
return segments;
}
segments.push(grouped ? { kind: 'list', run: [block] } : { kind: 'block', block });
return segments;
}, []);
};
/**
* Render a sibling run, joined once at the end. Appending each block onto the
* markup that follows it re-allocates the whole remainder per block, which is
* quadratic — and grouping list runs by recursing down the siblings puts one
* frame on the stack per block. A 600 KB article did neither: it allocated
* past the server runtime's per-conversion memory limit and failed outright.
* @param blocks - sibling run
*/
const renderList = (blocks: ViewBlock[]): string => {
return segmentsOf(blocks).map((segment) => {
if (segment.kind === 'block') {
return renderGuarded(segment.block);
}
const run = segment.run.filter((item) => item.id === undefined || !active.has(item.id));
const listHtml = renderListRun(run, env);
/**
* Under parity each `- ` carries the tool hook itself (every list item
* IS a block), so stamping the grouping `
`/`` too would double it
* and break one-to-one pairing against the editor's flat item blocks.
* Without parity the legacy run-level hook is preserved unchanged.
*/
return toolAttributes && !classes ? stampAttr(listHtml, 'data-blok-tool', 'list') : listHtml;
}).join('');
};
return {
renderTopLevel: () => renderList(model.topLevel),
renderList,
ctxFor,
};
};
/**
* Render a saved Blok document to semantic HTML, synchronously and DOM-free.
* @param data - saved document (strict or loose wire shape; nullish tolerated)
* @param options - schema / custom renderers / unknown-block policy
* @returns HTML string ('' for empty/malformed documents)
*/
export const blocksToHtml = (
data: OutputData | LooseOutputData | null | undefined,
options: BlocksToHtmlOptions = {}
): string => {
const body = createHtmlRenderer(buildDocumentModel(data), options).renderTopLevel();
/**
* An empty document still yields the wrapper when opted in: consumers style
* the container, and a container that vanishes for empty content forces them
* to handle two output shapes.
*/
return options.root === true ? `${body}
` : body;
};