Skip to content
FrameworkJavaScript

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 synchronously and DOM-free — it runs in Node, workers, and React Server Components — so 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, with a URL scheme policy 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, <BlokView> (and the wrapper-free useBlokView) is the obvious read-only path — reach for it instead of <BlokEditor readOnly>, which ships the full editing runtime (toolbar, history, mutation machinery) to every viewer. Output is unstyled by default: opt into classes + root together with the opt-in @bloklabs/core/view.css for editor parity, or toolAttributes alone with that stylesheet for the classless baseline that 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 / CDN image URLs.

Last updated Aug 7, 2026Edit this page on GitHub

Reaching the editor instance

The methods below run on the editor you created with new Blok(). They are available once editor.isReady resolves.

TypeScript
// 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?)

string

Render a saved document to semantic HTML — 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

ParameterTypeRequiredDefaultDescription
dataOutputData | LooseOutputData | null | undefinedRequiredSaved document, in the strict save() shape or the loose wire shape.
options.schemaBlokViewSchemaviewSchema from defineBlokSchema. Its single composed baseSanitize — folded from the enabled INLINE TOOLS and TUNES, not from block tools' own static sanitize — 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; the renderer does not read it. To control a custom block's markup, use renderers.
options.renderersRecord<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.inlineRenderersRecord<string, (element) => string | undefined>Custom renderers for INLINE elements, keyed by lowercase tag name — 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 runs after sanitization, over the elements that survived it, and REPLACES the element with what it returns: undefined keeps the element as sanitized, '' drops it. The returned markup is inserted as-is — it is NOT re-sanitized, 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 }. 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.toolAttributesbooleanfalseStamp 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.blockIdsbooleanfalseStamp data-blok-id="<id>" on each block root (list items on their <li>, not the grouped <ul>/<ol>), so "copy link to block" deep links resolve off the live editor. Blocks without an id and bare containers that emit no root of their own (database) are left unstamped.
options.transformUrl(url, ctx) => stringPure URL rewrite hook applied to every block URL (image/video/audio src, file/bookmark/embed href) and every inline anchor href — for rewriting hrefs or routing CDN image URLs. ctx is { attr: 'href' | 'src', blockType?: string } (blockType is undefined for inline anchors). It runs BEFORE the unsafe-scheme strip, so a rewrite can never re-introduce a javascript:/data: sink; returning '' drops the URL.
options.rootbooleanfalseWrap the output in <div data-blok-interface="view">. 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. 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.classesbooleanfalseRender blocks with the editor's presentational classes and the per-block holder → content scaffolding, so the result matches a read-only editor render. Requires @bloklabs/core/view.css plus root: true (or an [data-blok-interface] ancestor) to actually paint; a few tools also gain a wrapper element under this flag. <BlokView> enables it by default; the useBlokView hook does not.
TypeScript
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?)

string

Extract the plain text of a saved document — blocks are separated by \n\n, list items by \n, table cells by \t. Synchronous and DOM-free, same options as blocksToHtml plus includeHiddenText. Ideal 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).length is the visible content length (what a user typed), while new TextEncoder().encode(JSON.stringify(data)).length is the transport size in bytes (what a save/upload limit — e.g. 500KB — should check). Use the plain-text length for content rules and the JSON byte length for storage rules.

Parameters

ParameterTypeRequiredDefaultDescription
dataOutputData | LooseOutputData | null | undefinedRequiredSaved document, in the strict save() shape or the loose wire shape.
options.includeHiddenTextbooleanAlso read the media text the default output leaves out, because the editor paints it as an attribute or does not paint it at all: 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 is appended after the block's visible label, one per line. 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.
TypeScript
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)

string

Serialize a saved document to Markdown — synchronous and DOM-free, the outbound twin of markdownToBlocks. Headings become #, lists -/1., to-dos - [x], tables GFM pipe grids. Markdown has no callout, toggle, column or spacer, so a callout becomes a blockquote carrying its emoji, a toggle a bold summary followed by its body, columns flatten into reading order, and a spacer is dropped. Returns '' for empty or malformed documents.

TypeScript
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, whether it was 'dropped' (nothing emitted) or 'degraded' (emitted lossily), and why. Reach for it when the result goes somewhere that cannot ask a follow-up question — an AI client, 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.

TypeScript
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)

string

Extract the plain text of an HTML fragment — synchronous and DOM-free, the view renderer's replacement for element.textContent. Entities are decoded (`a &lt; 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.

TypeScript
import { htmlTextContent } from '@bloklabs/core/view';

htmlTextContent('<b>Intro</b> &amp; more'); // → 'Intro & more'

sanitizeHtmlFragment(html, config)

string

Sanitize an HTML fragment against a sanitizer config with no DOM (parse5-backed, matching the editor's html-janitor semantics). `config` is a tag → rule allowlist, or the `'plaintext'` sentinel to strip markup entirely. The DOM-free counterpart of `api.sanitizer.clean()` — use it in Node, workers and RSC, where the editor's sanitizer cannot run.

TypeScript
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. Synchronous and DOM-free: 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 / scroll targets, so no separate DOMParser pass is needed. Headings with empty text are skipped.

TypeScript
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: only headings with no anchor yet, only an exact text match (markup, entities and whitespace normalized away — punctuation is not), and 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. DOM-free, so it runs in a Node script over stored records; migrate legacy data first.

TypeScript
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. 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.

TypeScript
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)

SanitizerConfig

Fold 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, and 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. Exported from both @bloklabs/core and @bloklabs/core/view.

TypeScript
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. Experimental — the shape is not frozen until a second framework adapter consumes it, so it may change in a minor release.

TypeScript
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), hardened for untrusted input: trust: false forbids the markup-injecting commands (\href, \includegraphics, \html*), maxExpand caps macro expansion, maxSize caps element sizing, and throwOnError: false renders malformed math as escaped source instead of failing the document. Reach for it instead of adding katex as your own dependency — that 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. Use createLatexRenderer for inlineRenderers, which is synchronous.

TypeScript
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 — the form inlineRenderers needs, since it replaces an element with the string it returns and a promise there stringifies as [object Promise]. 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. 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.

TypeScript
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

ReactNode

The React display component from @bloklabs/react: 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 to "blok", which carries all: initial !important and would block host typography. This is the obvious read-only path — reach for it instead of <BlokEditor readOnly> at display-only call sites: 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

ParameterTypeRequiredDefaultDescription
dataOutputData | LooseOutputData | null | undefinedRequiredSaved document to display (nullish tolerated).
schemaBlokViewSchemaviewSchema from defineBlokSchema — display under the composition that produced the document.
renderersRecord<string, (data, ctx) => string>Custom per-tool renderers; win over the built-ins.
onUnknownBlock'skip' | 'comment''skip'Unknown-tool policy ('comment' markers are dropped in the React tree).
toolAttributesbooleanfalseStamp data-blok-tool on each block root (pairs with @bloklabs/core/view.css). Forwards to blocksToHtml.
blockIdsbooleanfalseStamp data-blok-id on each block root (list items on their <li>) for copy-link-to-block deep links.
transformUrl(url, ctx) => stringURL rewrite hook for block URLs + inline anchors, run before the unsafe-scheme strip. Forwards to blocksToHtml.
inlineRenderersRecord<string, (element) => string | undefined>Per-tag renderers for INLINE elements — server-render an equation's LaTeX with KaTeX, turn a mention span into a chip. Forwards to blocksToHtml.
classesbooleantrueRender with the editor's presentational classes and the per-block holder → content scaffolding, so the output matches a read-only editor render. On by default here — this component owns a wrapper and its job is to look like the editor — and it needs @bloklabs/core/view.css imported to paint. Pass classes={false} for unstyled semantic markup.
...divPropsHTMLAttributes<HTMLDivElement>Any standard <div> attribute (className, id, style, data-*, aria-*, event handlers, …) is forwarded onto the single wrapper element.
TypeScript
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?)

ReactNode

The wrapper-free form of BlokView: 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. Synchronous and effect-free (SSR-safe), memoized on the data reference and the individual option values. Same options as blocksToHtml, except root — which the hook ignores, since emitting no wrapper is its contract — and classes, which defaults to false here (it defaults to true in <BlokView>, which owns a wrapper). Passing root: true type-checks and silently does nothing; wrap the returned Fragment yourself in an element carrying data-blok-interface="view" when you need view.css to paint.

TypeScript
import { useBlokView } from '@bloklabs/react';

function RowLabel({ saved }: { saved: OutputData }) {
  const content = useBlokView(saved, { schema: schema.viewSchema });
  return <label>{content}</label>;
}
TypeScript
// 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);