Rendering#

RichTextView renders source text (or a tree) on the web. Underneath it is a renderer-neutral engine that walks the tree through a component map — the same engine the Lynx MarkdownView uses.

RichTextView#

TSX
import { RichTextView } from '@sigx/richtext/dom';
import { markdownFormat } from '@sigx/richtext-markdown';

<RichTextView value={source} format={markdownFormat} onLink={(url) => router.push(url)} />

The view owns one incremental engine per instance, so a growing value re-parses only the live tail and re-renders only the block still being written — see Streaming. The DOM entry knows no format: format is required and decides how value is parsed.

PropTypeDescription
formatDocumentFormatRequired. The format value is written in; its nodes join the schema. A new identity re-creates the engine.
valuestringSource text in format. Reactive: append to it and only the live block re-renders.
rootRootA parsed tree instead of source (wins over value). Keys are assigned if missing.
pluginsreadonly RichTextPlugin[]Node types, syntax and renderers. Pass a stable array — a new identity re-parses from scratch.
schemaSchemaThe schema to render with. Default: the standard specs plus the format's and every plugin's nodes.
componentsPartial<DomComponents>Overrides for any slot of the default map, plus renderers for plugin node types.
onLink(url, node, event) => voidLink clicks are routed here (with preventDefault) instead of navigating. url is the sanitised URL.
linkTarget'_blank' | '_self'target for external (http(s)) links when no onLink is given.
sanitizeUrl(url, kind) => stringReplace the default URL allow-list.
classPrefixstringAdds <prefix>-<part> classes next to the data-part attributes.
copyButtonbooleanShow the copy button on code blocks. Default true.

Any other attribute (id, style, aria-*, class) lands on the root element; class composes with the part class.

Render a saved document by passing it as root:

TSX
import { fromJSON } from '@sigx/richtext';

<RichTextView root={fromJSON(saved)} format={markdownFormat} />

Component maps#

The engine is generic over the element type E — a sigx VNode on the web and on Lynx, a string or layout node in a terminal renderer. A platform supplies one ComponentMap<E>; the engine owns the recursion, the props each node type carries (from the schema) and the reconciliation keys, so a component only decides which element wraps its already-rendered children.

Only root is required. A node type without a component renders its spec's text projection, else its children. Block components must return an element (the engine stamps the block's key on it); inline components may return a plain string.

SlotProps beyond node
root, paragraph, blockquotechildren
headingdepth, children
listordered, start, spread, children
listItemordered, index, number (start + index), checked (boolean | null), spread, children
codevalue, lang, meta, open (true while the fence is unterminated)
thematicBreak—
tablealign (one entry per column), children (children[0] is the header row)
tableRowheader, index, children
tableCellheader, align, index, children
htmlvalue (raw HTML — the default DOM renderer shows it as literal text)
definition— (renders nothing without a component)
text, inlineCodevalue
emphasis, strong, deletechildren
break—
linkurl (sanitised), title, autolink, onLink, children
imageurl (sanitised), alt, title

Plugin node types get a flat slot keyed by node.type — components.mention, components.emoji.

TSX
import type { DomComponents } from '@sigx/richtext/dom';

const components: Partial<DomComponents> = {
    heading: ({ depth, children }) => <h2 class={`title-${depth}`}>{children}</h2>,
    mention: ({ node }) => <a class="chip" href={`/u/${node.id}`}>@{node.label}</a>,
};

<RichTextView value={source} format={markdownFormat} plugins={[mentionPlugin]} components={components} />

createDomComponents({ classPrefix, linkTarget, copyButton }) builds the default DOM map; defaultComponents is that map with no options. To render outside a view — a terminal renderer, a server string — call the engine directly:

TypeScript
import { renderDocument, standardSchema } from '@sigx/richtext';

renderDocument(root, { components, schema: standardSchema });

RenderContext also takes env, onLink, sanitizeUrl and stampKey (how a key is stamped on an element — a string renderer passes a no-op). renderBlock and renderInline render a single block or phrasing run; missingComponents(schema, components) lists node types a map has no slot for.

URLs are sanitised at render time, before a component sees them. The default allow-list is http, https, mailto and tel for links, http and https for images, plus relative URLs; anything else becomes #. The check follows how browsers parse URLs, so a scheme split by a tab or newline is still caught. Pass sanitizeUrl to replace it.

Reference links ([text][ref]) are emitted as linkReference / imageReference nodes whether or not a definition exists, and resolve against the document's definitions at render time.

Styling#

The default DOM components carry no classes and no CSS. Every element has data-scope="richtext" and a data-part naming what it is:

root, paragraph, heading, blockquote, list, list-item, checkbox, code, code-header, code-lang, copy, pre, code-body, thematic-break, table, table-head, table-body, table-row, table-cell, emphasis, strong, delete, inline-code, break, link, image.

State rides on attributes:

AttributeOnMeaning
data-depthheading1–6
data-ordered, data-spreadlistOrdered list; loose list
data-task, data-checkedlist-itemA GFM task item; its box is ticked
data-headertable-rowThe header row
data-aligntable-cellleft, center or right
data-autolinklinkA <…> or bare autolink
data-lang, data-open, data-copiedcodeFence language; still streaming; just copied
CSS
[data-scope="richtext"][data-part="heading"][data-depth="1"] { font-size: 2rem; }
[data-scope="richtext"][data-part="list-item"][data-task] { list-style: none; }
[data-scope="richtext"][data-part="code"][data-open] { opacity: 0.8; }

Prefer class selectors? classPrefix="rt" adds rt-heading, rt-code, … next to the attributes.

Code blocks and highlighting#

The default code slot is CodeBlock: a header with the language label and a clipboard copy button, then pre > code. The copy button is omitted when the Clipboard API is absent, or with copyButton={false}.

For syntax highlighting, add shikiPlugin() from @sigx/richtext-shiki — it contributes a highlighted code slot through components.dom:

TSX
import { shikiPlugin } from '@sigx/richtext-shiki';

const plugins = [shikiPlugin({ themes: { light: 'github-light', dark: 'github-dark' } })];

<RichTextView value={source} format={markdownFormat} plugins={plugins} />
OptionDefaultDescription
themes{ light: 'github-light', dark: 'github-dark' }The dual theme pair
langsDEFAULT_LANGSGrammars preloaded with the highlighter (javascript, typescript, jsx, tsx, json, css, html, markdown, bash, shell)
loadLanguagestrueLoad other bundled grammars on demand
load() => import('shiki')How to load shiki — pass your own for a fine-grained bundle
cacheSize200LRU cache entries, one per distinct (lang, code)
debounceMs120Delay before highlighting an unterminated (streaming) fence; 0 highlights immediately
classPrefix, copyButton—Forwarded to CodeBlock

How it behaves:

  • shiki loads on the first code block. Until then a block renders as plain tokens and swaps in place.
  • While a fence is still open, highlighting is debounced; the last result stays on screen with the new tail plain, so a block never flashes back to unhighlighted text. A closed fence highlights at once, and a cached result renders synchronously.
  • Highlighting never rejects: if the import, grammar or tokenizer fails, the block renders as plain text.
  • Tokens are span elements with inline styles — never innerHTML.

Tokens are produced in shiki's dual-theme mode: each carries the light colour in color and the dark one as the --shiki-dark CSS variable. Switch with one rule:

CSS
[data-theme="dark"] [data-scope="richtext"][data-part="code-body"] span { color: var(--shiki-dark); }

Any other highlighter#

highlightedCodeBlock(highlighter, options?) in @sigx/richtext/dom turns anything implementing CodeHighlighter into a code slot:

TypeScript
interface CodeHighlighter {
    peek(code: string, lang: string | null): HighlightedToken[][] | null;       // cached tokens, synchronously
    highlight(code: string, lang: string | null): Promise<HighlightedToken[][]>; // never rejects
    supports?(lang: string): boolean;
}

createShikiHighlighter(options) is the shiki implementation on its own, and plainTokens(code) gives the one-token-per-line fallback.