feat: markdown 增量解析

This commit is contained in:
07akioni
2026-08-06 13:56:43 +08:00
parent e88bace07e
commit 8d6824a84b
62 changed files with 3090 additions and 573 deletions
@@ -1,155 +1,164 @@
import { isValidElement, useMemo } from 'react'
import ReactMarkdown from 'react-markdown'
import type { Components, UrlTransform } from 'react-markdown'
import rehypeKatex from 'rehype-katex'
import remarkGfm from 'remark-gfm'
import remarkMath from 'remark-math'
import { CodeBlock } from './CodeBlock.tsx'
import { remarkMathCompatibility } from './remarkMathCompatibility.ts'
/**
* Untrusted assistant-Markdown renderer over the direct mdast pipeline:
* `parse.ts` grammars, the incremental streaming parser, and `render.tsx`.
* While a message streams, all but the trailing two blocks freeze as cached
* React elements and only the source tail behind them re-parses per chunk,
* so per-chunk work tracks the tail size instead of the whole reply. Frozen
* blocks keep their source-offset keys when they cross the freeze boundary,
* so React reconciles instead of remounting. Known deviation while
* streaming: a reference-style link or footnote whose definition sits on the
* other side of the freeze boundary renders literally until the settled
* full parse self-heals it.
*/
import { memo, useMemo, useRef } from 'react'
import type { ReactNode } from 'react'
import { IncrementalMarkdownParser } from './incremental.ts'
import { parseGfm, parseGfmWithMath } from './parse.ts'
import {
collectReferenceTargets, createReferenceTargets, renderBlocks, renderFootnoteSection,
wrapBlockChildren,
} from './render.tsx'
import type { MarkdownCodeLabels, MarkdownRenderContext, ReferenceTargets } from './render.tsx'
import 'katex/dist/katex.min.css'
import css from './MarkdownText.module.css'
const streamingRemarkPlugins = [remarkGfm]
const settledRemarkPlugins = [
remarkGfm,
remarkMathCompatibility,
remarkMath,
]
const settledRehypePlugins = [rehypeKatex]
export type { MarkdownCodeLabels } from './render.tsx'
function sanitizeUrl(url: string): string {
try {
switch (new URL(url).protocol) {
case 'http:':
case 'https:':
case 'mailto:':
return url
default:
return ''
/** One settled full render: parse with math, resolve references, append the footnote section. */
function renderSettled(text: string, codeLabels: MarkdownCodeLabels | undefined): ReactNode[] {
const root = parseGfmWithMath(text)
const targets = createReferenceTargets()
collectReferenceTargets(root.children, targets)
const context: MarkdownRenderContext = {
streaming: false,
codeLabels,
targets,
footnoteOrder: [],
footnoteCounts: new Map(),
}
const blocks = wrapBlockChildren(
renderBlocks(root.children.map((node, index) => ({ node, key: index })), context),
false,
)
const section = renderFootnoteSection(context)
return section === null ? blocks : [...blocks, '\n', section]
}
/**
* Streaming render state for one growing message: the incremental parser,
* the frozen blocks' cached elements, and the reference/footnote state their
* rendering consumed (footnote numbering assigned to frozen references is
* final, so the tail continues from a copy of it each frame).
*/
class StreamingRenderer {
private readonly parser = new IncrementalMarkdownParser(parseGfm)
private generation = -1
private frozenCount = 0
private frozenElements: ReactNode[] = []
private frozenTargets: ReferenceTargets = createReferenceTargets()
private frozenFootnoteOrder: string[] = []
private frozenFootnoteCounts = new Map<string, number>()
private lastText: string | null = null
private lastRendered: ReactNode[] = []
/** @param codeLabels - Fence copy labels baked into cached elements; the owner replaces the renderer when they change. */
constructor(private readonly codeLabels: MarkdownCodeLabels | undefined) {}
/**
* Render the current accumulated text. Idempotent per text value, so React
* may re-execute the calling render freely.
* @param text - The full accumulated markdown source.
* @returns Frozen elements, re-rendered tail, and the footnote section.
*/
render(text: string): ReactNode[] {
if (text === this.lastText) return this.lastRendered
const { frozen, tail, generation } = this.parser.update(text)
if (generation !== this.generation) {
this.generation = generation
this.frozenCount = 0
this.frozenElements = []
this.frozenTargets = createReferenceTargets()
this.frozenFootnoteOrder = []
this.frozenFootnoteCounts = new Map()
}
} catch {
return ''
const newlyFrozen = frozen.slice(this.frozenCount)
collectReferenceTargets(newlyFrozen.map(block => block.node), this.frozenTargets)
// Targets visible this frame: everything frozen so far plus the current
// tail parse — a newly frozen block's references resolved against the
// same parse tree its definitions came from.
const frameTargets: ReferenceTargets = {
definitions: new Map(this.frozenTargets.definitions),
footnotes: new Map(this.frozenTargets.footnotes),
}
collectReferenceTargets(tail.map(block => block.node), frameTargets)
if (newlyFrozen.length > 0) {
const frozenContext: MarkdownRenderContext = {
streaming: true,
codeLabels: this.codeLabels,
targets: frameTargets,
footnoteOrder: this.frozenFootnoteOrder,
footnoteCounts: this.frozenFootnoteCounts,
}
// Separator newlines are cached alongside the elements so the
// assembled children match the settled pipeline's block wrapping.
const batch = [...this.frozenElements]
for (const element of renderBlocks(newlyFrozen, frozenContext)) {
if (batch.length > 0) batch.push('\n')
batch.push(element)
}
this.frozenElements = batch
this.frozenCount = frozen.length
}
const tailContext: MarkdownRenderContext = {
streaming: true,
codeLabels: this.codeLabels,
targets: frameTargets,
footnoteOrder: [...this.frozenFootnoteOrder],
footnoteCounts: new Map(this.frozenFootnoteCounts),
}
const children = [...this.frozenElements]
for (const element of renderBlocks(tail, tailContext)) {
if (children.length > 0) children.push('\n')
children.push(element)
}
const section = renderFootnoteSection(tailContext)
if (section !== null) children.push('\n', section)
this.lastText = text
this.lastRendered = children
return this.lastRendered
}
}
const safeUrl: UrlTransform = url => sanitizeUrl(url)
/** Copy-button labels forwarded to fence CodeBlocks (this package is cordis-free, so copy arrives via props). */
export interface MarkdownCodeLabels {
/** Copy-button idle label. */
copyLabel?: string | undefined
/** Copy-button label during the post-copy confirmation window. */
copiedLabel?: string | undefined
}
function remoteImageUrl(url: string): string | undefined {
try {
const protocol = new URL(url).protocol
return protocol === 'http:' || protocol === 'https:' ? url : undefined
} catch {
return undefined
}
}
/** Build the component table; while `streaming`, fences render the plain arm (see CodeBlock). */
function buildComponents(streaming: boolean, codeLabels?: MarkdownCodeLabels): Components {
return {
a: ({ href = '', children }) => {
const safeHref = sanitizeUrl(href)
if (safeHref === '') return <>{children}</>
const external = ['http:', 'https:'].includes(new URL(safeHref).protocol)
return (
<a
href={safeHref}
{...(external ? { target: '_blank', rel: 'noopener noreferrer' } : {})}
>
{children}
</a>
)
},
img: ({ alt = '', src = '' }) => {
const imageSrc = remoteImageUrl(src)
if (imageSrc === undefined) return <span className={css.imageAlt}>{alt}</span>
return (
<img
className={css.image}
src={imageSrc}
alt={alt}
loading="lazy"
decoding="async"
referrerPolicy="no-referrer"
/>
)
},
table: ({ children }) => (
<div className={css.tableScroll}>
<table>{children}</table>
</div>
),
// Fenced blocks route through the shared CodeBlock (shiki for registered
// grammars, identical-geometry plain fallback for unknown/absent
// languages); inline code keeps the default <code> path (the :not(pre)
// rule styles it). While the message streams, the fence renders the
// plain arm — retokenizing a growing fence on every chunk is quadratic
// main-thread work; the finalize swap highlights it once.
pre: ({ children }) => {
// The markdown pipeline always hands `pre` its single `code` element;
// the undefined arm guards a react-markdown representation change.
/* v8 ignore next 2 */
const child = isValidElement<{ className?: string; children?: unknown }>(children) ? children : undefined
const raw = child?.props.children
// A fence whose content isn't one plain string (e.g. an empty fence)
// keeps the stock <pre> rather than guessing.
if (typeof raw !== 'string') return <pre>{children}</pre>
const lang = /language-([\w-]+)/.exec(child?.props.className ?? '')?.[1]
return (
<CodeBlock
code={raw}
lang={streaming ? undefined : lang}
copyLabel={codeLabels?.copyLabel}
copiedLabel={codeLabels?.copiedLabel}
/>
)
},
}
}
const staticComponents = buildComponents(false)
const streamingComponents = buildComponents(true)
/**
* Render untrusted assistant-authored Markdown as semantic React elements.
* @param props - Markdown source text preserved by the session projection;
* `streaming` renders fences and TeX plain (highlighting and KaTeX land on the finalize swap);
* `codeLabels` forwards localized copy-button labels to fence CodeBlocks —
* pass a reference-stable object (memoized per locale revision), because the
* component table memoizes on its identity and a fresh literal per render
* would rebuild it every streaming chunk.
* `streaming` renders fences and TeX plain (highlighting and KaTeX land on
* the finalize swap) and parses incrementally across chunks; `codeLabels`
* forwards localized copy-button labels to fence CodeBlocks — pass a
* reference-stable object (memoized per locale revision), because a new
* identity discards the streaming render cache mid-message.
* @returns A GFM document with TeX math rendered through KaTeX; raw HTML,
* relative links, and unsafe protocols are disabled, while absolute HTTP(S)
* images render directly.
*/
export function MarkdownText({ text, streaming = false, codeLabels }: {
export const MarkdownText = memo(function MarkdownText({ text, streaming = false, codeLabels }: {
text: string
streaming?: boolean
codeLabels?: MarkdownCodeLabels | undefined
}) {
// The label-free tables stay module-level singletons so the common case
// keeps referential stability across renders without a hook.
const components = useMemo(() => {
if (codeLabels === undefined) return streaming ? streamingComponents : staticComponents
return buildComponents(streaming, codeLabels)
}, [streaming, codeLabels])
return (
<div className={css.markdown}>
<ReactMarkdown
remarkPlugins={streaming ? streamingRemarkPlugins : settledRemarkPlugins}
rehypePlugins={streaming ? undefined : settledRehypePlugins}
components={components}
urlTransform={safeUrl}
>
{text}
</ReactMarkdown>
</div>
)
}
const streamRef = useRef<StreamingRenderer | null>(null)
const streamLabelsRef = useRef<MarkdownCodeLabels | undefined>(codeLabels)
const children = useMemo(() => {
if (!streaming) {
streamRef.current = null
return renderSettled(text, codeLabels)
}
if (streamRef.current === null || streamLabelsRef.current !== codeLabels) {
streamRef.current = new StreamingRenderer(codeLabels)
streamLabelsRef.current = codeLabels
}
return streamRef.current.render(text)
}, [text, streaming, codeLabels])
return <div className={css.markdown}>{children}</div>
})
@@ -0,0 +1,121 @@
/**
* Incremental block-level markdown parsing for an append-only text stream.
*
* Re-parsing the whole accumulated document on every streaming chunk is
* quadratic in the final reply length. CommonMark block parsing is line-based
* and appended text can only reshape the parse frontier — the last top-level
* block (a paragraph becoming a setext heading or a table, a list continuing
* after a blank line, an unclosed fence swallowing lines) — so earlier blocks
* are final. This parser therefore freezes all but the trailing
* {@link UNSTABLE_TAIL_BLOCKS} blocks and re-parses only the source tail
* behind them: each source region is parsed O(1) times over the stream
* instead of once per chunk.
*
* The freeze boundary comes from the parser's own `position` offsets, never
* from custom source scanning. The cut sits at the *end offset* of the last
* frozen block (not the next block's start): a following block's start offset
* excludes up to three spaces of insignificant leading indentation, which is
* harmless to drop, but cutting at the previous end also keeps the
* inter-block blank lines in the tail so the sliced source stays verbatim.
*
* Known deviation, shared with any prefix-freeze scheme: micromark resolves
* reference-style links and footnotes document-wide at parse time, so a
* reference whose definition lands on the other side of the freeze boundary
* renders literally until the settled full parse self-heals it.
*/
import type { Root, RootContent } from 'mdast'
/**
* Trailing blocks kept unstable. Appended text reshapes at most the last
* block; the second-to-last is retained as safety margin so a freeze decision
* never has to reason about the parse frontier.
*/
const UNSTABLE_TAIL_BLOCKS = 2
/** A top-level mdast block plus a render key that is stable across chunks. */
export interface PositionedBlock {
/** The parsed block. Positions inside it are relative to its parse slice. */
readonly node: RootContent
/**
* The block's start offset in the full source text. Stable from the frame
* a block first appears through freezing, so React reconciles rather than
* remounts when a block crosses the freeze boundary.
*/
readonly key: number
}
/** One {@link IncrementalMarkdownParser.update} result. */
export interface IncrementalBlocks {
/** Blocks that can no longer change; grows monotonically per generation. */
readonly frozen: readonly PositionedBlock[]
/** The re-parsed unstable tail (at most {@link UNSTABLE_TAIL_BLOCKS} blocks plus growth). */
readonly tail: readonly PositionedBlock[]
/** Bumped whenever non-append input discards the frozen prefix; callers drop caches keyed on it. */
readonly generation: number
}
/**
* A block's render key: its absolute source start offset. A position-less
* node (a grammar is free to omit positions) falls back to a negative
* list-index key, which keeps sibling keys unique without inventing offsets.
*/
function blockKey(node: RootContent, base: number, index: number): number {
const offset = node.position?.start.offset
return offset === undefined ? -(index + 1) : base + offset
}
/**
* Append-only incremental parser over a caller-supplied grammar. One instance
* accumulates one streaming document; non-append input resets it.
*/
export class IncrementalMarkdownParser {
private prevText = ''
private tailStart = 0
private frozen: PositionedBlock[] = []
private generation = 0
private cached: IncrementalBlocks | null = null
/** @param parse - Grammar shared with whatever renders the blocks, so boundaries agree. */
constructor(private readonly parse: (text: string) => Root) {}
/**
* Fold the current accumulated text and return the frozen/tail split.
* Idempotent for identical input (the previous result is returned as-is),
* so callers may invoke it from render paths that re-execute.
* @param text - The full accumulated markdown source.
* @returns Frozen and tail blocks with stream-stable render keys.
*/
update(text: string): IncrementalBlocks {
if (this.cached !== null && text === this.prevText) return this.cached
if (!text.startsWith(this.prevText)) {
this.prevText = ''
this.tailStart = 0
this.frozen = []
this.generation += 1
}
this.prevText = text
const base = this.tailStart
const blocks = this.parse(text.slice(base)).children
let firstUnstable = Math.max(0, blocks.length - UNSTABLE_TAIL_BLOCKS)
if (firstUnstable > 0) {
const cutEnd = blocks[firstUnstable - 1]?.position?.end.offset
if (cutEnd === undefined) {
// A grammar that omits positions leaves nothing to cut at; keep the
// whole parse in the tail rather than guessing a boundary.
firstUnstable = 0
} else {
for (const node of blocks.slice(0, firstUnstable)) {
this.frozen.push({ node, key: blockKey(node, base, this.frozen.length) })
}
this.tailStart = base + cutEnd
}
}
const tail = blocks.slice(firstUnstable).map((node, index) => ({
node,
key: blockKey(node, base, index),
}))
this.cached = { frozen: [...this.frozen], tail, generation: this.generation }
return this.cached
}
}
@@ -0,0 +1,84 @@
/**
* TeX-to-React via KaTeX, replicating the rehype-katex pipeline this renderer
* replaced: the same three-arm error chain (strict render, `strict: 'ignore'`
* retry, error span) and a DOM-identical element tree, so settled math keeps
* its exact markup. KaTeX emits an HTML string; the browser's own HTML parser
* (`DOMParser`, applying the spec's SVG/MathML foreign-content attribute
* adjustments KaTeX output relies on) turns it into a tree this module maps
* onto React elements — KaTeX output is a static span/MathML/SVG vocabulary
* with no raw user HTML, the same trust shiki's tree gets in CodeBlock.
*/
import { createElement } from 'react'
import type { CSSProperties, ReactNode } from 'react'
import katex from 'katex'
/**
* Convert one inline `style` attribute string into React's style object.
* KaTeX emits only plain kebab-case declarations (no custom properties and no
* nameless declarations), so camel-casing the property is the whole mapping.
*/
function styleObject(css: string): CSSProperties {
const style: Record<string, string> = {}
for (const declaration of css.split(';')) {
const colon = declaration.indexOf(':')
if (colon === -1) continue
const name = declaration.slice(0, colon).trim()
const key = name.replace(/-([a-z])/g, (_, letter: string) => letter.toUpperCase())
style[key] = declaration.slice(colon + 1).trim()
}
return style
}
/** Map one parsed DOM node onto a React element (text nodes pass through). */
function domToReact(node: ChildNode, key: number): ReactNode {
if (node.nodeType === Node.TEXT_NODE) return node.textContent
/* v8 ignore next 2 -- KaTeX output holds only elements and text; other
node kinds cannot appear in its serialized vocabulary. */
if (node.nodeType !== Node.ELEMENT_NODE) return null
const element = node as Element
const props: Record<string, unknown> = { key }
for (const attribute of element.attributes) {
if (attribute.name === 'class') props['className'] = attribute.value
else if (attribute.name === 'style') props['style'] = styleObject(attribute.value)
else props[attribute.name] = attribute.value
}
const children = [...element.childNodes].map(domToReact)
return children.length === 0
? createElement(element.localName, props)
: createElement(element.localName, props, ...children)
}
/**
* Render TeX source to React elements through KaTeX.
* @param value - The TeX source (math node value; fenced `math` blocks append
* their trailing newline to match the replaced pipeline's text extraction).
* @param displayMode - Display (block) versus inline rendering.
* @returns KaTeX's element tree, or the error span when the source does not
* parse (colored with KaTeX's stock `errorColor`, matching rehype-katex).
*/
export function renderTexToReact(value: string, displayMode: boolean): ReactNode {
let html: string
try {
html = katex.renderToString(value, { displayMode, throwOnError: true })
} catch (error) {
try {
html = katex.renderToString(value, { displayMode, strict: 'ignore', throwOnError: false })
} catch {
// KaTeX renders ParseErrors itself under throwOnError: false; only its
// internal errors reach here, so mirror rehype-katex's manual span.
/* v8 ignore next 8 */
return (
<span
className="katex-error"
style={{ color: '#cc0000' }}
title={String(error)}
>
{value}
</span>
)
}
}
const parsed = new DOMParser().parseFromString(html, 'text/html')
return [...parsed.body.childNodes].map(domToReact)
}
@@ -8,10 +8,6 @@ import type { Construct, Extension, Previous, State, Tokenizer } from 'micromark
// oxlint-disable typescript/no-this-alias -- micromark binds tokenizer context only on the outer callback.
interface RemarkProcessor {
data(): { micromarkExtensions?: Extension[] }
}
const previousBackslash: Previous = function (code) {
if (code !== codes.backslash) return true
const tail = this.events.at(-1)
@@ -342,12 +338,12 @@ const backslashMath: Extension = {
}
/**
* Add TeX backslash delimiters and same-line display-dollar blocks for remark-math.
* The same processor must register remark-math to compile the emitted math tokens.
* @returns Nothing.
* TeX backslash delimiters and same-line display-dollar blocks as a micromark
* syntax extension reusing `micromark-extension-math`'s token vocabulary; the
* caller must also register `math()` on the same parse so the emitted tokens
* compile to standard math nodes.
* @returns The micromark syntax extension.
*/
export function remarkMathCompatibility(this: RemarkProcessor): undefined {
const data = this.data()
const extensions = data.micromarkExtensions ?? (data.micromarkExtensions = [])
extensions.push(backslashMath)
export function mathCompatibility(): Extension {
return backslashMath
}
@@ -0,0 +1,41 @@
/**
* The markdown renderer's two mdast grammars, one per rendering arm. Both are
* built from the same micromark extensions, so block boundaries and inline
* semantics are identical wherever a document (or a document tail) is parsed:
* the incremental streaming path, the settled path, and the plain-text
* projection all agree on where blocks start and end.
*/
import type { Root } from 'mdast'
import { fromMarkdown } from 'mdast-util-from-markdown'
import { gfmFromMarkdown } from 'mdast-util-gfm'
import { mathFromMarkdown } from 'mdast-util-math'
import { gfm } from 'micromark-extension-gfm'
import { math } from 'micromark-extension-math'
import { mathCompatibility } from './mathCompatibility.ts'
/**
* Parse GFM markdown (the streaming arm's grammar: no math, so incomplete
* TeX never flashes KaTeX errors mid-stream).
* @param text - Markdown source.
* @returns The mdast root.
*/
export function parseGfm(text: string): Root {
return fromMarkdown(text, {
extensions: [gfm()],
mdastExtensions: [gfmFromMarkdown()],
})
}
/**
* Parse GFM markdown plus TeX math with the compatibility delimiters
* (the settled arm's grammar).
* @param text - Markdown source.
* @returns The mdast root.
*/
export function parseGfmWithMath(text: string): Root {
return fromMarkdown(text, {
extensions: [gfm(), mathCompatibility(), math()],
mdastExtensions: [gfmFromMarkdown(), mathFromMarkdown()],
})
}
@@ -0,0 +1,512 @@
/**
* Direct mdast→React markdown renderer. Replaces the react-markdown /
* remark-rehype pipeline with one switch over parsed nodes so streaming can
* cache frozen blocks as React elements; the rendered DOM is pinned
* byte-for-byte by `tests/fixtures/markdown-dom` and must not drift.
*
* Untrusted-output policy (unchanged from the replaced pipeline): link and
* image destinations pass a protocol allowlist, images additionally require
* absolute HTTP(S), raw HTML renders as literal text (no HTML enters the
* DOM), and KaTeX runs without trusted commands. Fragment-anchor URLs fail
* the allowlist, so footnote references and back-references render as plain
* text rather than in-page links.
*
* Merge-extensible node unions fall through the documented default (render
* nothing) rather than ending in assertNever: grammars registered elsewhere
* may add node types this renderer has no mapping for.
*/
import { Fragment, createElement } from 'react'
import type { Key, ReactNode } from 'react'
import type * as Md from 'mdast'
import type {} from 'mdast-util-math'
import { normalizeUri } from 'micromark-util-sanitize-uri'
import { CodeBlock } from './CodeBlock.tsx'
import { renderTexToReact } from './katex.tsx'
import type { PositionedBlock } from './incremental.ts'
import css from './MarkdownText.module.css'
/** Copy-button labels forwarded to fence CodeBlocks (this package is cordis-free, so copy arrives via props). */
export interface MarkdownCodeLabels {
/** Copy-button idle label. */
copyLabel?: string | undefined
/** Copy-button label during the post-copy confirmation window. */
copiedLabel?: string | undefined
}
function sanitizeUrl(url: string): string {
try {
switch (new URL(url).protocol) {
case 'http:':
case 'https:':
case 'mailto:':
return url
default:
return ''
}
} catch {
// Relative and otherwise unparsable destinations are disallowed alongside
// disallowed protocols; new URL() has no other failure mode for strings.
return ''
}
}
function remoteImageUrl(url: string): string | undefined {
try {
const protocol = new URL(url).protocol
return protocol === 'http:' || protocol === 'https:' ? url : undefined
} catch {
// Same single failure mode as above: not an absolute URL.
return undefined
}
}
/** Link/image reference targets collected from a document (first definition per identifier wins, as in CommonMark). */
export interface ReferenceTargets {
/** Link/image definitions keyed by upper-cased identifier. */
definitions: Map<string, Md.Definition>
/** Footnote definitions keyed by upper-cased identifier. */
footnotes: Map<string, Md.FootnoteDefinition>
}
/**
* Create an empty {@link ReferenceTargets}.
* @returns Fresh empty maps.
*/
export function createReferenceTargets(): ReferenceTargets {
return { definitions: new Map(), footnotes: new Map() }
}
/**
* Record every definition and footnote definition under `nodes` into
* `targets`, depth-first, keeping the first definition per identifier.
* @param nodes - Subtrees to walk (top-level blocks or any nested children).
* @param targets - Accumulator, typically shared across incremental segments.
*/
export function collectReferenceTargets(
nodes: readonly Md.RootContent[],
targets: ReferenceTargets,
): void {
for (const node of nodes) {
if (node.type === 'definition') {
const id = node.identifier.toUpperCase()
if (!targets.definitions.has(id)) targets.definitions.set(id, node)
} else if (node.type === 'footnoteDefinition') {
const id = node.identifier.toUpperCase()
if (!targets.footnotes.has(id)) targets.footnotes.set(id, node)
}
if ('children' in node) collectReferenceTargets(node.children, targets)
}
}
/**
* One render pass's state: immutable options and targets plus the footnote
* numbering accumulated in document order while references render.
*/
export interface MarkdownRenderContext {
/** Streaming arm: fences render plain and TeX stays literal. */
readonly streaming: boolean
/** Localized fence copy-button labels. */
readonly codeLabels: MarkdownCodeLabels | undefined
/** Reference targets visible to this pass. */
readonly targets: ReferenceTargets
/** Footnote identifiers in first-reference order; a footnote's number is its 1-based index here. */
readonly footnoteOrder: string[]
/** References rendered per identifier; drives the section's back-reference count. */
readonly footnoteCounts: Map<string, number>
}
/**
* Render top-level blocks. Nodes that render nothing (definitions, unmapped
* types) are dropped rather than kept as null placeholders, matching the
* replaced pipeline's child lists so separator newlines land identically.
* @param blocks - Blocks with their stream-stable render keys.
* @param context - The pass state; footnote numbering mutates in document order.
* @returns One React node per rendered block.
*/
export function renderBlocks(
blocks: readonly PositionedBlock[],
context: MarkdownRenderContext,
): ReactNode[] {
return blocks
.map(block => renderNode(block.node, block.key, context))
.filter(element => element !== null)
}
/**
* Interleave the newline text nodes the replaced pipeline emitted between
* block-level children. They are invisible between elements but coalesce
* into adjacent literal raw-HTML text, where the DOM parity fixtures pin
* them.
* @param elements - Rendered block children with empty renders already dropped.
* @param edges - Also emit the leading and trailing newline (hast's loose wrap).
* @returns The interleaved children.
*/
export function wrapBlockChildren(elements: readonly ReactNode[], edges: boolean): ReactNode[] {
const wrapped: ReactNode[] = []
for (const element of elements) {
if (edges || wrapped.length > 0) wrapped.push('\n')
wrapped.push(element)
}
if (edges && elements.length > 0) wrapped.push('\n')
return wrapped
}
/**
* A block child rendered for a parent that must tell paragraphs apart from
* other blocks (list items unwrap them when tight; footnote bodies receive
* their back-references inside the trailing paragraph).
*/
type BlockEntry = { paragraph: ReactNode[] } | { element: ReactNode }
/** Render container children into {@link BlockEntry} values, dropping empty renders. */
function renderBlockEntries(
blocks: readonly Md.RootContent[],
context: MarkdownRenderContext,
): BlockEntry[] {
const entries: BlockEntry[] = []
for (const [index, block] of blocks.entries()) {
if (block.type === 'paragraph') {
entries.push({ paragraph: renderChildren(block.children, context) })
} else {
const element = renderNode(block, index, context)
if (element !== null) entries.push({ element })
}
}
return entries
}
function renderChildren(
nodes: readonly Md.RootContent[],
context: MarkdownRenderContext,
): ReactNode[] {
return nodes.map((node, index) => renderNode(node, index, context))
}
function renderNode(node: Md.RootContent, key: Key, context: MarkdownRenderContext): ReactNode {
switch (node.type) {
case 'text':
return node.value
case 'paragraph':
return <p key={key}>{renderChildren(node.children, context)}</p>
case 'heading':
return createElement(`h${node.depth}`, { key }, ...renderChildren(node.children, context))
case 'blockquote':
return (
<blockquote key={key}>
{wrapBlockChildren(renderChildren(node.children, context).filter(child => child !== null), true)}
</blockquote>
)
case 'thematicBreak':
return <hr key={key} />
case 'break':
// The replaced pipeline emitted a newline text node after each <br>.
return <Fragment key={key}><br />{'\n'}</Fragment>
case 'strong':
return <strong key={key}>{renderChildren(node.children, context)}</strong>
case 'emphasis':
return <em key={key}>{renderChildren(node.children, context)}</em>
case 'delete':
return <del key={key}>{renderChildren(node.children, context)}</del>
case 'inlineCode':
// Parity with mdast-util-to-hast: inline code renders line endings as spaces.
return <code key={key}>{node.value.replace(/\r?\n|\r/g, ' ')}</code>
case 'html':
// No HTML parser enters the pipeline: raw HTML stays literal text.
return node.value
case 'code':
return renderCode(node, key, context)
case 'math':
return <Fragment key={key}>{renderTexToReact(node.value, true)}</Fragment>
case 'inlineMath':
return <Fragment key={key}>{renderTexToReact(node.value, false)}</Fragment>
case 'list':
return renderList(node, key, context)
case 'listItem':
// Reachable only in hand-built trees: the grammar emits items inside lists.
return renderListItem(node, listItemLoose(node), key, context)
case 'table':
return renderTable(node, key, context)
case 'link':
return renderAnchor(node.url, renderChildren(node.children, context), key)
case 'linkReference':
return renderLinkReference(node, key, context)
case 'image':
return renderImage(node.url, node.alt ?? '', key)
case 'imageReference':
return renderImageReference(node, key, context)
case 'footnoteReference':
return renderFootnoteReference(node, key, context)
case 'definition':
case 'footnoteDefinition':
// Targets render elsewhere: definitions resolve references in place;
// footnote bodies render in the trailing section.
return null
default:
// Documented default for the merge-extensible union: node types without
// a mapping (tableRow/tableCell outside a table, frontmatter, future
// grammar contributions) render nothing.
return null
}
}
function renderCode(node: Md.Code, key: Key, context: MarkdownRenderContext): ReactNode {
const language = node.lang ?? undefined
if (node.value === '') {
// Parity: the replaced pipeline kept the stock <pre> for an empty fence.
return (
<pre key={key}>
<code className={language === undefined ? undefined : `language-${language}`} />
</pre>
)
}
// The replaced pipeline recovered the grammar id from the hast class with
// /language-([\w-]+)/, which truncates at the first non-word character.
const lang = language === undefined ? undefined : /^[\w-]+/.exec(language)?.[0]
if (!context.streaming && lang === 'math') {
// ```math fences render as display TeX once settled (rehype-katex parity);
// its text extraction saw the code block's trailing newline.
return <Fragment key={key}>{renderTexToReact(`${node.value}\n`, true)}</Fragment>
}
return (
<CodeBlock
key={key}
code={node.value}
lang={context.streaming ? undefined : lang}
copyLabel={context.codeLabels?.copyLabel}
copiedLabel={context.codeLabels?.copiedLabel}
/>
)
}
/** A list is loose when it or any of its items is spread; every item then keeps its paragraphs. */
function listLoose(list: Md.List): boolean {
return (list.spread ?? false) || list.children.some(listItemLoose)
}
function listItemLoose(item: Md.ListItem): boolean {
return item.spread ?? item.children.length > 1
}
function renderList(node: Md.List, key: Key, context: MarkdownRenderContext): ReactNode {
const loose = listLoose(node)
const properties: { start?: number; className?: string } = {}
if (typeof node.start === 'number' && node.start !== 1) properties.start = node.start
if (node.children.some(item => typeof item.checked === 'boolean')) {
properties.className = 'contains-task-list'
}
return createElement(
node.ordered === true ? 'ol' : 'ul',
{ key, ...properties },
...node.children.map((item, index) => renderListItem(item, loose, index, context)),
)
}
function renderListItem(
item: Md.ListItem,
loose: boolean,
key: Key,
context: MarkdownRenderContext,
): ReactNode {
const entries = renderBlockEntries(item.children, context)
const task = typeof item.checked === 'boolean'
if (task) {
const checkbox = <input key="task-checkbox" type="checkbox" checked={item.checked === true} disabled />
const head = entries[0]
if (head !== undefined && 'paragraph' in head) {
head.paragraph = head.paragraph.length > 0 ? [checkbox, ' ', ...head.paragraph] : [checkbox]
} else {
entries.unshift({ paragraph: [checkbox] })
}
}
// Newline placement and tight-paragraph unwrapping mirror
// mdast-util-to-hast's list-item handler: a newline before every child
// except a tight leading paragraph, and after a trailing non-paragraph
// (or any trailing child when loose).
const parts: ReactNode[] = []
for (const [index, entry] of entries.entries()) {
const isParagraph = 'paragraph' in entry
if (loose || index !== 0 || !isParagraph) parts.push('\n')
if (!isParagraph) parts.push(entry.element)
else if (loose) parts.push(<p key={`p-${index}`}>{entry.paragraph}</p>)
else parts.push(<Fragment key={`p-${index}`}>{entry.paragraph}</Fragment>)
}
const tail = entries[entries.length - 1]
if (tail !== undefined && (loose || !('paragraph' in tail))) parts.push('\n')
return (
<li key={key} className={task ? 'task-list-item' : undefined}>
{parts}
</li>
)
}
function renderTable(node: Md.Table, key: Key, context: MarkdownRenderContext): ReactNode {
const align = node.align ?? null
const [headRow, ...bodyRows] = node.children
return (
<div key={key} className={css.tableScroll}>
<table>
{headRow !== undefined && <thead>{renderTableRow(headRow, 'th', align, 0, context)}</thead>}
{bodyRows.length > 0 && (
<tbody>
{bodyRows.map((row, index) => renderTableRow(row, 'td', align, index + 1, context))}
</tbody>
)}
</table>
</div>
)
}
function renderTableRow(
row: Md.TableRow,
cellTag: 'th' | 'td',
align: readonly Md.AlignType[] | null,
key: Key,
context: MarkdownRenderContext,
): ReactNode {
// With column alignment present, every row renders exactly one cell per
// column, padding or truncating the row (mdast-util-to-hast parity).
const length = align === null ? row.children.length : align.length
const cells: ReactNode[] = []
for (let index = 0; index < length; index++) {
const cell = row.children[index]
const alignValue = align?.[index]
cells.push(createElement(
cellTag,
// hast-util-to-jsx-runtime's default tableCellAlignToStyle turned the
// deprecated align attribute into an inline style; keep that DOM.
{ key: index, style: alignValue == null ? undefined : { textAlign: alignValue } },
...(cell === undefined ? [] : renderChildren(cell.children, context)),
))
}
return <tr key={key}>{cells}</tr>
}
function renderAnchor(url: string, children: ReactNode[], key: Key): ReactNode {
const safeHref = sanitizeUrl(normalizeUri(url))
if (safeHref === '') return <Fragment key={key}>{children}</Fragment>
const external = ['http:', 'https:'].includes(new URL(safeHref).protocol)
return (
<a
key={key}
href={safeHref}
{...(external ? { target: '_blank', rel: 'noopener noreferrer' } : {})}
>
{children}
</a>
)
}
function renderImage(url: string, alt: string, key: Key): ReactNode {
const imageSrc = remoteImageUrl(sanitizeUrl(normalizeUri(url)))
if (imageSrc === undefined) {
return <span key={key} className={css.imageAlt}>{alt}</span>
}
return (
<img
key={key}
className={css.image}
src={imageSrc}
alt={alt}
loading="lazy"
decoding="async"
referrerPolicy="no-referrer"
/>
)
}
/** The bracketed source text a reference reverts to when its definition is missing. */
function referenceSuffix(node: Md.LinkReference | Md.ImageReference): string {
if (node.referenceType === 'collapsed') return '][]'
if (node.referenceType === 'full') return `][${node.label ?? node.identifier}]`
return ']'
}
function renderLinkReference(
node: Md.LinkReference,
key: Key,
context: MarkdownRenderContext,
): ReactNode {
const definition = context.targets.definitions.get(node.identifier.toUpperCase())
const children = renderChildren(node.children, context)
if (definition === undefined) {
// The grammar only emits references whose definitions exist somewhere in
// the same parse, but incremental segments and hand-built trees may still
// present unresolved ones: revert to the bracketed source text.
return <Fragment key={key}>{'['}{children}{referenceSuffix(node)}</Fragment>
}
return renderAnchor(definition.url, children, key)
}
function renderImageReference(
node: Md.ImageReference,
key: Key,
context: MarkdownRenderContext,
): ReactNode {
const definition = context.targets.definitions.get(node.identifier.toUpperCase())
if (definition === undefined) return `![${node.alt ?? ''}${referenceSuffix(node)}`
return renderImage(definition.url, node.alt ?? '', key)
}
function renderFootnoteReference(
node: Md.FootnoteReference,
key: Key,
context: MarkdownRenderContext,
): ReactNode {
const id = node.identifier.toUpperCase()
const seen = context.footnoteCounts.get(id)
if (seen === undefined) context.footnoteOrder.push(id)
context.footnoteCounts.set(id, (seen ?? 0) + 1)
// The in-page anchor fails the protocol allowlist, so only the numbered
// superscript renders (matching the replaced pipeline's unwrapped link).
return <sup key={key}>{String(context.footnoteOrder.indexOf(id) + 1)}</sup>
}
/**
* Render the trailing footnote section for every footnote referenced during
* the pass, in first-reference order, with one plain-text back-reference
* marker per rendered reference.
* @param context - The pass state after all blocks rendered.
* @returns The section, or null when no referenced footnote has a definition.
*/
export function renderFootnoteSection(context: MarkdownRenderContext): ReactNode | null {
const items: ReactNode[] = []
for (const id of context.footnoteOrder) {
const definition = context.targets.footnotes.get(id)
if (definition === undefined) continue
const count = context.footnoteCounts.get(id) ?? 0
const backrefs: ReactNode[] = []
for (let reference = 1; reference <= count; reference++) {
if (backrefs.length > 0) backrefs.push(' ')
backrefs.push('↩')
if (reference > 1) backrefs.push(<sup key={`re-${reference}`}>{String(reference)}</sup>)
}
const entries = renderBlockEntries(definition.children, context)
const tail = entries[entries.length - 1]
const body: ReactNode[] = entries.map((entry, index) => (
'paragraph' in entry
? (
<p key={`p-${index}`}>
{entry.paragraph}
{entry === tail && <>{' '}{backrefs}</>}
</p>
)
: entry.element
))
// Without a trailing paragraph the back-references join the block list
// itself (and pick up the wrap newlines), as in the replaced pipeline.
if (tail === undefined || !('paragraph' in tail)) body.push(...backrefs)
items.push(
<li key={id} id={`user-content-fn-${normalizeUri(id.toLowerCase())}`}>
{wrapBlockChildren(body, true)}
</li>,
)
}
if (items.length === 0) return null
return (
<section key="footnotes" data-footnotes className="footnotes">
<h2 id="footnote-label" className="sr-only">Footnotes</h2>
<ol>{items}</ol>
</section>
)
}