// Copyright (c) Meta Platforms, Inc. and affiliates. 'use client'; /** * @file Markdown.tsx * @input Markdown string, canonical AST, optional plugins and custom renderers * @output Exports Markdown component, MarkdownProps, and renderer contracts * @position Core implementation; renders markdown as Astryx components */ import { Component, Suspense, useMemo, useRef } from 'react'; import type React from 'react'; import { Fragment } from 'react'; import * as stylex from "../style-runtime.js"; import type { StyleXStyles } from "../style-runtime.js"; import "../theme/tokens.stylex.js"; import { colorVars, spacingVars, radiusVars, typeScaleVars, typographyVars, fontWeightVars, borderVars, durationVars, easeVars } from "../theme/tokens.stylex.js"; import type { TextDisplay } from "../theme/types.js"; import { CodeBlock, Code } from "../CodeBlock/index.js"; import { CheckboxList } from "../CheckboxList/CheckboxList.js"; import { CheckboxListItem } from "../CheckboxList/CheckboxListItem.js"; import { Blockquote } from "../Blockquote/Blockquote.js"; import { List } from "../List/List.js"; import { ListItem } from "../List/ListItem.js"; import { Table } from "../Table/Table.js"; import { TableRow } from "../Table/TableRow.js"; import { TableCell } from "../Table/TableCell.js"; import { TableHeaderCell } from "../Table/TableHeaderCell.js"; import { TableHeader } from "../Table/TableHeader.js"; import { TableBody } from "../Table/TableBody.js"; import { mergeProps } from "../utils/index.js"; import { useStreamingText } from "../hooks/useStreamingText.js"; import { computeBoundaries, computeSegments } from "./streaming.js"; import type { BaseProps } from "../BaseProps.js"; import { useTheme } from "../theme/useTheme.js"; import { Citation } from "../Citation/Citation.js"; import type { CitationSource } from "../Citation/Citation.js"; import { useLinkComponent } from "../Link/useLinkComponent.js"; import type { LinkComponentType } from "../Link/types.js"; import { parseInlineAst, parseMarkdownAst, parseMarkdownAstIncremental, createIncrementalState, trimStreamingArtifacts, slugify, uniqueSlug } from "./parser.js"; import type { IncrementalState, MathParseOptions, ParseOptions } from "./parser.js"; import { getMarkdownAstLegacyCodeLanguage, markdownAstText } from "./ast.js"; import type { MarkdownAstBlockContent, MarkdownAstPhrasingContent, MarkdownAstTable } from "./ast.js"; import { applyMarkdownTransforms, getMarkdownExtensionRenderer, markdownExtensionText, prepareMarkdownPlugins, reportMarkdownPluginFailure } from "./plugins/protocol.js"; import { getMarkdownFenceProposal } from "./plugins/semanticFence.js"; import type { MarkdownExtensionNode, MarkdownPluginEntry, PreparedMarkdownPlugins } from "./plugins/protocol.js"; import { sanitizeMarkdownUrl } from "./url.js"; import { themeProps } from "../utils/themeProps.js"; import { useTranslator, type TranslatorFn } from "../i18n/index.js"; type SyncReactNode = Exclude>; type RenderExtensionNode = MarkdownExtensionNode; type RenderInlineNode = MarkdownAstPhrasingContent; type RenderBlockNode = MarkdownAstBlockContent; type RenderTable = MarkdownAstTable; // --------------------------------------------------------------------------- // Props // --------------------------------------------------------------------------- /** * A plugin that transforms text patterns into custom React elements * inside Markdown. Applied to parsed text nodes only — code blocks, * inline code, math, and other non-prose contexts are unaffected. * * Follows Lexical's TextMatchTransformer architecture: * - `pattern` for initial regex matching * - `getEndIndex` for programmatic boundary refinement * - `render` for producing the replacement element */ export interface MarkdownInlinePlugin { /** Regex with global flag. Matched against text nodes only. */ pattern: RegExp; /** * Optional: refine the match boundary after the regex hits. * Return the end index, or false to reject the match. * Default: match.index + match[0].length */ getEndIndex?: (text: string, match: RegExpMatchArray) => number | false; /** Render the match as a React element. */ render: (match: RegExpMatchArray, key: string) => React.ReactNode; } /** * A citation source referenced inline in the markdown via `[id]` or `【id】`. * When `sources` is provided, bracket content matching a source key is rendered * as a compact superscript citation pill instead of plain text. */ export type MarkdownSource = CitationSource; export interface MarkdownComponents { code?: React.ComponentType<{ code: string; language?: string; }>; inlineCode?: React.ComponentType<{ children: string; }>; /** * Renders opt-in `$…$` and `$$…$$` math. Supplying this renderer enables * math parsing; Astryx passes the raw expression and does not execute it. */ math?: React.ComponentType<{ value: string; display: 'inline' | 'block'; }>; citation?: React.ComponentType<{ source: CitationSource; number: number; variant: 'label' | 'number'; }>; link?: React.ComponentType<{ href: string; children: React.ReactNode; }>; heading?: React.ComponentType<{ level: 1 | 2 | 3 | 4 | 5 | 6; children: React.ReactNode; /** * Generated slug for this heading, matching the ids produced by * useOutlineFromMarkdown / parseOutlineFromMarkdown. Render it as the * element's `id` to keep Outline hash navigation working. Undefined for * headings nested inside blockquotes or list items (the outline only * lists top-level headings). */ id?: string; }>; paragraph?: React.ComponentType<{ children: React.ReactNode; }>; image?: React.ComponentType<{ src: string; alt: string; }>; blockquote?: React.ComponentType<{ children: React.ReactNode; }>; hr?: React.ComponentType; } export interface MarkdownProps = readonly []> extends BaseProps { ref?: React.Ref | React.Ref; children: string; /** * Display type. Markdown defaults to block. * Use 'inline' for markdown spans embedded inside surrounding text. * @default 'block' */ display?: TextDisplay; density?: 'default' | 'compact'; /** * The HTML heading level that markdown `#` maps to. * Shifts all heading levels down to fit the surrounding page hierarchy. * E.g. headingLevelStart={3} renders `#` as h3, `##` as h4, `###` as h5. * Levels that would exceed h6 are clamped to h6. * @default 1 */ headingLevelStart?: 1 | 2 | 3 | 4 | 5 | 6; isStreaming?: boolean; onLinkClick?: (href: string, event: React.MouseEvent) => void | false; // eslint-disable-line @typescript-eslint/no-invalid-void-type /** * Citation sources keyed by ID. When provided, `[id]` and `【id】` markers * in the markdown that match a key are rendered as citation chips. */ sources?: Record; /** * How citations are displayed inline. * - `'label'` (default) — chip with source title text, icon, and border * - `'number'` — compact numbered badge (1, 2, 3…) * @default 'label' */ citationStyle?: 'label' | 'number'; /** * Max width for prose content (paragraphs, headings, lists, blockquotes). * Tables and code blocks are unconstrained and can expand to the full * container width. Use for readable line lengths in wide layouts. * * @example * ``` * {text} * ``` */ contentWidth?: number | string; /** * Alignment of prose content within the container when `contentWidth` * is narrower than the available space. * - 'start': left-aligned (default) * - 'center': centered * @default 'start' */ contentAlign?: 'start' | 'center'; components?: Partial; /** * Plugins that transform text patterns into custom React elements. * Applied to text nodes after parsing — code blocks, inline code, and math * are unaffected. Patterns are matched in order; first match wins * for overlapping ranges. */ /** Ordered syntax, immutable transforms, and typed extension renderers. */ plugins?: Plugins; /** * Legacy text-match renderers. Unchanged and independent from `plugins`. */ inlinePlugins?: MarkdownInlinePlugin[]; /** * Opt-in autolinking of bare URLs and emails inside text. * - `'gfm'` — GitHub-Flavored Markdown autolink-literal rules: * `https?://…`, `www.…`, ``, ``, and bare * `user@host` all become `` links. Trailing sentence punctuation * (`?!.,:*_~`) and unbalanced trailing `)` are excluded; matches inside * code spans, code blocks, existing links, and image alt text are * skipped. * * Default behavior (option unset) is unchanged — bare URLs render as * literal text. */ autolink?: 'gfm'; } // --------------------------------------------------------------------------- // Styles // --------------------------------------------------------------------------- const ALIGN_MARGIN: Record = { start: '0', center: 'auto' }; const BLOCK_ALIGN_MARGIN: Record = { start: null, center: 'auto' }; const _temp = { k7Eaqz: "xkj4a21", "$$css": true }; const dynamicStyles = { proseWidth: (maxWidth: string) => [{ ks0D6T: maxWidth != null ? "xf68679" : maxWidth, $$css: true as const }, { "--x-maxWidth": (val => typeof val === "number" ? val + "px" : val != null ? val : undefined)(maxWidth) }], proseAlign: (marginInline: string) => [{ kUOVxO: marginInline != null ? "xvlecxo" : marginInline, $$css: true as const }, { "--x-marginInline": (val => typeof val === "number" ? val + "px" : val != null ? val : undefined)(marginInline) }], blockWidth: (minWidth: string) => [_temp, { "--x-minWidth": (val => typeof val === "number" ? val + "px" : val != null ? val : undefined)(`min(${minWidth}, 100%)`) }], blockAlign: (marginInline: string) => [{ kUOVxO: marginInline != null ? "xvlecxo" : marginInline, $$css: true as const }, { "--x-marginInline": (val => typeof val === "number" ? val + "px" : val != null ? val : undefined)(marginInline) }], cellMinWidth: (minWidth: string) => [{ k7Eaqz: minWidth != null ? "xkj4a21" : minWidth, $$css: true as const }, { "--x-minWidth": (val => typeof val === "number" ? val + "px" : val != null ? val : undefined)(minWidth) }] }; const cellAlignStyles = { center: { k9WMMc: "x2b8uid", $$css: true as const }, end: { k9WMMc: "xp4054r", $$css: true as const } }; const styles = { root: { kMv6JI: "x9ynric", kMwMTN: "x1tgivj0", kLWn49: "xw6l6zx", kGuDYH: "xjm74w1", kzqmXN: "xh8yej3", ks0D6T: "x193iq5w", k7Eaqz: "xeuugli", kHjlTd: "x1mzt3pk", $$css: true as const }, inlineRoot: { k1xSpc: "xt0psk2", kzqmXN: "x14atkfc", ks0D6T: "x1x1rfll", kMv6JI: "xjb2p0i", kMwMTN: "x1heor9g", kLWn49: "x15bjb6t", kGuDYH: "x1qlqyl8", $$css: true as const }, headingBase: { kMv6JI: "x1g81bgm", kMwMTN: "x1tgivj0", $$css: true as const }, h1: { kGuDYH: "xcg7oai", k63SB2: "x1v68xuy", kLWn49: "xfmsba7", $$css: true as const }, h2: { kGuDYH: "x1xvnhcw", k63SB2: "x12yy4cs", kLWn49: "x1cpk1wn", $$css: true as const }, h3: { kGuDYH: "xii13ha", k63SB2: "x1jcxfy8", kLWn49: "xwjzt0u", $$css: true as const }, h4: { kGuDYH: "x8tkxat", k63SB2: "x2hcmsi", kLWn49: "xqerer", $$css: true as const }, h5: { kGuDYH: "xsgqta0", k63SB2: "xno150v", kLWn49: "xo3gurs", $$css: true as const }, h6: { kGuDYH: "xw5ohdf", k63SB2: "x1pw4frv", kLWn49: "xeixjfn", $$css: true as const }, spacingHeadingMajorDefault: { keoZOQ: "x13rv44w", k1K539: "xep27e5", $$css: true as const }, spacingHeadingMinorDefault: { keoZOQ: "x1s1qpeq", k1K539: "x1p37lm5", $$css: true as const }, spacingParagraphDefault: { keoZOQ: "xj6sv8s", k1K539: "xep27e5", $$css: true as const }, spacingCodeblockDefault: { keoZOQ: "x1s1qpeq", k1K539: "xwh1t77", $$css: true as const }, spacingBlockquoteDefault: { keoZOQ: "x1s1qpeq", k1K539: "xwh1t77", $$css: true as const }, spacingListDefault: { keoZOQ: "xj6sv8s", k1K539: "xep27e5", $$css: true as const }, spacingTableDefault: { keoZOQ: "x1s1qpeq", k1K539: "xwh1t77", $$css: true as const }, spacingHrDefault: { keoZOQ: "x13rv44w", k1K539: "x4d5cxa", $$css: true as const }, spacingImageDefault: { keoZOQ: "xj6sv8s", k1K539: "xep27e5", $$css: true as const }, spacingHeadingMajorCompact: { keoZOQ: "x1s1qpeq", k1K539: "x1p37lm5", $$css: true as const }, spacingHeadingMinorCompact: { keoZOQ: "xj6sv8s", k1K539: "x14cgwvg", $$css: true as const }, spacingParagraphCompact: { keoZOQ: "xcsaf9d", k1K539: "x14cgwvg", $$css: true as const }, spacingCodeblockCompact: { keoZOQ: "xtbrsbv", k1K539: "x1p37lm5", $$css: true as const }, spacingBlockquoteCompact: { keoZOQ: "xtbrsbv", k1K539: "x1p37lm5", $$css: true as const }, spacingListCompact: { keoZOQ: "xcsaf9d", k1K539: "x14cgwvg", $$css: true as const }, spacingTableCompact: { keoZOQ: "xtbrsbv", k1K539: "x1p37lm5", $$css: true as const }, spacingHrCompact: { keoZOQ: "xj6sv8s", k1K539: "xep27e5", $$css: true as const }, spacingImageCompact: { keoZOQ: "xtbrsbv", k1K539: "x1p37lm5", $$css: true as const }, noMarginBlockStart: { keoZOQ: "xdj266r", $$css: true as const }, noMarginBlockEnd: { k1K539: "xat24cr", $$css: true as const }, codeBlockWrapper: { ks0D6T: "x193iq5w", $$css: true as const }, tableWrapper: { kXHlph: "xw2csxc", ks0D6T: "x193iq5w", "--container-padding-inline-start": "xrhngw9", "--container-padding-inline-end": "xjsfl84", "--container-padding-block-start": "x1047aw6", "--container-padding-block-end": "xax9j7h", $$css: true as const }, blockIndent: { kUOVxO: "x1ho7dyk", $$css: true as const }, hr: { kMzoRj: "xc342km", kEafiO: "x11xkdxz", kPef9Z: "x13fuv20", kLZC3w: "x1pc3f07", $$css: true as const } }; // --------------------------------------------------------------------------- // Streaming fade-in animation // Parse a CSS duration string (e.g. "175ms", "0.15s") to milliseconds. function parseDuration(value: string): number | null { const ms = value.match(/^([\d.]+)ms$/); if (ms) { return parseFloat(ms[1]); } const s = value.match(/^([\d.]+)s$/); if (s) { return parseFloat(s[1]) * 1000; } return null; } // --------------------------------------------------------------------------- /** * Mutable cursor threaded through the render tree during streaming. * Tracks how many text characters we've visited so far. When `offset` * crosses `boundary`, newly rendered content is wrapped in a fade-in span. */ interface StreamingCursor { /** Characters visited so far in this render pass */ offset: number; /** Recent boundary positions (up to 3), oldest first. * Text before boundaries[0] is fully visible (no animation). * Text between boundaries[i] and boundaries[i+1] gets its own fade span. * Text after the last boundary is the newest chunk. */ boundaries: number[]; /** Whether streaming fade is active */ active: boolean; } /** * Count the total text characters in inline nodes without rendering. * Used to advance the cursor past a block that will be faded as a whole unit. */ function countInlineTextLength(nodes: ReadonlyArray): number { let len = 0; for (const node of nodes) { switch (node.type) { case 'text': len += node.value.length; break; case 'inlineCode': len += node.value.length; break; case 'inlineMath': len += node.value.length; break; case 'image': len += node.alt.length; break; case 'strong': case 'emphasis': case 'delete': case 'link': len += countInlineTextLength(node.children); break; case 'break': len += 1; break; case 'citation': len += 1; break; case 'extension': len += node.source?.length ?? 0; break; } } return len; } /** * Count total text characters in a block node tree. */ function countBlockTextLength(nodes: ReadonlyArray): number { let len = 0; for (const node of nodes) { switch (node.type) { case 'heading': case 'paragraph': len += countInlineTextLength(node.children); break; case 'code': len += node.value.length; break; case 'math': len += node.value.length; break; case 'blockquote': len += countBlockTextLength(node.children); break; case 'list': for (const item of node.children) { len += countBlockTextLength(item.children); } break; case 'table': for (const row of node.children) { for (const cell of row.children) { len += countInlineTextLength(cell.children); } } break; case 'extension': len += node.source?.length ?? 0; break; case 'thematicBreak': break; case 'image': len += node.alt.length; break; } } return len; } const headingStyles = { 1: styles.h1, 2: styles.h2, 3: styles.h3, 4: styles.h4, 5: styles.h5, 6: styles.h6 } as const; // --------------------------------------------------------------------------- // --------------------------------------------------------------------------- // Inline plugin matching // --------------------------------------------------------------------------- type InlinePluginSegment = { type: 'text'; content: string; } | { type: 'plugin'; element: React.ReactNode; matchLength: number; }; /** * Find all plugin matches in a text string and split it into segments * of plain text and plugin-rendered elements. * * Algorithm mirrors `findMatches` in `useLinkify`: * 1. Run each plugin's regex against the text * 2. Check getEndIndex if provided (default: match.index + match[0].length) * 3. If getEndIndex returns false, skip the match * 4. Collect all non-overlapping matches, sorted by start (first match wins) * 5. Split into text/plugin segments */ function applyInlinePlugins(text: string, plugins: MarkdownInlinePlugin[]): InlinePluginSegment[] { interface RawMatch { start: number; end: number; match: RegExpMatchArray; plugin: MarkdownInlinePlugin; } const allMatches: RawMatch[] = []; for (const plugin of plugins) { // Reset lastIndex instead of cloning — avoids allocation per call. // Safe because text nodes are processed sequentially (no interleaving). plugin.pattern.lastIndex = 0; let m: RegExpExecArray | null; while ((m = plugin.pattern.exec(text)) !== null) { let end: number; if (plugin.getEndIndex) { const result = plugin.getEndIndex(text, m); if (result === false) { continue; } end = result; } else { end = m.index + m[0].length; } allMatches.push({ start: m.index, end, match: m, plugin }); } } // Sort by start position (stable sort preserves plugin order for same position) allMatches.sort((a, b) => a.start - b.start); // Remove overlapping matches (first wins) const resolved: RawMatch[] = []; let lastEnd = 0; for (const m of allMatches) { if (m.start >= lastEnd) { resolved.push(m); lastEnd = m.end; } } if (resolved.length === 0) { return [{ type: 'text', content: text }]; } const segments: InlinePluginSegment[] = []; let cursor = 0; for (let i = 0; i < resolved.length; i++) { const m = resolved[i]; // Text before this match if (m.start > cursor) { segments.push({ type: 'text', content: text.slice(cursor, m.start) }); } // Plugin element segments.push({ type: 'plugin', element: m.plugin.render(m.match, `plugin-${i}`), matchLength: m.end - m.start }); cursor = m.end; } // Remaining text if (cursor < text.length) { segments.push({ type: 'text', content: text.slice(cursor) }); } return segments; } interface MarkdownPluginBoundaryProps { children: React.ReactNode; fallback: React.ReactNode; pluginName: string; resetKey: unknown; resetRenderer: unknown; } interface MarkdownPluginBoundaryState { failed: boolean; resetKey: unknown; resetRenderer: unknown; } class MarkdownPluginBoundary extends Component { state: MarkdownPluginBoundaryState = { failed: false, resetKey: this.props.resetKey, resetRenderer: this.props.resetRenderer }; static getDerivedStateFromError(): Partial { return { failed: true }; } static getDerivedStateFromProps(props: MarkdownPluginBoundaryProps, state: MarkdownPluginBoundaryState): Partial | null { return props.resetKey === state.resetKey && props.resetRenderer === state.resetRenderer ? null : { failed: false, resetKey: props.resetKey, resetRenderer: props.resetRenderer }; } componentDidCatch(error: unknown): void { reportMarkdownPluginFailure(this.props.pluginName, 'render', error); } render(): React.ReactNode { return this.state.failed ? this.props.fallback : this.props.children; } } function useStableMarkdownSyntaxEntries(plugins: PreparedMarkdownPlugins | undefined): ReadonlyArray | undefined { const identity = plugins?.syntaxIdentity ?? ''; const syntaxEntriesRef = useRef<{ identity: string; entries: ReadonlyArray | undefined; } | undefined>(undefined); if (syntaxEntriesRef.current == null || syntaxEntriesRef.current.identity !== identity) { syntaxEntriesRef.current = { identity, entries: plugins == null || identity === '' ? undefined : plugins.syntaxEntries }; } return syntaxEntriesRef.current.entries; } // --------------------------------------------------------------------------- // Inline renderer // --------------------------------------------------------------------------- /** * Wrap a text string with a fade-in span for the portion that is "new". * If the entire string is old, returns it as-is. If partially new, splits * into [old, new]. If entirely new, wraps the whole thing. */ /** Check if a cursor offset is in the "new" (animating) region */ function wrapTextWithFade(content: string, cursor: StreamingCursor, key: string | number): SyncReactNode { const startOffset = cursor.offset; cursor.offset += content.length; if (!cursor.active) { return content; } const segments = computeSegments(content, startOffset, cursor.boundaries, key); if (segments == null) { return content; } const nodes = segments.map(seg => seg.fading ? {seg.text} : {seg.text}); return {nodes}; } /** * Context for citation rendering, threaded through the inline/block render tree. * Tracks which sources have been cited and assigns sequential display numbers. */ interface CitationContext { sources: Record; numberMap: Map; nextNumber: number; style: 'label' | 'number'; } function getCitationNumber(ctx: CitationContext, sourceId: string): number { let num = ctx.numberMap.get(sourceId); if (num == null) { num = ctx.nextNumber++; ctx.numberMap.set(sourceId, num); } return num; } function renderInline(node: RenderInlineNode, index: number, onLinkClick: MarkdownProps['onLinkClick'] | undefined, cursor: StreamingCursor, citationCtx: CitationContext | null, linkComponent: LinkComponentType = 'a', inlinePlugins?: MarkdownInlinePlugin[], components?: Partial, preparedPlugins?: PreparedMarkdownPlugins): SyncReactNode { switch (node.type) { case 'text': { if (inlinePlugins && inlinePlugins.length > 0) { const segments = applyInlinePlugins(node.value, inlinePlugins); // If no plugin matched, fall through to the normal path // O(1) guard: applyInlinePlugins returns a single text segment when // nothing matched — skip the plugin path entirely in that case. if (!(segments.length === 1 && segments[0].type === 'text')) { const result: React.ReactNode[] = []; for (let i = 0; i < segments.length; i++) { const seg = segments[i]; if (seg.type === 'text') { result.push(wrapTextWithFade(seg.content, cursor, `${index}-seg-${i}`)); } else { // Plugin segment — advance cursor by matchLength cursor.offset += seg.matchLength; result.push({seg.element}); } } return {result}; } } return wrapTextWithFade(node.value, cursor, index); } case 'strong': return {node.children.map((c, i) => renderInline(c, i, onLinkClick, cursor, citationCtx, linkComponent, inlinePlugins, components, preparedPlugins))} ; case 'emphasis': return {node.children.map((c, i) => renderInline(c, i, onLinkClick, cursor, citationCtx, linkComponent, inlinePlugins, components, preparedPlugins))} ; case 'delete': return {node.children.map((c, i) => renderInline(c, i, onLinkClick, cursor, citationCtx, linkComponent, inlinePlugins, components, preparedPlugins))} ; case 'inlineCode': { // Track code content length for cursor but don't split inside code cursor.offset += node.value.length; const InlineCodeComp = components?.inlineCode; const codeEl = InlineCodeComp ? {node.value} : {node.value}; return codeEl; } case 'inlineMath': { const MathComp = components?.math; if (MathComp == null) { return wrapTextWithFade(`$${node.value}$`, cursor, index); } cursor.offset += node.value.length; return ; } case 'link': { const safeHref = sanitizeMarkdownUrl(node.url); if (safeHref == null) { // Unsafe URL — render as plain text return {node.children.map((c, i) => renderInline(c, i, onLinkClick, cursor, citationCtx, linkComponent, inlinePlugins, components, preparedPlugins))} ; } const LinkComp = components?.link; if (LinkComp) { return {node.children.map((c, i) => renderInline(c, i, onLinkClick, cursor, citationCtx, linkComponent, inlinePlugins, components, preparedPlugins))} ; } const isExternal = safeHref.startsWith('https://') || safeHref.startsWith('http://'); const handleClick = onLinkClick ? (e: React.MouseEvent) => { const result = onLinkClick(safeHref, e); if (result === false) { e.preventDefault(); } } : undefined; // Use linkComponent for internal links, native for external links. // Framework routers (Next.js, React Router) handle internal navigation; // external links with target="_blank" should use a plain anchor. const LinkTag = isExternal ? 'a' : linkComponent; return {node.children.map((c, i) => renderInline(c, i, onLinkClick, cursor, citationCtx, linkComponent, inlinePlugins, components, preparedPlugins))} ; } case 'image': { const safeSrc = sanitizeMarkdownUrl(node.url); if (safeSrc == null) { return [{node.alt}]; } const ImageComp = components?.image; if (ImageComp) { return ; } return {node.alt}; } case 'extension': { const renderer = getMarkdownExtensionRenderer(preparedPlugins, node); const fallback = wrapTextWithFade(node.source ?? markdownExtensionText(preparedPlugins, node), cursor, index); if (renderer == null) { return fallback; } let rendered: React.ReactNode; try { rendered = renderer.render({ node }); } catch (error) { reportMarkdownPluginFailure(node.plugin, 'render', error); return fallback; } return {rendered} ; } case 'break': cursor.offset += 1; return
; case 'citation': { cursor.offset += 1; if (!citationCtx) { // No sources provided — render as plain text return [{node.sourceId}]; } const num = getCitationNumber(citationCtx, node.sourceId); const source = citationCtx.sources[node.sourceId] ?? { title: node.sourceId }; const citVariant = citationCtx.style === 'number' ? 'number' : 'label'; const CitationComp = components?.citation; const chip = CitationComp ? : ; return chip; } } } // --------------------------------------------------------------------------- // Block spacing helper // --------------------------------------------------------------------------- function getElementSpacing(node: RenderBlockNode, density: 'default' | 'compact'): StyleXStyles { const compact = density === 'compact'; switch (node.type) { case 'heading': return node.depth <= 3 ? compact ? styles.spacingHeadingMajorCompact : styles.spacingHeadingMajorDefault : compact ? styles.spacingHeadingMinorCompact : styles.spacingHeadingMinorDefault; case 'paragraph': return compact ? styles.spacingParagraphCompact : styles.spacingParagraphDefault; case 'code': case 'math': return compact ? styles.spacingCodeblockCompact : styles.spacingCodeblockDefault; case 'blockquote': return compact ? styles.spacingBlockquoteCompact : styles.spacingBlockquoteDefault; case 'list': return compact ? styles.spacingListCompact : styles.spacingListDefault; case 'table': return compact ? styles.spacingTableCompact : styles.spacingTableDefault; case 'extension': return compact ? styles.spacingParagraphCompact : styles.spacingParagraphDefault; case 'thematicBreak': return compact ? styles.spacingHrCompact : styles.spacingHrDefault; case 'image': return compact ? styles.spacingImageCompact : styles.spacingImageDefault; } } // --------------------------------------------------------------------------- // Block renderer // --------------------------------------------------------------------------- /** * Compute per-column min-widths from table AST content. * Buckets: ≤6 chars → 60px, 7–15 → 80px, >15 → 120px. */ function computeTableColumnMinWidths(node: RenderTable): number[] { const [header, ...rows] = node.children; if (header == null) { return []; } return header.children.map((cell, colIdx) => { let maxLen = countInlineTextLength(cell.children); for (const row of rows) { const rowCell = row.children[colIdx]; if (rowCell != null) { const len = countInlineTextLength(rowCell.children); if (len > maxLen) { maxLen = len; } } } return maxLen <= 6 ? 60 : maxLen <= 15 ? 80 : 120; }); } function renderBlock(node: RenderBlockNode, index: number, blockCount: number, density: 'default' | 'compact', headingLevelStart: 1 | 2 | 3 | 4 | 5 | 6, onLinkClick: MarkdownProps['onLinkClick'] | undefined, cursor: StreamingCursor, citationCtx: CitationContext | null, contentWidthValue: string | null, contentAlign: 'start' | 'center', linkComponent: LinkComponentType = 'a', inlinePlugins: MarkdownInlinePlugin[] | undefined, components: Partial | undefined, preparedPlugins: PreparedMarkdownPlugins | undefined, t: TranslatorFn, headingIdMap?: ReadonlyMap): SyncReactNode { const blockAlignMargin = BLOCK_ALIGN_MARGIN[contentAlign]; const blockAlignStyle = blockAlignMargin != null ? dynamicStyles.blockAlign(blockAlignMargin) : null; const spacing = getElementSpacing(node, density); const isFirst = index === 0; const isLast = index === blockCount - 1; switch (node.type) { case 'heading': { const level = Math.min(node.depth + headingLevelStart - 1, 6) as 1 | 2 | 3 | 4 | 5 | 6; const headingChildren = node.children.map((c, i) => renderInline(c, i, onLinkClick, cursor, citationCtx, linkComponent, inlinePlugins, components, preparedPlugins)); // Only top-level headings get an id: the map is built from the same // traversal parseOutlineFromMarkdown uses (which skips headings nested // in blockquotes / list items), so rendered ids and outline ids stay // identical — including duplicate-slug numbering. const headingId = headingIdMap?.get(node); const HeadingComp = components?.heading; if (HeadingComp) { return {headingChildren} ; } const Tag = `h${level}` as const; return {headingChildren} ; } case 'paragraph': { const paraChildren = node.children.map((c, i) => renderInline(c, i, onLinkClick, cursor, citationCtx, linkComponent, inlinePlugins, components, preparedPlugins)); const ParagraphComp = components?.paragraph; if (ParagraphComp) { return {paraChildren}; } // Markdown paragraphs render as
, not

: inline content can // include block-level nodes (images, custom inline components), and a //

would reparent them, desyncing SSR markup from the hydrated DOM. // Block spacing comes from token-based StyleX margins, so the rendered // appearance is unchanged. role="paragraph" re-exposes the paragraph // role in the accessibility tree (a pure ARIA hint — it does not trigger // the parser's block-child reparenting) so prose semantics are preserved // without the

composition hazard. Consumers who want a real

// element can still pass components={{paragraph: 'p'}}. return

{paraChildren}
; } case 'code': { // Track codeblock content in cursor for accurate character counting cursor.offset += node.value.length; const language = getMarkdownAstLegacyCodeLanguage(node) ?? 'plaintext'; const CodeBlockComp = components?.code; if (CodeBlockComp) { return ; } const fallback =
; const proposal = getMarkdownFenceProposal(node); if (proposal == null) { return fallback; } const renderer = getMarkdownExtensionRenderer(preparedPlugins, proposal.node); if (renderer == null) { return fallback; } try { const rendered = renderer.render({ node: proposal.node }); if (rendered == null || typeof rendered === 'boolean') { return fallback; } return {rendered} ; } catch (error) { reportMarkdownPluginFailure(proposal.node.plugin, 'render', error); return fallback; } } case 'math': { cursor.offset += node.value.length; const MathComp = components?.math; if (MathComp == null) { return
{`$$${node.value}$$`}
; } return ; } case 'blockquote': { const BlockquoteComp = components?.blockquote; if (BlockquoteComp) { const bqC = node.children.map((c, i) => renderBlock(c, i, node.children.length, density, headingLevelStart, onLinkClick, cursor, citationCtx, contentWidthValue, contentAlign, linkComponent, inlinePlugins, components, preparedPlugins, t)); return {bqC}; } return
{node.children.map((c, i) => renderBlock(c, i, node.children.length, density, headingLevelStart, onLinkClick, cursor, citationCtx, contentWidthValue, contentAlign, linkComponent, inlinePlugins, components, preparedPlugins, t))}
; } case 'list': { // Detect task lists: all items have a checked state const isTaskList = node.children.length > 0 && node.children.every(item => item.checked != null); if (isTaskList) { // Extract labels from task items — render as rich inline content const checkedValues = node.children.map((item, i) => ({ item, key: `task-${i}` })).filter(({ item }) => item.checked).map(({ key }) => key); return
{node.children.map((item, i) => { const firstChild = item.children[0]; const isInline = item.children.length === 1 && firstChild?.type === 'paragraph'; const label = isInline ? <> {firstChild.children.map((c, j) => renderInline(c, j, onLinkClick, cursor, citationCtx, linkComponent, inlinePlugins, components, preparedPlugins))} : <> {item.children.map((c, j) => renderBlock(c, j, item.children.length, density, headingLevelStart, onLinkClick, cursor, citationCtx, contentWidthValue, contentAlign, linkComponent, inlinePlugins, components, preparedPlugins, t))} ; return ; })}
; } return
{node.children.map((item, i) => { const firstChild = item.children[0]; const isInline = item.children.length === 1 && firstChild?.type === 'paragraph'; // Check if this entire list item is "new" — if so, fade the // whole item as a block instead of fading individual text spans. const label = isInline ? <> {firstChild.children.map((c, j) => renderInline(c, j, onLinkClick, cursor, citationCtx, linkComponent, inlinePlugins, components, preparedPlugins))} : <> {item.children.map((c, j) => renderBlock(c, j, item.children.length, density, headingLevelStart, onLinkClick, cursor, citationCtx, contentWidthValue, contentAlign, linkComponent, inlinePlugins, components, preparedPlugins, t))} ; return ; })}
; } case 'table': { const colMinWidths = computeTableColumnMinWidths(node); const [header, ...rows] = node.children; return
{header?.children.map((h, i) => {h.children.map((c, j) => renderInline(c, j, onLinkClick, cursor, citationCtx, linkComponent, inlinePlugins, components, preparedPlugins))} )} {rows.map((row, i) => { const cells = row.children.map((cell, j) => {cell.children.map((c, k) => renderInline(c, k, onLinkClick, cursor, citationCtx, linkComponent, inlinePlugins, components, preparedPlugins))} ); return {cells} ; })}
; } case 'extension': { const renderer = getMarkdownExtensionRenderer(preparedPlugins, node); const fallbackText = node.source ?? markdownExtensionText(preparedPlugins, node); const fallback = wrapTextWithFade(fallbackText, cursor, index); let content: React.ReactNode = fallback; if (renderer != null) { try { const rendered = renderer.render({ node }); content = {rendered} ; } catch (error) { reportMarkdownPluginFailure(node.plugin, 'render', error); } } return
{content}
; } case 'thematicBreak': { const HrComp = components?.hr; if (HrComp) { return ; } return
; } case 'image': { const safeSrc = sanitizeMarkdownUrl(node.url); if (safeSrc == null) { return
[{node.alt}]
; } const ImageComp = components?.image; if (ImageComp) { return ; } return
{node.alt}
; } } } // --------------------------------------------------------------------------- // Component // --------------------------------------------------------------------------- /** * Renders a markdown string as Astryx components. Supports streaming with * smooth fade-in animation via isStreaming. * * @example * ``` * * {'# Hello\n\nThis is **bold** and _italic_ text.\n\n- Item one\n- Item two'} * * ``` */ export function Markdown = readonly []>({ ref, children, display = 'block', density = 'default', headingLevelStart = 1, isStreaming = false, onLinkClick, sources, citationStyle = 'label', contentWidth = 680, contentAlign = 'start', components, plugins, inlinePlugins, autolink, xstyle, className, style, 'data-testid': testId, ...props }: MarkdownProps): React.ReactElement { const t = useTranslator(); const LinkComponent = useLinkComponent(); // Derive the set of source IDs for the parser (stable across renders when sources don't change) const sourceIds = useMemo(() => sources ? new Set(Object.keys(sources)) : undefined, [sources]); const preparedPlugins = useMemo(() => { if (plugins == null) { return undefined; } try { return prepareMarkdownPlugins(plugins); } catch (error) { reportMarkdownPluginFailure('configuration', 'transform', error); return undefined; } }, [plugins]); const syntaxPlugins = useStableMarkdownSyntaxEntries(preparedPlugins); const hasMathRenderer = components?.math != null; const legacyParseOptions = useMemo>>(() => ({ sourceIds, autolink, plugins: syntaxPlugins }), [sourceIds, autolink, syntaxPlugins]); const mathParseOptions = useMemo>>(() => ({ sourceIds, autolink, math: true, plugins: syntaxPlugins }), [sourceIds, autolink, syntaxPlugins]); // Smooth bursty streamed chunks into a steady character-by-character reveal. // When not streaming, the hook returns children unchanged (no-op). const smoothedText = useStreamingText(children, isStreaming); const incrementalStateRef = useRef>(createIncrementalState()); // Reset incremental cache when parser-affecting component options toggle — // cached settled blocks were parsed with the previous setting. const prevAutolinkRef = useRef(autolink); const prevMathRef = useRef(hasMathRenderer); if (prevAutolinkRef.current !== autolink || prevMathRef.current !== hasMathRenderer) { incrementalStateRef.current = createIncrementalState(); prevAutolinkRef.current = autolink; prevMathRef.current = hasMathRenderer; } const parsedBlocks = useMemo(() => { if (display === 'inline') { return []; } if (isStreaming) { if (smoothedText === '') { incrementalStateRef.current = createIncrementalState(); return []; } const options = hasMathRenderer ? mathParseOptions : legacyParseOptions; const input = trimStreamingArtifacts(smoothedText, options); return (hasMathRenderer ? parseMarkdownAstIncremental(input, incrementalStateRef.current, mathParseOptions) : parseMarkdownAstIncremental(input, incrementalStateRef.current, legacyParseOptions)).children; } return (hasMathRenderer ? parseMarkdownAst(children, mathParseOptions) : parseMarkdownAst(children, legacyParseOptions)).children; }, [display, smoothedText, children, isStreaming, hasMathRenderer, mathParseOptions, legacyParseOptions]); const transformSource = isStreaming ? trimStreamingArtifacts(smoothedText, hasMathRenderer ? mathParseOptions : legacyParseOptions) : children; const blocks = useMemo(() => applyMarkdownTransforms({ type: 'root', children: parsedBlocks }, preparedPlugins, transformSource, !isStreaming, 'block').children, [parsedBlocks, preparedPlugins, transformSource, isStreaming]); // Assign each top-level heading the slug that parseOutlineFromMarkdown // would derive for it, so Outline hash links built from the same source // always find a matching DOM id. Mirrors that function's traversal exactly: // top-level blocks only, one shared duplicate-numbering sequence. // NOTE: must stay above the `display === 'inline'` early return below — // hooks cannot be conditional. const headingIdMap = useMemo(() => { if (display === 'inline' || blocks.length === 0) { return undefined; } const map = new Map(); const counts = new Map(); for (const block of blocks) { if (block.type === 'heading') { const label = markdownAstText(block.children, node => markdownExtensionText(preparedPlugins, node)).trim(); map.set(block, uniqueSlug(slugify(label), counts)); } } return map; }, [display, blocks, preparedPlugins]); const parsedInlineNodes = useMemo(() => { if (display !== 'inline') { return []; } const options = hasMathRenderer ? mathParseOptions : legacyParseOptions; const input = isStreaming ? trimStreamingArtifacts(smoothedText, options) : children; return hasMathRenderer ? parseInlineAst(input, mathParseOptions) : parseInlineAst(input, legacyParseOptions); }, [display, smoothedText, children, isStreaming, hasMathRenderer, mathParseOptions, legacyParseOptions]); const inlineTransformSource = isStreaming ? trimStreamingArtifacts(smoothedText, hasMathRenderer ? mathParseOptions : legacyParseOptions) : children; const inlineNodes = useMemo(() => { if (display !== 'inline') { return []; } const transformed = applyMarkdownTransforms({ type: 'root', children: [{ type: 'paragraph', children: parsedInlineNodes }] }, preparedPlugins, inlineTransformSource, !isStreaming, 'inline'); const paragraph = transformed.children[0]; return paragraph?.type === 'paragraph' ? [...paragraph.children] : parsedInlineNodes; }, [display, parsedInlineNodes, preparedPlugins, inlineTransformSource, isStreaming]); // Track recent boundaries for stacked fade-in animation. // The number of spans needed = ceil(animationDuration / tickInterval). // useStreamingText ticks every ~tickMs, and the fade runs for // --duration-fast-max. We compute the span count dynamically so the // oldest span has just finished animating when it gets evicted. const { token } = useTheme(); const maxSpans = useMemo(() => { const duration = parseDuration(token('--duration-fast-max')) ?? 230; const tick = parseDuration(token('--duration-fast-min')); // Tick interval mirrors useStreamingText's timing: base / 10 const tickMs = tick != null ? Math.max(4, Math.round(tick / 10)) : 50; return Math.min(Math.ceil(duration / tickMs), 12); }, [token]); const prevBlocksRef = useRef>([]); const prevInlineNodesRef = useRef>([]); const boundariesRef = useRef([]); const smoothedLen = smoothedText.length; const boundaries = useMemo(() => { void smoothedLen; // dep trigger const prevLen = display === 'inline' ? countInlineTextLength(prevInlineNodesRef.current) : countBlockTextLength(prevBlocksRef.current); const next = computeBoundaries(boundariesRef.current, prevLen, maxSpans); boundariesRef.current = next; return next; }, [display, smoothedLen, maxSpans]); const cursor: StreamingCursor = { offset: 0, boundaries, active: isStreaming }; // Build citation context — numbers are assigned in encounter order during rendering. // This is recreated each render so numbering stays consistent with the AST. const citationCtx: CitationContext | null = sources ? { sources, numberMap: new Map(), nextNumber: 1, style: citationStyle } : null; if (display === 'inline') { const renderedInline = {inlineNodes.map((node, i) => renderInline(node, i, onLinkClick, cursor, citationCtx, LinkComponent, inlinePlugins, components, preparedPlugins))} ; // Store current inline nodes for next render's boundary calculation. prevInlineNodesRef.current = inlineNodes; prevBlocksRef.current = []; return renderedInline; } const rendered =
} // Consumer props first: what the component sets for itself — the // document role included — wins. {...props} role="document" data-testid={testId} {...mergeProps(themeProps('markdown', { density }), stylex.props(styles.root, xstyle), className, style)}> {blocks.map((block, i) => renderBlock(block, i, blocks.length, density, headingLevelStart, onLinkClick, cursor, citationCtx, contentWidth ? typeof contentWidth === 'number' ? `${contentWidth}px` : contentWidth : null, contentAlign, LinkComponent, inlinePlugins, components, preparedPlugins, t, headingIdMap))}
; // Store current blocks for next render's boundary calculation. // This ref write is safe under StrictMode: both invocations produce the same // blocks (same smoothedText → same useMemo result), so both write the same value. prevBlocksRef.current = blocks; prevInlineNodesRef.current = []; return rendered; } Markdown.displayName = 'Markdown';