The core ships zero backend code and stays app-independent. Every heavy or
host-specific capability is a plug point: a field on the MarkdownConfig
object you inject per render, with any heavy backend behind its own subpath
entry so bundlers drop it unless you import it. Pass the config as the second
argument to renderMarkdown / renderMarkdownUnsafe / renderStreamingMarkdown,
or to the StreamingMarkdownRenderer constructor — where it is captured once and
re-applied around every update():
import { renderMarkdown, StreamingMarkdownRenderer } from '@copse/streaming-markdown'
renderMarkdown(md, { htmlPolicy: 'escape', linkDecorator: myDecorator })
const renderer = new StreamingMarkdownRenderer(el, { mathSyntax: true })Every field is optional; omitting one uses the built-in default, and passing
null scopes a field back to that default for that render. Two renderers with
different config coexist with no process-wide state to reset.
| Plug point | Config field | Optional backend entry |
|---|---|---|
| HTML sanitizer | sanitizerBackend |
…/sanitizers/dompurify |
| Syntax highlighter | codeHighlighter |
…/highlighters/highlightjs, …/highlighters/shiki |
| Diagram renderer | diagramRenderer (async — via hydrate()) |
…/diagrams/mermaid |
| Math renderer | mathRenderer (async — via hydrate()) |
…/math/katex |
| Math prose syntax | mathSyntax |
— |
| Custom fenced blocks | fenceHandlers |
— (you supply the handler) |
| Custom inline syntax | inlinePasses |
— (you supply the pass) |
| CJK-friendly emphasis / autolinks | cjkFriendlyConfig (spread) |
…/cjk |
<a> routing |
linkDecorator |
— |
Raw <img> handling |
rawImageRenderer |
— |
| Sanitizer allowlist | sanitizeExtension |
— |
| Link scheme allowlist | safeHrefSchemes |
— |
| Link/image origin policy | linkImagePolicy |
— |
| Trusted Types policy | trustedTypesPolicy |
— |
| Full HTML5 entity decoder | entityDecoder |
…/entities/full |
| Extra named entities | namedEntities |
— |
The mathRenderer and diagramRenderer backends are asynchronous, so they do
not run during the synchronous render — they flow through hydrate() (or the
hydratePending* free functions) after the pending scaffolding is on the page;
see Math and LAZY-LOADING.md. Everything else
applies during the render itself.
The whole public surface is in src/index.ts.
sanitizeRenderedMarkdown runs through a pluggable sanitizer backend. By default
it uses the browser's native Sanitizer API
(Element.setHTML) — a zero-dependency backend that pulls no sanitizer code into
your bundle, and needs no setup in a modern browser.
For Node/jsdom/SSR or older browsers without the native API, opt into the bundled DOMPurify backend (a peer dependency you install). Because it lives behind its own entry point, bundlers drop DOMPurify entirely unless you import it:
import { renderMarkdown } from '@copse/streaming-markdown'
import { dompurifyBackend } from '@copse/streaming-markdown/sanitizers/dompurify'
renderMarkdown(md, { sanitizerBackend: dompurifyBackend })You can also supply your own SanitizerBackend. If no backend is configured and
the native API is unavailable, sanitizeRenderedMarkdown throws rather than emit
unsanitized HTML. (sanitizeRenderedMarkdown itself takes no config; to run it
under a specific backend, render through renderMarkdown with sanitizerBackend
set, or wrap the call — the streaming/at-rest entries thread the config for you.)
Backends may implement an optional node path (SanitizerBackend.sanitizeInto):
the sanitized nodes are placed into the target element directly — one parse per
write instead of the string path's parse -> serialize -> re-parse, and no
innerHTML sink at all. Both bundled backends implement it; custom backends may
omit it and sinks fall back to the string path. The node path must parse in a
neutral (body/div) context so both paths serialize identically, and if you wrap
a bundled backend to customize sanitize, either override sanitizeInto
consistently or omit it — a spread copies the bundled node path, which sinks
prefer, silently bypassing your custom sanitize.
The package works on pages that enforce
Trusted Types
(Content-Security-Policy: require-trusted-types-for 'script'). Every internal
DOM write goes through a single sink chokepoint that sanitizes first and then
blesses the markup through a Trusted Types policy — a lazily created policy
named streaming-markdown by default. This does not depend on the native
Sanitizer API: with the DOMPurify (or any custom) backend, sanitized HTML is
still assigned via innerHTML as policy-minted TrustedHTML. (When the active
backend provides the node path above, most writes bypass innerHTML entirely
and need no policy at all.)
If your CSP restricts policy names (trusted-types directive), either
allowlist streaming-markdown or inject your own policy:
import { renderMarkdown } from '@copse/streaming-markdown'
renderMarkdown(md, {
trustedTypesPolicy: window.trustedTypes.createPolicy(
'my-app#markdown',
{ createHTML: (s) => s },
),
})The injected policy always receives markup that has already been through
sanitizeRenderedMarkdown, so an identity createHTML is sound — the hook
exists for CSP policy-name control, not to replace the sanitizer.
That "already sanitized" contract is compiler-enforced: sanitizeRenderedMarkdown
returns a branded SanitizedHtml type (a string at runtime, exported from the
package entry), and the internal presanitized sink only accepts that brand — an
arbitrary string cannot reach it without passing through the sanitizer or an
audited internal assertion.
Edges to know about:
- DOMPurify backend under enforcement. DOMPurify's internal parser is
itself a Trusted Types sink, and DOMPurify guards it with its own policy
named
dompurify. If your CSP restricts policy names, allowlist both:trusted-types streaming-markdown dompurify— otherwise DOMPurify cannot parse and every render comes out empty. - Your own sinks.
renderMarkdown/renderStreamingMarkdownreturn plain strings; assigning them toinnerHTMLyourself still needs your own policy. Prefer the exportedsetSanitizedHtml(el, html)— the package's reference sink — which sanitizes and blesses in one call (also the right tool inside a customFenceHandler.sync). - Mermaid SVG and KaTeX HTML bypass the markdown sanitizer by design, so
the package never blesses them. Under enforcement, pass
transformSvgtohydratePendingDiagrams(ortransformHtmltohydratePendingMath) and return aTrustedHTMLminted by your own policy (e.g.DOMPurify.sanitize(svg, { RETURN_TRUSTED_TYPE: true })).
Highlighting is a pluggable backend, like the sanitizer. The core carries no
highlight.js code and renders fenced code as escaped
plain text (with the correct hljs lang-* class) until you register one — so
highlight.js is only in your bundle if you ask for it:
import { renderMarkdown } from '@copse/streaming-markdown'
import { highlightjsHighlighter } from '@copse/streaming-markdown/highlighters/highlightjs'
renderMarkdown(md, { codeHighlighter: highlightjsHighlighter })Or lazily — the grammars load as a separate chunk only when first needed;
loadHighlightjs() returns the highlighter, and a re-render with it in the
config upgrades already-rendered fences from plain to highlighted:
const { loadHighlightjs } = await import('@copse/streaming-markdown/highlighters/highlightjs')
const codeHighlighter = await loadHighlightjs()
// re-render with { codeHighlighter } in the configSee LAZY-LOADING.md for the bundle-size rationale and how the
same shape applies to Mermaid.
A second bundled backend uses Shiki (an optional peer
dependency you install, like mermaid — never bundled by this package). Shiki can
only initialize asynchronously, so the backend is an async-load seam over the
synchronous CodeHighlighter contract: fences render as escaped plain text
(with the stable core-resolved lang-* class) until the load resolves, and a
re-render upgrades them in place — the same UX as lazy highlight.js:
const { loadShiki, shikiThemeCss } = await import('@copse/streaming-markdown/highlighters/shiki')
const codeHighlighter = await loadShiki() // loads shiki/core + grammars + theme
document.head.insertAdjacentHTML('beforeend', `<style>${shikiThemeCss()}</style>`)
rerender({ codeHighlighter }) // already-rendered fences upgrade from plain → highlightedloadShiki() resolves to the CodeHighlighter backend; pass it as
codeHighlighter in the render config. It rejects if the optional shiki peer
isn't installed, so await it to observe completion or a missing-peer failure.
Styling. Shiki's stock output colors tokens with inline style attributes,
which the sink sanitizer strips (style stays off the allowlist — it would hand
markdown authors arbitrary CSS). The backend instead renders tokens itself with
class-based colors — <span class="shiki-f97583"> plus
shiki-italic/shiki-bold/… — which pass the existing class allowlist, and
shikiThemeCss() returns the theme's tiny stylesheet (one rule per palette
color) to inject once, any way your app ships CSS. Tokens in the theme's default
foreground are emitted bare, so your code-block text color still applies.
Theme and grammars. The default is the github-dark theme and grammars
covering the core KNOWN_LANGUAGES set (shellscript provides both the bash
and shell ids). The first loadShiki/installShiki call can override both:
await loadShiki({ theme: 'vitesse-light', langs: ['typescript', 'python'] })theme is a bundled shiki theme name or a pre-resolved theme registration
object; langs are shiki grammar names. Two behavioural mismatches with the
hljs backend: shiki has no auto-detection, so fences with an empty info string
stay plain text, and grammars you drop from langs fall back to plain text even
though the core still resolves their ids.
Mermaid and math support are built on a general fence-handler map: which HTML
a fenced code block emits is looked up by the fence's language (case-insensitive),
and mermaid / math are simply the built-in entries. Add your own via the
fenceHandlers config field — a Record<string, FenceHandler | null> — to add
mermaid-style blocks (graphviz, vega, and friends):
import { renderMarkdown, escapeHtml, FORMING_FENCE_PRE_CLASS } from '@copse/streaming-markdown'
renderMarkdown(md, {
fenceHandlers: {
graphviz: {
// At-rest HTML for a completed ```graphviz fence. Emitted before the
// sanitizer sink: stay inside the allowlist (or widen it via
// `sanitizeExtension`).
render: (code) =>
`<div class="dot-graph dot-graph--pending"><pre class="dot">${escapeHtml(code.trimEnd())}</pre></div>`,
// Optional: what the fence shows while still streaming (both emitters).
forming: {
html: (code) =>
`<div class="dot-graph dot-graph--pending ${FORMING_FENCE_PRE_CLASS}"><pre class="dot">${escapeHtml(code)}</pre></div>`,
// Optional incremental DOM update; default = sanitized innerHTML replace.
sync: (container, code) => {
/* patch container in place */
},
},
},
},
})The pattern is two-phase, like mermaid: the handler emits inert, escaped
scaffolding at render time, and your app hydrates it into rich output (SVG,
KaTeX, …) after the HTML is sanitized at the sink — mermaid's hydrator is
hydratePendingDiagrams, math's is hydratePendingMath. Fences are opaque to
the parser, so handlers change emission only. { fenceHandlers: { mermaid: null } }
(or { math: null }) removes a built-in for that render and renders those fences
as ordinary code blocks.
Math is first-class syntax. Four surface forms emit the same inert two-phase scaffolding:
```mathfences and$$ … $$/\[ … \]display blocks (delimiters on their own lines, or a one-line$$E=mc^2$$/\[ E=mc^2 \]) →<div class="math-block math-block--pending"><pre class="math">…escaped TeX…</pre></div>$x$/$$x$$/\(x\)inline math →<span class="math-inline math-inline--pending">…escaped TeX…</span>
The prose grammar is opt-in via mathSyntax (#78). $…$-style delimiters in
ordinary prose have realistic non-math readings (set $PATH$ properly, prices),
so by default they stay literal text and output is byte-identical to a math-free
build — no pending scaffolding that nothing will hydrate. Turn the prose grammar
on explicitly with the mathSyntax config field:
{ mathSyntax: true }— force the grammar on (the whole opt-in; a scaffolding- only host can render$…$even before a renderer exists and hydrate later).{ mathSyntax: false }— force it off (KaTeX for fences only).{ mathSyntax: null }— the default, equivalent tofalse: the prose grammar stays off. Providing a math renderer no longer flips it on implicitly, so a host that wants prose math sets{ mathSyntax: true }explicitly.
The setting is read by the shared tokenizer, so a mid-stream flip only affects
regions (re)rendered afterwards — recreate the streaming renderer (its config is
captured at construction) for a clean switch. The explicitly labeled
```math fence is never gated — like a ```mermaid fence,
it is unambiguous author intent.
With the grammar on: single-dollar math carries remark-math's currency
guards — no whitespace just inside the delimiters and no digit right after the
closing $ — so $20 and $30 stays prose; escaped \$ and $ inside code
spans/fences/link destinations never delimit. While streaming, a half-open
$$ block shows a forming pending-math state and a half-open $x+ holds, so
raw delimiters never flash. (Recognizing \(…\)/\[…\] is a deliberate,
documented divergence from CommonMark's escaped-punctuation reading — OpenAI
models emit bracket delimiters — gated to non-empty bodies so the spec suites
still pass.)
The core ships zero KaTeX code: without a backend, pending math shows its
escaped TeX source. The KaTeX backend (an optional peer dependency you install)
is asynchronous, so it does not run during the synchronous render — it flows in
via hydrate() after the pending scaffolding is on the page. loadKatex()
returns the MathRenderer; turn the prose grammar on with mathSyntax: true
and pass the renderer as mathRenderer:
import { StreamingMarkdownRenderer } from '@copse/streaming-markdown'
const { loadKatex } = await import('@copse/streaming-markdown/math/katex')
const mathRenderer = await loadKatex() // returns the backend; library loads lazily
const renderer = new StreamingMarkdownRenderer(el, { mathSyntax: true, mathRenderer })
renderer.update(text)
await renderer.hydrate() // pending → rendered KaTeX HTML; returns { math, diagrams } countsFor at-rest / non-StreamingMarkdownRenderer flows, render with
{ mathSyntax: true } and hydrate the settled DOM with the standalone free
function, passing the renderer via options:
import { hydratePendingMath } from '@copse/streaming-markdown'
const { loadKatex } = await import('@copse/streaming-markdown/math/katex')
const renderer = await loadKatex()
// …render with { mathSyntax: true }, then after the scaffolding is in the DOM:
await hydratePendingMath(messageEl, { renderer }) // pending → rendered KaTeX HTMLDon't forget KaTeX's stylesheet and fonts (katex/dist/katex.min.css) —
the rendered HTML is unreadable without them. The backend renders with
throwOnError: false (invalid TeX degrades to highlighted source) and
trust: false (no \href/\html* commands).
Trust boundary: like mermaid SVG, KaTeX HTML is injected after the sink
sanitizer by design and is not re-sanitized. A safety-conscious host can pass
hydratePendingMath(root, { transformHtml }) to run it through its own
sanitizer — and under Trusted Types enforcement that hook is required, exactly
like mermaid's transformSvg.
Fence handlers extend block syntax; inline passes extend inline syntax —
Pandoc-style citations [@key], ==highlights==, emoji shortcodes — without
forking the fixed inline pipeline. Supply ordered passes with the inlinePasses
config field:
import { renderMarkdown, escapeHtml } from '@copse/streaming-markdown'
renderMarkdown(md, {
inlinePasses: [
{
name: 'citations',
stage: 'before-links', // consume [@key] before it parses as a link label (Pandoc order)
apply: (text, ctx) =>
text.replace(/\[@([\w.-]+)\]/g, (_m, key) =>
// ctx.emit shields trusted HTML from later passes and the escape step.
ctx.emit(`<cite class="citation">@${escapeHtml(key)}</cite>`)),
// Optional streaming hold: don't flash a half-open `[@doe` mid-stream.
holdStart: (line) => {
const i = line.lastIndexOf('[@')
return i === -1 ? line.length : i
},
},
],
})- Stage.
before-links(default) runs after emphasis/strikethrough and before markdown-link resolution — required for bracket syntaxes that must beat link labels.after-linksruns last, over text with<a>/<code>already rendered. - Shielded and escaped for you. A pass only sees text outside rendered
<code>/<a>/<img>spans, and backslash escapes are already inert — so`[@key]`and\[@key]never fire. Emit HTML throughctx.emit(html): it's parked behind an inert placeholder and restored after the escape step, so your markup survives instead of being escaped away. - Streaming hold. An optional
holdStartkeeps a half-open construct ([@doe,==foo) from flashing raw mid-stream — the same mechanism as the built-in strikethrough hold. - The sanitizer is still the gate. Emitted tags/attributes outside the core
allowlist need
sanitizeExtension(below).
With no passes registered the pipeline is unchanged and output is byte-identical.
Or use the shipped emoji pass. Emoji shortcodes (:smile: → 😄) are the same
recipe promoted to a built-in, optional pass — so hosts don't hand-roll the
shortcode map. It lives behind its own subpath (zero bytes in the main bundle
unless imported) and ships a GitHub/gemoji-aligned table so :shortcode:s an LLM
emits resolve to the glyph GitHub would render:
import { renderMarkdown } from '@copse/streaming-markdown'
import { emojiInlinePass } from '@copse/streaming-markdown/inline/emoji'
renderMarkdown(md, { inlinePasses: [emojiInlinePass] })It obeys the full contract for free: `:smile:` and \:smile: stay literal,
unknown codes pass through, and a half-typed :smi holds mid-stream. Extend or
replace the table with createEmojiInlinePass(customMap), or read the shipped
emojiShortcodes map from the same entry.
East-Asian (Chinese / Japanese / Korean) output splits cleanly into two layers, and the honest scope split matters: most of it is the host's CSS, and only a small, real slice belongs in the renderer.
Renderer layer — opt-in JS behind @copse/streaming-markdown/cjk. CommonMark's
emphasis flanking rules count full-width / ideographic punctuation (「」,
。, !, (), …) as ordinary Unicode punctuation, so a ** between a CJK
character and one of those marks fails to flank and the emphasis never pairs —
これは**「強調」**です stays literal. That is a documented CommonMark
limitation, not a bug in this renderer (the reference implementation produces the
same literal output), so it is an extension, off by default. A post-process
inline pass cannot fix it — by the time passes run, the ** have already been
left as text — so it is a default-off hook in the flanking classifier instead.
Turn it on by spreading the exported cjkFriendlyConfig fragment into the render
config; it also stops a run-together bare autolink at the first full-width mark
(https://example.com。次 → link + prose):
import { renderMarkdown } from '@copse/streaming-markdown'
import { cjkFriendlyConfig } from '@copse/streaming-markdown/cjk'
renderMarkdown(md, { ...cjkFriendlyConfig }) // cjk-friendly emphasis + autolink boundariescjkFriendlyConfig is a small object of two config fields
(flankingPunctuationExclusion, bareUrlCjkBoundary) both pointing at the
built-in CJK-punctuation predicate — spread it alongside your other config, or
set those fields yourself for a custom range. Like the other optional backends,
the range table lives behind its own entry — nothing is pulled into your bundle
unless you import …/cjk. Omit it (the default) and Latin output and the
CommonMark/GFM conformance suites are byte-identical.
Host layer — CSS. Line breaking (ideographs wrap between any two characters, Kinsoku start/end constraints), inter-script spacing (the gap between CJK and Latin/numbers), and full-width-punctuation kerning are presentation the host owns — the renderer emits the same structural HTML for every script and does not guess a language. Ship the ready-made optional sheet and tell the browser the language:
import '@copse/streaming-markdown/styles/cjk.css'
el.lang = 'ja' // or 'zh' / 'ko'; or add class 'sm-cjk' to the containerstyles/cjk.css is not imported by core.css / default.css and is pure CSS
(word-break, line-break: strict, and progressive text-autospace /
text-spacing-trim), scoped under .streaming-markdown and gated on :lang()
or a .sm-cjk class hook — see the header comment in the file. It needs no JS,
and the JS entry needs no CSS; use either, both, or neither.
A LinkDecorator returns the attribute string appended after href on every
rendered <a> — the seam for host-specific routing (open-in-new-tab, in-app
navigation, rel policy) without hard-coding it into the parser.
The built-in default is neutral (#112): rendered anchors carry only
href/title and no target, rel, class, or data-* attributes, so the
"just render this" path stays host-agnostic. Pass your own decorator as the
linkDecorator config field to add routing:
import { renderMarkdown } from '@copse/streaming-markdown'
renderMarkdown(md, {
linkDecorator: ({ href, isWorkspace, title }) =>
isWorkspace ? ` data-nav="${href}"` : ` target="_blank" rel="noopener noreferrer"`,
})The Copse workspace/browser decorator ships behind a host-only subpath. Hosts that
want the pre-0.10 in-app behaviour (data-workspace-link / data-browser-link,
target="_blank") restore it with a single call:
import { renderMarkdown } from '@copse/streaming-markdown'
import { appLinkDecorator } from '@copse/streaming-markdown/host/workspace'
renderMarkdown(md, { linkDecorator: appLinkDecorator })Attribute names a decorator emits must be in the escape and sink allowlists, or they are stripped — widen both to match a custom decorator's vocabulary.
The core is image-agnostic: every raw <img> is escaped by default. A host that
wants to allow specific images (e.g. resolving an app artifact URL to an inert
placeholder) supplies a RawImageRenderer via the rawImageRenderer config
field; return the replacement HTML, or null to leave the tag escaped.
normalizeHostImagePath
is a companion that reduces a volatile src (absolute container paths, per-session
download URLs) to a stable relative path so rendered output stays deterministic
across machines.
Link destinations are validated against a scheme allowlist
(DEFAULT_SAFE_HREF_SCHEMES: http, https, mailto, tel, sms, ftp,
ftps, plus scheme-less relative/fragment/path forms); any other scheme —
including javascript: and data: — is dropped. Override it with the
safeHrefSchemes config field ({ safeHrefSchemes: [...] }, case-insensitive;
null restores the default). Narrowing the list is always safe; only widen it
with schemes that are inert as an href, never
javascript/data/vbscript/file.
The scheme allowlist above decides which URL schemes may render. A separate, opt-in origin policy decides which origins an already-scheme-safe link or image may point at — the turnkey equivalent of hand-rolling an allowlist at your sink. It is off by default: with no policy installed, output is byte-identical to today.
import { renderMarkdown } from '@copse/streaming-markdown'
renderMarkdown(md, {
linkImagePolicy: {
allowedLinkPrefixes: ['https://docs.example.com/', 'https://github.com/acme/'],
allowedImagePrefixes: ['https://cdn.example.com/'],
defaultOrigin: 'https://app.example.com',
allowDataImages: false, // default true — set false to strip base64 data: images
// blockedLinkClass / blockedImageClass — optional, default `blocked-link` / `blocked-image`
},
})Semantics (enforced at the sanitizer sink, so it covers every rendered <a> —
including autolinks — and every <img> a host renders, on both sanitizer
backends and under Trusted Types):
- A link whose destination is not under an
allowedLinkPrefixesentry is rewritten todefaultOrigin(or has itshrefdropped whendefaultOriginis empty) and tagged withblockedLinkClass. Allowed absolute links pass untouched. - An image whose
srcis not under anallowedImagePrefixesentry is neutralized (itssrcis stripped so nothing loads; the element and itsaltstay) and tagged withblockedImageClass. - Relative URLs resolve against
defaultOrigin; an allowed one is rewritten to its resolved absolute form. A relative URL with nodefaultOriginis blocked. data:images are governed solely byallowDataImages(defaulttrue), independent of the prefix list;falsestrips them.- Pass
{ linkImagePolicy: null }to render without the policy.
Interaction with the scheme allowlist. These are complementary, not
redundant: safeHrefSchemes is the scheme gate (it drops javascript:/data:
links before an <a> is built), and linkImagePolicy is the origin gate
over the schemes that survive. The origin policy does not re-check schemes —
keep scheme filtering in safeHrefSchemes.
Bypass hardening. Prefixes and each candidate URL are compared on their
WHATWG-canonical serialization (new URL(...) with credentials stripped), which
is the exact string the browser navigates to. That neutralizes the usual
allowlist tricks in one place: case-folded scheme/host (HTTPS://Evil),
\ vs / (https:\\evil), embedded credentials (https://good.com@evil.com
resolves to evil.com, not a good.com prefix match), scheme-relative
//evil.com, leading/trailing whitespace, and unicode host confusables (folded
to punycode). No new runtime dependency is pulled in — the platform URL parser
does the canonicalization.
The classes it adds (class values) are already inside the sink allowlist, so no
sanitizeExtension widening is needed for the policy itself. For images to
reach the policy at all, a host must first allow <img> through the sink (image
handling is host-injected — see Raw images).
CommonMark decodes the full HTML5 named + numeric character-reference set, but
the full named table is ~2,100 entries (~23 KB gzip — roughly half the core's
transfer size). Models overwhelmingly emit only the Latin-1 / typographic / math
tail of it, so the default decoder is dependency-free: it carries the 252
classic HTML4 named references plus all numeric references (which need no table
— they are algorithmic). Across the entire CommonMark spec that subset costs
exactly one example (#25, which packs HTML5-only names like Ď and
ℋ).
Need the full HTML5 set? Pass a decoder as the entityDecoder config field —
decoding routes through it automatically:
import { renderMarkdown, browserEntityDecoder } from '@copse/streaming-markdown'
import { fullEntityDecoder } from '@copse/streaming-markdown/entities/full'
// Option A — the browser's own parser table, via a detached <textarea>.
// Full HTML5 coverage at ZERO bundle cost. DOM only.
renderMarkdown(md, { entityDecoder: browserEntityDecoder })
// Option B — the `entities` package (install it as a peer dep). Works anywhere,
// adds the ~23 KB table to your bundle. Best for Node/SSR without a DOM.
renderMarkdown(md, { entityDecoder: fullEntityDecoder })Both are strict (a trailing ; is required, per CommonMark) and decode any name
in the built-in set byte-identically to the full table. (Pass fullEntityDecoder
from the entities/full entry as { entityDecoder: fullEntityDecoder }, or a
setDefaultConfig({ entityDecoder: fullEntityDecoder }) once at startup.)
Just need a handful of extra names? Add them for a render with the
namedEntities config field instead of shipping the whole table:
import { renderMarkdown } from '@copse/streaming-markdown'
renderMarkdown(md, { namedEntities: { checkmark: '✓', myco: '🌱' } }) // bare names, no &/;namedEntities supplies the user layer for that render (its entries win over the
252 built-ins on collision). It affects the built-in decoder only — an
entityDecoder you pass owns its own set.
The sink allowlist in sanitize.ts mirrors exactly what the renderer emits and
is the security gate. If a host plug-in emits tags/attributes outside it (a custom
fence handler's scaffolding, a decorator's attribute, an artifact <img>), widen
the sink with the sanitizeExtension config field — and keep the additions as
narrow as the injected output. onElement runs for every kept element so a host can lock down
its own tags. Note the core gate strips any id outside the renderer's own
footnote shape (fn-…/fnref-…, plus the section's …footnote-label
heading); a host that injects other ids must re-set them from its onElement
hook (which runs after the strip).
On a page that stacks many rendered messages (smd's primary chat use case), two
messages that both use [^1] would otherwise both emit id="fn-1" and
cross-link. Pass a distinct, attribute-safe footnoteIdPrefix per message
(MarkdownConfig, e.g. `${messageId}-`, chars in [A-Za-z0-9_-]) to
namespace every footnote id and anchor; it must be deterministic and stable
across incremental updates of a stream (do not derive it from Math.random() /
Date.now()). The default '' is byte-identical to prior output.
The renderer emits a documented set of class hooks (stream-pending-*,
contains-task-list, mermaid-diagram, math-block / math-inline (with
--pending / --rendered / --error states), hljs-*, … — see the class contract in
ARCHITECTURE.md) but ships no styles by default, so it stays
host-independent. Two optional stylesheets are provided; both scope every rule
under a .streaming-markdown class, so add that class to the element you render
into:
import '@copse/streaming-markdown/styles/default.css'
el.classList.add('streaming-markdown')styles/core.css— structural only: the rules the emitter's output needs to render correctly regardless of theme (pending-state whitespace, task-list marker suppression, code-block whitespace, layout-blowout guards). No colours, spacing, or typography. Pair it with your own theme.styles/default.css— importscore.cssand adds a batteries-included look (spacing, typography, tables, links, and a highlight.js VS Code Dark+ palette).styles/cjk.css— optional East-Asian line-break / spacing rules, gated on:lang()or a.sm-cjkclass hook. Not imported by the other two; see the CJK / East-Asian text section above.
Retheme default.css by setting --sm-* custom properties on .streaming-markdown
(or any ancestor) — each has a fallback, so the sheet also stands alone. See the
header comment in styles/default.css for the full list
(--sm-space-sm, --sm-border, --sm-accent, --sm-code-bg, …).
The stylesheets are authored with native CSS nesting; bundle with a target that supports it (any current engine) or let your bundler lower it.
Widgets on top of the render — copy buttons on code blocks, download links,
carets — aren't part of the library; you add them in your own app (the same reason
the stylesheets are optional). RECIPES.md walks through building them
against the class hooks above, starting with copy buttons and the streaming
gotcha they hit: the incremental emitter morphs the DOM on every update(), so a
naïvely appended button gets reconciled away. It shows the correct
delegation + idempotent re-attach pattern and how to copy clean source rather than
tokenized markup.