@humanspeak/svelte-markdown
Version:
Markdown and HTML renderer for Svelte 5 — built for rendering streaming AI agent output from Claude Code, ChatGPT, and agentic workflows. XSS-safe defaults, streaming-aware sanitization, token caching, TypeScript types, and Svelte 5 runes.
422 lines (421 loc) • 18.7 kB
JavaScript
import Slugger from 'github-slugger';
export const RENDER_METADATA_CONTEXT = Symbol('svelte-markdown.renderMetadata');
const asNodeArray = (value) => Array.isArray(value) ? value : undefined;
const getNodeSpanText = (node) => {
if (typeof node.raw === 'string')
return node.raw;
if (typeof node.text === 'string')
return node.text;
return '';
};
const getNodeSourceLength = (node) => {
if (typeof node.sourceLength === 'number')
return node.sourceLength;
return getNodeSpanText(node).length;
};
/**
* Shallow-clones a github-slugger `occurrences` map so a dedup snapshot can be
* taken or restored without aliasing the live slugger's mutable state.
*
* @param occurrences - The slugger's `occurrences` record (`slug → count`).
* @returns A new object with the same `slug → count` entries.
* @example
* ```ts
* const snapshot = cloneSluggerOccurrences(slugger.occurrences)
* slugger.slug('intro') // does not mutate `snapshot`
* ```
*/
const cloneSluggerOccurrences = (occurrences) => ({
...occurrences
});
/**
* Captures the heading-id options that affect slug output, so a stored dedup
* snapshot can be invalidated when they change between passes.
*
* @param options - The active {@link SvelteMarkdownOptions}.
* @returns The `headerIds`/`headerPrefix` pair that identifies the slugger's
* configuration for the current pass.
* @example
* ```ts
* const signature = getHeadingSluggerSignature(options)
* ```
*/
const getHeadingSluggerSignature = (options) => ({
headerIds: options.headerIds,
headerPrefix: options.headerPrefix
});
/**
* Compares two heading-slugger signatures for equality. A previous signature of
* `undefined` (no prior pass) never matches, forcing the safe replay path.
*
* @param a - The previously stored signature, or `undefined` on the first pass.
* @param b - The current pass's signature.
* @returns `true` when both `headerIds` and `headerPrefix` are identical.
* @example
* ```ts
* if (headingSluggerSignaturesMatch(preparedHeadingSignature, current)) {
* // safe to restore the occurrences snapshot
* }
* ```
*/
const headingSluggerSignaturesMatch = (a, b) => a !== undefined && a.headerIds === b.headerIds && a.headerPrefix === b.headerPrefix;
/**
* Creates a per-`SvelteMarkdown`-instance render metadata helper. State
* (render keys, precomputed heading ids, source offsets, and cross-pass
* bookkeeping) is captured in the returned closure via WeakMaps keyed by token
* objects, so each component instance gets isolated metadata and caller token
* arrays are never mutated. See the module overview above for the keying and
* heading-id strategy.
*
* @returns A {@link RenderMetadata} whose methods precompute per-pass metadata
* (`prepareTokensForRender`) and read it back during render
* (`getPreparedHeadingId`, `getStableNodeKey`, `getStableRowKey`).
* @example
* ```ts
* const metadata = createRenderMetadata()
* // Streaming append: skip the stable prefix via the parser's diverge point.
* const tokens = metadata.prepareTokensForRender(rawTokens, options, {
* source,
* startIndex,
* startOffset
* })
* const key = metadata.getStableNodeKey(tokens?.[0], 0)
* const id = metadata.getPreparedHeadingId(tokens?.[0])
* ```
*/
export const createRenderMetadata = () => {
const renderKeys = new WeakMap();
const headingIds = new WeakMap();
const sourceOffsets = new WeakMap();
let preparedHeadingNodes = [];
let preparedHeadingSnapshots = [];
let preparedHeadingSignature;
let previousSourceLessRoots = [];
const setRenderKey = (node, value) => {
renderKeys.set(node, value);
};
const getRenderKey = (node) => typeof node === 'object' && node !== null ? renderKeys.get(node) : undefined;
const getStableNodeKey = (node, index) => {
const renderKey = getRenderKey(node);
if (renderKey !== undefined)
return renderKey;
if (typeof node === 'object' && node !== null)
return node;
return `${index}:${String(node)}`;
};
// Source offsets are recorded as numbers when keys are assigned, so the
// streaming heading-seed loop can read them without re-parsing the `src:`
// key string on every prior heading each flush.
const getSourceOffset = (node) => sourceOffsets.get(node);
const assignSequentialSourceKeys = (nodes, absoluteOffset = 0, startIndex = 0, startOffset = 0) => {
if (!nodes)
return;
let cursor = startOffset;
for (let index = startIndex; index < nodes.length; index++) {
const node = nodes[index];
const spanLength = getNodeSourceLength(node);
const nodeOffset = absoluteOffset + cursor;
sourceOffsets.set(node, nodeOffset);
if (spanLength === 0) {
setRenderKey(node, `src:${nodeOffset}:zero:${index}`);
}
else {
setRenderKey(node, `src:${nodeOffset}`);
}
assignSourceKeysToChildren(node, nodeOffset);
cursor += spanLength;
}
};
const collectSourceLessIdentities = (value, identities = new Set()) => {
if (typeof value !== 'object' || value === null)
return identities;
identities.add(value);
if (Array.isArray(value)) {
for (const item of value) {
collectSourceLessIdentities(item, identities);
}
return identities;
}
const node = value;
collectSourceLessIdentities(node.tokens, identities);
collectSourceLessIdentities(node.items, identities);
collectSourceLessIdentities(node.header, identities);
collectSourceLessIdentities(node.rows, identities);
return identities;
};
const countIdentityOverlap = (a, b) => {
let count = 0;
const [smaller, larger] = a.size <= b.size ? [a, b] : [b, a];
for (const identity of smaller) {
if (larger.has(identity))
count++;
}
return count;
};
const findPreviousSourceLessRoot = (node, identities, usedPreviousRoots) => {
let bestIndex = -1;
let bestScore = 0;
for (let index = 0; index < previousSourceLessRoots.length; index++) {
if (usedPreviousRoots.has(index))
continue;
const previous = previousSourceLessRoots[index];
if (previous.type !== node.type)
continue;
const score = countIdentityOverlap(identities, previous.identities);
if (score > bestScore) {
bestIndex = index;
bestScore = score;
}
}
return bestIndex === -1
? undefined
: { index: bestIndex, record: previousSourceLessRoots[bestIndex] };
};
const assignSourceLessRootKeys = (nodes) => {
if (!nodes) {
previousSourceLessRoots = [];
return;
}
const nextRoots = [];
const usedPreviousRoots = new Set();
for (const node of nodes) {
const identities = collectSourceLessIdentities(node);
const existingKey = getRenderKey(node);
const previous = existingKey === undefined
? findPreviousSourceLessRoot(node, identities, usedPreviousRoots)
: undefined;
const key = existingKey ?? previous?.record.key ?? node;
if (previous)
usedPreviousRoots.add(previous.index);
if (existingKey === undefined)
setRenderKey(node, key);
nextRoots.push({
key,
identities,
type: node.type
});
}
previousSourceLessRoots = nextRoots;
};
const assignSourceKeysToChildren = (node, absoluteOffset) => {
assignSequentialSourceKeys(asNodeArray(node.tokens), absoluteOffset);
assignSequentialSourceKeys(asNodeArray(node.items), absoluteOffset);
assignSequentialSourceKeys(asNodeArray(node.header), absoluteOffset);
const rows = asNodeArray(node.rows);
if (rows) {
for (let rowIndex = 0; rowIndex < rows.length; rowIndex++) {
const row = rows[rowIndex];
// Body cell keys intentionally mirror header cells below.
// Rows need their own key space because getStableRowKey()
// otherwise derives duplicate row keys from first-cell keys.
setRenderKey(row, `src:${absoluteOffset}:row:${rowIndex}`);
assignSequentialSourceKeys(asNodeArray(row), absoluteOffset);
}
}
};
const assignHeadingIds = (nodes, options, slugger, nextHeadingNodes, nextHeadingSnapshots, startIndex = 0) => {
if (!nodes)
return;
for (let index = startIndex; index < nodes.length; index++) {
const node = nodes[index];
if (node.type === 'heading') {
seedHeadingSlugger(node, options, slugger);
rememberPreparedHeading(node, slugger, nextHeadingNodes, nextHeadingSnapshots);
}
assignHeadingIds(asNodeArray(node.tokens), options, slugger, nextHeadingNodes, nextHeadingSnapshots);
assignHeadingIds(asNodeArray(node.items), options, slugger, nextHeadingNodes, nextHeadingSnapshots);
assignHeadingIds(asNodeArray(node.header), options, slugger, nextHeadingNodes, nextHeadingSnapshots);
const rows = asNodeArray(node.rows);
if (rows) {
for (const row of rows) {
assignHeadingIds(asNodeArray(row), options, slugger, nextHeadingNodes, nextHeadingSnapshots);
}
}
}
};
const seedHeadingSlugger = (node, options, slugger) => {
headingIds.set(node, options.headerIds && typeof node.text === 'string'
? `${options.headerPrefix}${slugger.slug(node.text)}`
: undefined);
};
/**
* Records a just-slugged heading and captures the slugger's `occurrences`
* state immediately after it, so a later append-only pass can restore dedup
* state at this exact boundary instead of replaying every prior heading. A
* heading with no source offset stores an `undefined` snapshot, which forces
* the safe replay path on the next pass.
*
* @param node - The heading node whose id was just assigned.
* @param slugger - The slugger whose post-slug `occurrences` is snapshotted.
* @param nextHeadingNodes - Ordered heading list being built for this pass;
* `node` is appended.
* @param nextHeadingSnapshots - Parallel snapshot list; the snapshot (or
* `undefined`) for `node` is appended in lockstep with `nextHeadingNodes`.
* @returns Nothing; both arrays are mutated in place.
* @example
* ```ts
* seedHeadingSlugger(node, options, slugger)
* rememberPreparedHeading(node, slugger, nextHeadingNodes, nextHeadingSnapshots)
* ```
*/
const rememberPreparedHeading = (node, slugger, nextHeadingNodes, nextHeadingSnapshots) => {
nextHeadingNodes.push(node);
const headingOffset = getSourceOffset(node);
nextHeadingSnapshots.push(headingOffset === undefined
? undefined
: {
offset: headingOffset,
occurrences: cloneSluggerOccurrences(slugger.occurrences)
});
};
/**
* Counts the leading prepared headings that fall strictly before
* `startOffset` and whose stored snapshot offsets still line up — i.e. the
* stable prefix a restore may reuse. Returns `undefined` if any prepared
* heading lacks a source offset or its snapshot has drifted, signalling the
* caller to fall back to full replay.
*
* @param startOffset - The parser's divergence offset for this append pass.
* @returns The number of reusable prefix headings, or `undefined` when the
* prefix cannot be trusted and replay is required.
* @example
* ```ts
* const prefixCount = getPreparedHeadingPrefixCount(startOffset)
* if (prefixCount === undefined) return false // caller replays
* ```
*/
const getPreparedHeadingPrefixCount = (startOffset) => {
let prefixCount = 0;
for (const heading of preparedHeadingNodes) {
const headingOffset = getSourceOffset(heading);
if (headingOffset === undefined)
return undefined;
if (headingOffset >= startOffset)
break;
const snapshot = preparedHeadingSnapshots[prefixCount];
if (!snapshot || snapshot.offset !== headingOffset)
return undefined;
prefixCount++;
}
return prefixCount;
};
/**
* Fast path for append-only passes: restores the slugger's `occurrences`
* from the snapshot taken at the stable-prefix boundary and carries the
* prefix headings forward without re-slugging them, making heading-id dedup
* O(tail) instead of O(H). Bails (returning `false`) when the option
* signature changed or the prefix cannot be trusted, so the caller replays.
*
* @param slugger - The fresh slugger to seed via snapshot restore.
* @param options - The active options, checked against the stored signature.
* @param startOffset - The parser's divergence offset for this pass.
* @param nextHeadingNodes - Heading list being built; the reused prefix is
* appended.
* @param nextHeadingSnapshots - Parallel snapshot list; the prefix snapshots
* are appended in lockstep.
* @returns `true` if the snapshot was restored (prefix reused); `false` if
* the caller must fall back to {@link replayPreparedHeadingPrefix}.
* @example
* ```ts
* const restored = restorePreparedHeadingSluggerSnapshot(
* slugger, options, startOffset, nextHeadingNodes, nextHeadingSnapshots
* )
* if (!restored) replayPreparedHeadingPrefix(...)
* ```
*/
const restorePreparedHeadingSluggerSnapshot = (slugger, options, startOffset, nextHeadingNodes, nextHeadingSnapshots) => {
const currentSignature = getHeadingSluggerSignature(options);
if (!headingSluggerSignaturesMatch(preparedHeadingSignature, currentSignature)) {
return false;
}
const prefixCount = getPreparedHeadingPrefixCount(startOffset);
if (prefixCount === undefined)
return false;
nextHeadingNodes.push(...preparedHeadingNodes.slice(0, prefixCount));
nextHeadingSnapshots.push(...preparedHeadingSnapshots.slice(0, prefixCount));
if (prefixCount === 0)
return true;
const snapshot = preparedHeadingSnapshots[prefixCount - 1];
if (!snapshot)
return false;
slugger.occurrences = cloneSluggerOccurrences(snapshot.occurrences);
return true;
};
/**
* Safe fallback path: re-slugs every prepared heading strictly before
* `startOffset` to rebuild dedup state (the original O(H) behavior),
* repopulating the snapshot array as it goes so subsequent passes can use
* the fast restore path again. Used whenever
* {@link restorePreparedHeadingSluggerSnapshot} declines.
*
* @param slugger - The fresh slugger to re-seed by replay.
* @param options - The active options passed to `seedHeadingSlugger`.
* @param startOffset - The parser's divergence offset for this pass.
* @param nextHeadingNodes - Heading list being built; each replayed prefix
* heading is appended.
* @param nextHeadingSnapshots - Parallel snapshot list, repopulated in
* lockstep for future passes.
* @returns Nothing; both arrays and the slugger are mutated in place.
* @example
* ```ts
* if (!restored) {
* replayPreparedHeadingPrefix(
* slugger, options, startOffset, nextHeadingNodes, nextHeadingSnapshots
* )
* }
* ```
*/
const replayPreparedHeadingPrefix = (slugger, options, startOffset, nextHeadingNodes, nextHeadingSnapshots) => {
for (const heading of preparedHeadingNodes) {
const headingOffset = getSourceOffset(heading);
if (headingOffset === undefined || headingOffset >= startOffset) {
continue;
}
seedHeadingSlugger(heading, options, slugger);
rememberPreparedHeading(heading, slugger, nextHeadingNodes, nextHeadingSnapshots);
}
};
const assignPreparedHeadingIds = (nodes, options, preparation) => {
const slugger = new Slugger();
const nextHeadingNodes = [];
const nextHeadingSnapshots = [];
if (preparation?.source !== undefined && preparation.startOffset !== undefined) {
const restored = restorePreparedHeadingSluggerSnapshot(slugger, options, preparation.startOffset, nextHeadingNodes, nextHeadingSnapshots);
if (!restored) {
replayPreparedHeadingPrefix(slugger, options, preparation.startOffset, nextHeadingNodes, nextHeadingSnapshots);
}
}
assignHeadingIds(nodes, options, slugger, nextHeadingNodes, nextHeadingSnapshots, preparation?.startIndex ?? 0);
preparedHeadingNodes = nextHeadingNodes;
preparedHeadingSnapshots = nextHeadingSnapshots;
preparedHeadingSignature = getHeadingSluggerSignature(options);
};
return {
prepareTokensForRender: (tokens, options, preparation) => {
if (!tokens)
return tokens;
const renderNodes = tokens;
if (preparation?.source !== undefined) {
previousSourceLessRoots = [];
assignSequentialSourceKeys(renderNodes, 0, preparation.startIndex ?? 0, preparation.startOffset ?? 0);
}
else {
assignSourceLessRootKeys(renderNodes);
}
assignPreparedHeadingIds(renderNodes, options, preparation);
return tokens;
},
getPreparedHeadingId: (node) => typeof node === 'object' && node !== null ? headingIds.get(node) : undefined,
getStableNodeKey,
getStableRowKey: (row, index) => {
const renderKey = getRenderKey(row);
if (renderKey !== undefined)
return renderKey;
if (row && row.length > 0)
return getStableNodeKey(row[0], index);
if (row)
return row;
return index;
}
};
};