View renderer: display documents without an editor
Display saved documents without paying for an editor. The @bloklabs/core/view subpath renders OutputData to semantic HTML or plain text.
It is synchronous and DOM-free, so it runs in Node, workers, and React Server Components. Display-only surfaces (published pages, previews, search indexing, emails) no longer need an editor instance, its bundle, or its async ready latch.
Every inline-content field is sanitized against the composed allowlist before interpolation, and the URL scheme policy is identical to the editor's. Pair the functions with defineBlokSchema and documents are displayed under the same sanitize composition that produced them. If you later change the inline-tool set at runtime via tools.setInlineToolbar, recompose the schema so the view keeps up.
For React, use <BlokView> or the wrapper-free useBlokView as the read-only path. Do not reach for <BlokEditor readOnly>, which ships the full editing runtime (toolbar, history, mutation machinery) to every viewer.
Output is unstyled by default.
- For editor parity, opt into classes and root together with the opt-in @bloklabs/core/view.css.
- For the classless baseline, use toolAttributes alone with that stylesheet: it reproduces the editor's block spacing from the same --blok-block-padding-* tokens.
Enable blockIds for copy-link-to-block deep links, and pass transformUrl to rewrite hrefs or CDN image URLs.
Reaching the editor instance
The methods below run on the editor you created with new Blok(). They are available once editor.isReady resolves.
// You already hold the instance returned by the constructor.
const editor = new Blok({ holder: 'editor' });
await editor.isReady;
// Call any API method on it.
editor.caret.setToLastBlock('end');Methods
blocksToHtml(data, options?)
stringRender a saved document to semantic HTML. It is synchronous and DOM-free, so it is safe in Node, workers, and React Server Components.
Every inline-content field is sanitized against the composed allowlist before interpolation, and the URL scheme policy is identical to the editor's. Returns '' for empty or malformed documents (nullish input is tolerated).
Parameters
| Parameter | Type | Required | Default | Description |
|---|---|---|---|---|
data | OutputData | LooseOutputData | null | undefined | Required | — | Saved document, in the strict save() shape or the loose wire shape. |
options.schema | BlokViewSchema | — | — | viewSchema from defineBlokSchema. It carries a single composed baseSanitize, folded from the enabled INLINE TOOLS and TUNES, not from block tools' own static sanitize. That config is spread over the renderer's default inline allowlist, so inline content displays under the same composition that produced it. viewSchema.tools is carried for consumers, but the renderer does not read it. To control a custom block's markup, use renderers. |
options.renderers | Record<string, (data, ctx) => string> | — | — | Custom per-tool renderers. A renderer wins over the built-in emitter for its tool name. ctx provides sanitizeInline (sanitize an inline-HTML string), renderBlocks (render an arbitrary block array), plainText (plain text of an HTML string), and renderChildren (render the current block's structural children), so custom output composes safely with the rest of the document. |
options.inlineRenderers | Record<string, (element) => string | undefined> | — | — | Custom renderers for INLINE elements, keyed by lowercase tag name. They are the inline counterpart of renderers, for marks whose display is not their stored markup: an inline equation stores only its LaTeX source, a mention only an id. Each one runs after sanitization, over the elements that survived it, and REPLACES the element with what it returns. Return undefined to keep the element as sanitized, or '' to drop it. The returned markup is inserted as-is and is NOT re-sanitized. That is the same trust contract as a block renderer's output, so it may carry markup the inline allowlist would strip (KaTeX spans, for instance). element is { tag, attrs, html, text }. This affects rendered HTML only: blocksToPlainText reads a mark's stored source, not its rendering. |
options.onUnknownBlock | 'skip' | 'comment' | — | 'skip' | What to do with a block whose tool has no renderer: drop it silently, or leave an HTML comment marker in the output. |
options.toolAttributes | boolean | — | false | Stamp data-blok-tool="<type>" on each block root (list runs on their <ul>/<ol>) as a styling hook. Import the opt-in @bloklabs/core/view.css to reproduce the editor's block spacing from the same --blok-block-padding-* tokens, instead of reverse-engineering it with bare-tag CSS. Only Blok's built-in markup is stamped. Custom renderers and bare containers (database) are left untouched. |
options.blockIds | boolean | — | false | Stamp data-blok-id="<id>" on each block root, and list items on their <li> rather than the grouped <ul>/<ol>, so "copy link to block" deep links resolve off the live editor. Blocks without an id are left unstamped, and so are bare containers that emit no root of their own (database). |
options.transformUrl | (url, ctx) => string | — | — | Pure URL rewrite hook. It runs on every block URL (image/video/audio src, file/bookmark/embed href) and on every inline anchor href, so you can rewrite hrefs or route CDN image URLs. ctx is { attr: 'href' | 'src', blockType?: string }, and blockType is undefined for inline anchors. The hook runs BEFORE the unsafe-scheme strip, so a rewrite can never re-introduce a javascript:/data: sink. Return '' to drop the URL. |
options.root | boolean | — | false | Wrap the output in <div data-blok-interface="view">. This is not cosmetic. The scoped preflight and the token/colour layers key on the bare [data-blok-interface] attribute, so emitted classes compute differently without the wrapper and @bloklabs/core/view.css cannot reproduce the editor's appearance. It is opt-in because it adds an element to existing output. <BlokView> stamps the attribute on its own wrapper, so React consumers never set this. |
options.classes | boolean | — | false | Render blocks with the editor's presentational classes and the per-block holder → content scaffolding, so the result matches a read-only editor render. To actually paint, it needs @bloklabs/core/view.css plus root: true (or a [data-blok-interface] ancestor). A few tools also gain a wrapper element under this flag. <BlokView> enables it by default, the useBlokView hook does not. |
import { blocksToHtml } from '@bloklabs/core/view';
const html = blocksToHtml(savedData, {
schema: schema.viewSchema,
onUnknownBlock: 'comment',
renderers: {
// Wins over the built-in paragraph emitter
paragraph: (data, ctx) =>
`<p class="lead">${ctx.sanitizeInline(String(data.text ?? ''))}</p>`,
},
});blocksToPlainText(data, options?)
stringExtract the plain text of a saved document. Blocks are separated by \n\n, list items by \n, and table cells by \t. It is synchronous and DOM-free, with the same options as blocksToHtml plus includeHiddenText. Use it for previews, search indexing, and character counts.
When to use
There is no core "document size" helper, because the two sizes you might mean are measured differently.
blocksToPlainText(data).lengthis the visible content length, which is what a user typed.new TextEncoder().encode(JSON.stringify(data)).lengthis the transport size in bytes, which is what a save or upload limit (e.g. 500KB) should check.
Use the plain-text length for content rules and the JSON byte length for storage rules.
Parameters
| Parameter | Type | Required | Default | Description |
|---|---|---|---|---|
data | OutputData | LooseOutputData | null | undefined | Required | — | Saved document, in the strict save() shape or the loose wire shape. |
options.includeHiddenText | boolean | — | — | Also read the media text the default output leaves out, because the editor paints it as an attribute or does not paint it at all. That covers an image's alt, a video's or file's url, an embed's source, an audio track's title, artist and url, and a bookmark's description and url. Each one is appended after the block's visible label, one per line. It is off by default, so the default output stays exactly what a reader sees on screen. Turn it on for a search index, where alt text and a pasted URL are both things people search for. |
import { blocksToPlainText } from '@bloklabs/core/view';
// A 160-character preview for a card or meta description
const preview = blocksToPlainText(savedData).slice(0, 160);
// Content length (characters the user typed) vs transport size (bytes on the wire)
const contentLength = blocksToPlainText(savedData).length;
const transportBytes = new TextEncoder().encode(JSON.stringify(savedData)).length;
if (transportBytes > 500 * 1024) {
throw new Error('Document exceeds the 500KB save limit');
}blocksToMarkdown(data)
stringSerialize a saved document to Markdown. It is synchronous and DOM-free, the outbound twin of markdownToBlocks. Headings become #, lists -/1., to-dos - [x], and tables GFM pipe grids.
Markdown has no callout, toggle, column or spacer. So a callout becomes a blockquote carrying its emoji, a toggle becomes a bold summary followed by its body, columns flatten into reading order, and a spacer is dropped.
Returns '' for empty or malformed documents.
import { blocksToMarkdown } from '@bloklabs/core/view';
// Feed an article to an LLM, or write it to a .md file
const markdown = blocksToMarkdown(savedData);blocksToMarkdownWithReport(data)
{ markdown: string; warnings: MarkdownDegradation[] }The same Markdown, plus a list of what could not be carried across. Each entry names the construct, says whether it was 'dropped' (nothing emitted) or 'degraded' (emitted lossily), and why.
Use it when the result goes somewhere that cannot ask a follow-up question, such as an AI client or an export, and needs to be told what it is missing. A block that leaves no output and carries no inline text is reported too, so a custom tool with no Markdown form is named rather than vanishing.
import { blocksToMarkdownWithReport } from '@bloklabs/core/view';
const { markdown, warnings } = blocksToMarkdownWithReport(savedData);
// warnings: [{ construct: 'callout', action: 'degraded', detail: 'callout is rendered as a blockquote; …' }]htmlTextContent(html)
stringExtract the plain text of an HTML fragment. It is synchronous and DOM-free, the view renderer's replacement for element.textContent. Entities are decoded (a < b → a < b) and <br> becomes a newline. Use it instead of hand-rolling a DOMParser strip (which needs a DOM) when reducing an inline-HTML field to text.
import { htmlTextContent } from '@bloklabs/core/view';
htmlTextContent('<b>Intro</b> & more'); // → 'Intro & more'sanitizeHtmlFragment(html, config)
stringSanitize an HTML fragment against a sanitizer config with no DOM. It is parse5-backed and matches the editor's html-janitor semantics. config is a tag → rule allowlist, or the 'plaintext' sentinel to strip markup entirely. It is the DOM-free counterpart of api.sanitizer.clean(), so use it in Node, workers and RSC, where the editor's sanitizer cannot run.
import { sanitizeHtmlFragment } from '@bloklabs/core/view';
sanitizeHtmlFragment('<b>bold</b><script>x()</script>', { b: {} });
// → '<b>bold</b>'outlineFromOutputData(data)
OutlineItem[]Extract the heading outline of a saved document, the source for a table of contents. It is synchronous and DOM-free. It walks the document in reading order (top-level blocks, then structural children), picks header blocks, and reduces each heading's inline HTML to plain text.
Each item is { id?, level, text }. The block id drives anchor links and scroll targets, so no separate DOMParser pass is needed. Headings with empty text are skipped.
import { outlineFromOutputData } from '@bloklabs/core/view';
const toc = outlineFromOutputData(savedData);
// → [{ id: 'h1', level: 1, text: 'Getting Started' }, ...]restoreHeadingAnchors(data)
{ data, report }Repair in-document links whose target was lost during an import. HTML addresses its own sections by an id on the heading. Google Docs writes <h2 id="h.2y1ok8y7pef0"> and links its table of contents to that fragment.
A converter that mints its own block ids and drops the source ones leaves those links pointing at nothing. The link's text still names the heading, so this pass hands each dead fragment to the heading that text names, as HeaderData.anchor.
Because it writes content, it guesses as little as possible.
- It takes only headings with no anchor yet.
- It needs an exact text match. Markup, entities and whitespace are normalized away first, punctuation is not.
- It acts only when exactly one heading and one fragment claim each other.
Anything less certain is left alone and listed in report.skipped. Running it twice changes nothing further.
Call it yourself as a one-off upgrade: it is not part of the automatic load path. It is DOM-free, so it runs in a Node script over stored records. Migrate legacy data first.
import { restoreHeadingAnchors } from '@bloklabs/core/view';
const { data, report } = restoreHeadingAnchors(savedData);
// report.restored → [{ anchor: 'h.2y1ok8y7pef0', blockId: 'header-18' }, ...]
// report.skipped → [{ anchor: 'h.other', reason: 'ambiguous' }]
await save(data);defineBlokSchema(config)
{ editorConfig, viewSchema }Resolve a tools/inlineToolbar/tunes config into one shared schema. It is pure and module-scope-safe: call it at module scope and import the result everywhere.
Spread editorConfig into new Blok(...) and pass viewSchema to the view functions. This guarantees documents are displayed under the SAME sanitize composition that produced them, instead of two configs drifting apart.
The guarantee is per composition: if you change the inline-tool set at runtime with tools.setInlineToolbar, recompose the schema from the current config. Options that don't participate in schema resolution (link, i18n, data, …) pass through editorConfig untouched.
import { defineBlokSchema, blocksToHtml } from '@bloklabs/core/view';
import { Header, Paragraph } from '@bloklabs/core/tools';
const schema = defineBlokSchema({
tools: { paragraph: Paragraph, header: Header },
});
const editor = new Blok({ holder: 'editor', ...schema.editorConfig });
const html = blocksToHtml(savedData, { schema: schema.viewSchema });composeBaseSanitizeConfig(configs)
SanitizerConfigFold an ordered list of sanitize configs with the editor's exact merge semantics: a later-wins Object.assign, inline tools first, then tunes. Function rules are carried by reference. Rules for the same tag are REPLACED, never deep-merged.
This is the same fold defineBlokSchema uses to build viewSchema.baseSanitize, exposed for hand-built lists. It is exported from both @bloklabs/core and @bloklabs/core/view.
import { composeBaseSanitizeConfig } from '@bloklabs/core/view';
const baseSanitize = composeBaseSanitizeConfig([
{ b: {}, i: {} },
{ a: { href: true } },
]);
// → { b: {}, i: {}, a: { href: true } }blocksToViewNodes(data, options?)
ViewNode[]Render to a framework-agnostic JSON tree instead of an HTML string. Each node is { tag, attrs, children } or { text }, with the same options and sanitization pipeline as blocksToHtml. This is what the React bindings map to real elements.
It is experimental: the shape is not frozen until a second framework adapter consumes it, so it may change in a minor release.
import { blocksToViewNodes } from '@bloklabs/core/view';
const nodes = blocksToViewNodes(savedData);
// → [{ tag: 'p', attrs: {}, children: [{ text: 'Hello' }] }]renderLatex(latex, options?)
Promise<string>Render a LaTeX string to HTML with the KaTeX build Blok already bundles (its code tool, equation inline tool and markdown importer all use it).
It is hardened for untrusted input.
- trust: false forbids the markup-injecting commands (\href, \includegraphics, \html*).
- maxExpand caps macro expansion.
- maxSize caps element sizing.
- throwOnError: false renders malformed math as escaped source instead of failing the document.
Use it instead of adding katex as your own dependency, which is a second copy of the library and a second, unaudited option set.
KaTeX is imported lazily on the first call. With no document present (SSR, workers) the stylesheet injection is skipped and you include katex.min.css yourself, and the markup is identical.
For inlineRenderers use createLatexRenderer, which is synchronous.
import { renderLatex } from '@bloklabs/core/view';
const html = await renderLatex('c = \\pm\\sqrt{a^2 + b^2}', { displayMode: false });createLatexRenderer()
Promise<(latex: string, options?: LatexRenderOptions) => string>Load KaTeX once and get back a synchronous LaTeX renderer. That is the form inlineRenderers needs, since it replaces an element with the string it returns, and a promise there stringifies as [object Promise].
It is shaped as "await the loader, get the renderer", so there is no call order to get wrong: the renderer cannot exist before KaTeX is ready. It uses the same hardened options as renderLatex.
This is what lets a display surface render equations through blocksToHtml/BlokView instead of booting a read-only editor for them.
import { blocksToHtml, createLatexRenderer } from '@bloklabs/core/view';
const renderLatexSync = await createLatexRenderer();
const html = blocksToHtml(savedData, {
inlineRenderers: {
span: ({ attrs }) => attrs['data-latex'] === undefined
? undefined
: renderLatexSync(attrs['data-latex'], { displayMode: false }),
},
});BlokView
ReactNodeThe React display component from @bloklabs/react. It renders a saved document inside a single <div> wrapper: no editor instance, no chrome, no async, no effects, and never dangerouslySetInnerHTML (content is mapped from the sanitized view tree to real React elements).
The wrapper always carries data-blok-interface="view", which is what makes the emitted classes compute as they do in the editor. It is written BEFORE the divProps spread, so a caller can override it. Never override it to "blok", which carries all: initial !important and would block host typography.
This is the read-only path to reach for at display-only call sites, instead of <BlokEditor readOnly>: it costs no editor bundle, has no ready latch, and renders identically under SSR.
Its props API is stable. Only the raw ViewNode tree it maps from (via blocksToViewNodes) stays experimental, and using BlokView never exposes you to it.
Parameters
| Parameter | Type | Required | Default | Description |
|---|---|---|---|---|
data | OutputData | LooseOutputData | null | undefined | Required | — | Saved document to display (nullish tolerated). |
schema | BlokViewSchema | — | — | viewSchema from defineBlokSchema, so the document displays under the composition that produced it. |
renderers | Record<string, (data, ctx) => string> | — | — | Custom per-tool renderers. They win over the built-ins. |
onUnknownBlock | 'skip' | 'comment' | — | 'skip' | Unknown-tool policy ('comment' markers are dropped in the React tree). |
toolAttributes | boolean | — | false | Stamp data-blok-tool on each block root (pairs with @bloklabs/core/view.css). Forwards to blocksToHtml. |
blockIds | boolean | — | false | Stamp data-blok-id on each block root (list items on their <li>) for copy-link-to-block deep links. |
transformUrl | (url, ctx) => string | — | — | URL rewrite hook for block URLs and inline anchors, run before the unsafe-scheme strip. Forwards to blocksToHtml. |
inlineRenderers | Record<string, (element) => string | undefined> | — | — | Per-tag renderers for INLINE elements. Use them to render an equation's LaTeX with KaTeX on the server, or to turn a mention span into a chip. Forwards to blocksToHtml. |
classes | boolean | — | true | Render with the editor's presentational classes and the per-block holder → content scaffolding, so the output matches a read-only editor render. It is on by default here, because this component owns a wrapper and its job is to look like the editor. It needs @bloklabs/core/view.css imported to paint. Pass classes={false} for unstyled semantic markup. |
...divProps | HTMLAttributes<HTMLDivElement> | — | — | Any standard <div> attribute (className, id, style, data-*, aria-*, event handlers, …) is forwarded onto the single wrapper element. |
import { BlokView } from '@bloklabs/react';
import { schema } from './schema';
import '@bloklabs/core/view.css'; // opt-in block-spacing baseline
export function Article({ saved }: { saved: OutputData }) {
return (
<BlokView
data={saved}
schema={schema.viewSchema}
toolAttributes
blockIds
id="article-body"
className="prose"
/>
);
}useBlokView(data, options?)
ReactNodeThe wrapper-free form of BlokView. It returns a Fragment of the block elements with no extra <div>, for slots where a wrapper is invalid or unwanted: checkbox labels, table cells, headings. It is synchronous and effect-free (SSR-safe), memoized on the data reference and the individual option values.
It takes the same options as blocksToHtml, with two exceptions.
- root is ignored, since emitting no wrapper is the hook's contract.
- classes defaults to false here, while it defaults to true in <BlokView>, which owns a wrapper.
Passing root: true type-checks and silently does nothing. When you need view.css to paint, wrap the returned Fragment yourself in an element carrying data-blok-interface="view".
import { useBlokView } from '@bloklabs/react';
function RowLabel({ saved }: { saved: OutputData }) {
const content = useBlokView(saved, { schema: schema.viewSchema });
return <label>{content}</label>;
}// schema.ts — pure and module-scope-safe; share it between editor and server
import { defineBlokSchema } from '@bloklabs/core/view';
import { Header, Paragraph, List } from '@bloklabs/core/tools';
export const schema = defineBlokSchema({
tools: { paragraph: Paragraph, header: Header, list: List },
});
// Editing side (browser)
import Blok from '@bloklabs/core';
const editor = new Blok({ holder: 'editor', ...schema.editorConfig });
// Display side — Node, a worker, an RSC, or the browser; no DOM needed
import { blocksToHtml, blocksToPlainText } from '@bloklabs/core/view';
const html = blocksToHtml(savedData, { schema: schema.viewSchema });
const preview = blocksToPlainText(savedData).slice(0, 160);