UNPKG

@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
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; } }; };