From 047ea509873aa2b891deb701924c19d7b35ec107 Mon Sep 17 00:00:00 2001 From: ZiyaZhang <199893125+ZiyaZhang@users.noreply.github.com> Date: Tue, 4 Aug 2026 01:46:52 -0700 Subject: [PATCH 01/29] feat(web): add preview badge to empty hero --- .../lifecycle-chrome/hero.expected.md | 2 +- .../lifecycle-chrome/plan-active.expected.md | 2 +- .../ui-conversation/src/client/locales.ts | 2 ++ .../src/client/skeleton/EmptyHero.tsx | 3 +- .../src/client/skeleton/HeroShell.module.css | 31 ++++++++++++++++--- .../ui-conversation/tests/skeleton.spec.tsx | 11 ++++++- 6 files changed, 43 insertions(+), 8 deletions(-) diff --git a/apps/web/tests/snapshots/lifecycle-chrome/hero.expected.md b/apps/web/tests/snapshots/lifecycle-chrome/hero.expected.md index 8611ac5c0d..bdb07876a3 100644 --- a/apps/web/tests/snapshots/lifecycle-chrome/hero.expected.md +++ b/apps/web/tests/snapshots/lifecycle-chrome/hero.expected.md @@ -20,7 +20,7 @@ - button "Settings": - img - text: Settings -- text: Let's start building +- text: Let's start building Preview - button "Choose workspace": - img - text: workspace diff --git a/apps/web/tests/snapshots/lifecycle-chrome/plan-active.expected.md b/apps/web/tests/snapshots/lifecycle-chrome/plan-active.expected.md index a9fb7901d7..8c5cf915dc 100644 --- a/apps/web/tests/snapshots/lifecycle-chrome/plan-active.expected.md +++ b/apps/web/tests/snapshots/lifecycle-chrome/plan-active.expected.md @@ -20,7 +20,7 @@ - button "Settings": - img - text: Settings -- text: Let's start building +- text: Let's start building Preview - button "Choose workspace": - img - text: workspace diff --git a/packages/client/ui-conversation/src/client/locales.ts b/packages/client/ui-conversation/src/client/locales.ts index 7a38aaa0f8..2f05223e5d 100644 --- a/packages/client/ui-conversation/src/client/locales.ts +++ b/packages/client/ui-conversation/src/client/locales.ts @@ -33,6 +33,7 @@ export const zh = { 'access.confirm.cancel': '取消', 'access.confirm.enable': '启用 Full access', 'hero.headline': '开始构建吧', + 'hero.preview': '预览版', 'hero.chooseWorkspace': '选择工作区', 'session.hierarchy': '会话层级', 'details.title': '详情', @@ -144,6 +145,7 @@ export const en = { 'access.confirm.cancel': 'Cancel', 'access.confirm.enable': 'Enable Full access', 'hero.headline': 'Let\'s start building', + 'hero.preview': 'Preview', 'hero.chooseWorkspace': 'Choose workspace', 'session.hierarchy': 'Session hierarchy', 'details.title': 'Details', diff --git a/packages/client/ui-conversation/src/client/skeleton/EmptyHero.tsx b/packages/client/ui-conversation/src/client/skeleton/EmptyHero.tsx index 0c1b31bbb7..4b491e1f7f 100644 --- a/packages/client/ui-conversation/src/client/skeleton/EmptyHero.tsx +++ b/packages/client/ui-conversation/src/client/skeleton/EmptyHero.tsx @@ -119,7 +119,8 @@ export function HeroShell({ t, children }: HeroShellProps) {
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 rather than guessing.
- if (typeof raw !== 'string') return {children}
- const lang = /language-([\w-]+)/.exec(child?.props.className ?? '')?.[1]
- return (
-
- )
- },
- }
-}
-
-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 (
-
-
- {text}
-
-
- )
-}
+ const streamRef = useRef(null)
+ const streamLabelsRef = useRef(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 {children}
+})
diff --git a/packages/client/ui-primitives/src/markdown/incremental.ts b/packages/client/ui-primitives/src/markdown/incremental.ts
new file mode 100644
index 0000000000..af56232e3a
--- /dev/null
+++ b/packages/client/ui-primitives/src/markdown/incremental.ts
@@ -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
+ }
+}
diff --git a/packages/client/ui-primitives/src/markdown/katex.tsx b/packages/client/ui-primitives/src/markdown/katex.tsx
new file mode 100644
index 0000000000..bae1aa5104
--- /dev/null
+++ b/packages/client/ui-primitives/src/markdown/katex.tsx
@@ -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 = {}
+ 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 = { 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 (
+
+ {value}
+
+ )
+ }
+ }
+ const parsed = new DOMParser().parseFromString(html, 'text/html')
+ return [...parsed.body.childNodes].map(domToReact)
+}
diff --git a/packages/client/ui-primitives/src/markdown/remarkMathCompatibility.ts b/packages/client/ui-primitives/src/markdown/mathCompatibility.ts
similarity index 95%
rename from packages/client/ui-primitives/src/markdown/remarkMathCompatibility.ts
rename to packages/client/ui-primitives/src/markdown/mathCompatibility.ts
index dcd8c32362..3edd9d1e63 100644
--- a/packages/client/ui-primitives/src/markdown/remarkMathCompatibility.ts
+++ b/packages/client/ui-primitives/src/markdown/mathCompatibility.ts
@@ -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
}
diff --git a/packages/client/ui-primitives/src/markdown/parse.ts b/packages/client/ui-primitives/src/markdown/parse.ts
new file mode 100644
index 0000000000..98482a809d
--- /dev/null
+++ b/packages/client/ui-primitives/src/markdown/parse.ts
@@ -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()],
+ })
+}
diff --git a/packages/client/ui-primitives/src/markdown/render.tsx b/packages/client/ui-primitives/src/markdown/render.tsx
new file mode 100644
index 0000000000..786a1a4636
--- /dev/null
+++ b/packages/client/ui-primitives/src/markdown/render.tsx
@@ -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
+ /** Footnote definitions keyed by upper-cased identifier. */
+ footnotes: Map
+}
+
+/**
+ * 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
+}
+
+/**
+ * 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 {renderChildren(node.children, context)}
+ case 'heading':
+ return createElement(`h${node.depth}`, { key }, ...renderChildren(node.children, context))
+ case 'blockquote':
+ return (
+
+ {wrapBlockChildren(renderChildren(node.children, context).filter(child => child !== null), true)}
+
+ )
+ case 'thematicBreak':
+ return
+ case 'break':
+ // The replaced pipeline emitted a newline text node after each
.
+ return
{'\n'}
+ case 'strong':
+ return {renderChildren(node.children, context)}
+ case 'emphasis':
+ return {renderChildren(node.children, context)}
+ case 'delete':
+ return {renderChildren(node.children, context)}
+ case 'inlineCode':
+ // Parity with mdast-util-to-hast: inline code renders line endings as spaces.
+ return {node.value.replace(/\r?\n|\r/g, ' ')}
+ 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 {renderTexToReact(node.value, true)}
+ case 'inlineMath':
+ return {renderTexToReact(node.value, false)}
+ 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 for an empty fence.
+ return (
+
+
+
+ )
+ }
+ // 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 {renderTexToReact(`${node.value}\n`, true)}
+ }
+ return (
+
+ )
+}
+
+/** 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 =
+ 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({entry.paragraph}
)
+ else parts.push({entry.paragraph} )
+ }
+ const tail = entries[entries.length - 1]
+ if (tail !== undefined && (loose || !('paragraph' in tail))) parts.push('\n')
+ return (
+
+ {parts}
+
+ )
+}
+
+function renderTable(node: Md.Table, key: Key, context: MarkdownRenderContext): ReactNode {
+ const align = node.align ?? null
+ const [headRow, ...bodyRows] = node.children
+ return (
+
+
+ {headRow !== undefined && {renderTableRow(headRow, 'th', align, 0, context)}}
+ {bodyRows.length > 0 && (
+
+ {bodyRows.map((row, index) => renderTableRow(row, 'td', align, index + 1, context))}
+
+ )}
+
+
+ )
+}
+
+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 {cells}
+}
+
+function renderAnchor(url: string, children: ReactNode[], key: Key): ReactNode {
+ const safeHref = sanitizeUrl(normalizeUri(url))
+ if (safeHref === '') return {children}
+ const external = ['http:', 'https:'].includes(new URL(safeHref).protocol)
+ return (
+
+ {children}
+
+ )
+}
+
+function renderImage(url: string, alt: string, key: Key): ReactNode {
+ const imageSrc = remoteImageUrl(sanitizeUrl(normalizeUri(url)))
+ if (imageSrc === undefined) {
+ return {alt}
+ }
+ return (
+
+ )
+}
+
+/** 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 {'['}{children}{referenceSuffix(node)}
+ }
+ 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 {String(context.footnoteOrder.indexOf(id) + 1)}
+}
+
+/**
+ * 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({String(reference)})
+ }
+ const entries = renderBlockEntries(definition.children, context)
+ const tail = entries[entries.length - 1]
+ const body: ReactNode[] = entries.map((entry, index) => (
+ 'paragraph' in entry
+ ? (
+
+ {entry.paragraph}
+ {entry === tail && <>{' '}{backrefs}>}
+
+ )
+ : 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(
+
+ {wrapBlockChildren(body, true)}
+ ,
+ )
+ }
+ if (items.length === 0) return null
+ return (
+
+ Footnotes
+ {items}
+
+ )
+}
diff --git a/packages/client/ui-primitives/tests/fixtures/markdown-dom/blockquote-nested.settled.txt b/packages/client/ui-primitives/tests/fixtures/markdown-dom/blockquote-nested.settled.txt
new file mode 100644
index 0000000000..d213078f94
--- /dev/null
+++ b/packages/client/ui-primitives/tests/fixtures/markdown-dom/blockquote-nested.settled.txt
@@ -0,0 +1,12 @@
+
+
+
+ #text "level one\nstill one"
+
+
+ #text "nested"
+
+ -
+ #text "quoted list"
+
+ #text "after"
diff --git a/packages/client/ui-primitives/tests/fixtures/markdown-dom/blockquote-nested.streaming.txt b/packages/client/ui-primitives/tests/fixtures/markdown-dom/blockquote-nested.streaming.txt
new file mode 100644
index 0000000000..d213078f94
--- /dev/null
+++ b/packages/client/ui-primitives/tests/fixtures/markdown-dom/blockquote-nested.streaming.txt
@@ -0,0 +1,12 @@
+
+
+
+ #text "level one\nstill one"
+
+
+ #text "nested"
+
+ -
+ #text "quoted list"
+
+ #text "after"
diff --git a/packages/client/ui-primitives/tests/fixtures/markdown-dom/code-fences.settled.txt b/packages/client/ui-primitives/tests/fixtures/markdown-dom/code-fences.settled.txt
new file mode 100644
index 0000000000..a0a11e1254
--- /dev/null
+++ b/packages/client/ui-primitives/tests/fixtures/markdown-dom/code-fences.settled.txt
@@ -0,0 +1,78 @@
+
+
+