"use client"; /** * MarkdownEngine — THE one react-markdown pipeline for every surface * (chat messages, blog, docs, KB articles, legal, releases, admin previews). * * `SimpleMarkdownRenderer` and `RichMarkdownRenderer` are thin compositions * over this engine (see ./simple-markdown-renderer.tsx and ./rich/) — there * is exactly ONE parse path, ONE sanitizer stack, ONE mermaid, ONE * heading-id algorithm, ONE link-click model. Do not fork this pipeline; * extend via `componentOverrides` / `additionalRemarkPlugins` / * `preprocessContent` / `extraAllowedHtmlTags`. * * Pipeline order (each stage documented in ./sanitize.ts): * preprocessContent (composition hook, e.g. shortcodes) * → completeStreamingTail (streaming only — MUST precede the escape pass) * → escapeUnknownHtmlTags (text pre-pass, React 19 crash guard) * → remark: remarkGfm, remarkBreaks, ...additionalRemarkPlugins * → rehype: rehypeRaw → rehypeSanitize(schema) → rehypeStripUnsafe * → rehypeHighlight * → urlTransform: cardAwareUrlTransform * → components: buildBaseComponents(...) spread-last componentOverrides */ import React, { memo, useMemo } from 'react'; import ReactMarkdown, { type Components } from 'react-markdown'; import type { PluggableList } from 'unified'; import remarkGfm from 'remark-gfm'; import remarkBreaks from 'remark-breaks'; import rehypeHighlight from 'rehype-highlight'; import rehypeRaw from 'rehype-raw'; import rehypeSanitize from 'rehype-sanitize'; import type { ResolveLinkResult } from '../../../types/doc-source'; import { buildEffectiveTagSet, buildSanitizeSchema, cardAwareUrlTransform, escapeUnknownHtmlTags, rehypeStripUnsafe, } from './sanitize'; import { resolveTextSizeConfig, type TextSizeConfig } from './text-size'; import { HeadingIdMapContext, HeadingLineOffsetContext, useHeadingIdMap, type HeadingSection, } from './heading-ids'; import { buildBaseComponents } from './base-components'; import { completeStreamingTail, splitStreamingBlocks } from './streaming'; export type { ResolveLinkResult }; /** * Module-scope empty default for `brokenLinks`. * * A `brokenLinks = []` DEFAULT PARAMETER allocates a fresh array on every * render, so the `components` useMemo (which lists it as a dep) recomputed * every render, which changed `StreamingBlockRenderer`'s `components` prop * identity, which made its `memo` bail EVERY time — every completed block * re-parsed on every streamed token, defeating the entire atomic-block * optimization. Any new default that is an object/array/function MUST be * hoisted here for the same reason. * * Exported so the compositions that apply the SAME default before handing * props down (rich) share this ONE identity instead of declaring a second * module-scope empty array with a copy of this rationale. Deliberately NOT * re-exported from ./index — it is an internal identity contract between the * engine and its compositions, not public API. */ export const NO_BROKEN_LINKS: readonly string[] = []; export interface MarkdownEngineProps { content: string; className?: string; /** Backend-provided heading IDs for deep-link anchors */ sectionIds?: HeadingSection[]; /** When the page already has an H1, render markdown `#` as `

` */ demoteMarkdownH1ToH2?: boolean; /** List of broken link hrefs detected server-side (shown with [BROKEN] badge) */ brokenLinks?: readonly string[]; /** Callback for internal (non-http, non-anchor) link clicks */ onInternalLinkClick?: (path: string, options?: { expandFolder?: boolean; fromInternalLink?: boolean }) => void; /** Current documentation path — enables internal-link mode when set */ currentPath?: string; /** Resolve an internal link href to a navigation path. */ onResolveLink?: (href: string, currentPath: string) => Promise; /** Pre-process the raw markdown string before rendering (e.g. shortcode expansion) */ preprocessContent?: (content: string) => string; /** Merge additional or override react-markdown component renderers (spread LAST — caller wins) */ componentOverrides?: Partial; /** Extra remark plugins appended after the built-in remarkGfm + remarkBreaks */ additionalRemarkPlugins?: PluggableList; /** Text sizing preset / per-element overrides */ textSize?: TextSizeConfig; /** * Extra raw-HTML tags this composition admits. Unioned into BOTH the * text pre-pass allowlist AND the sanitize schema together (the * coupled-allowlist invariant — see ./sanitize.ts). */ extraAllowedHtmlTags?: string[]; /** * Set true for the actively streaming message ONLY. Enables atomic-block * memoization + fence tail-completion + an aria-live wrapper. The caller * MUST flip back to false on completion — that final render is one * authoritative whole-document parse (block cache discarded), so * streaming can never permanently diverge from the settled output. */ streaming?: boolean; } /** * One memoized completed block of a streaming message. Parent-level memos * keep every prop except `text` referentially stable, so a completed block * re-renders only if its own text changes (i.e. the splitter re-cut). * React key = position index — identical blocks at different positions * never alias (see ./streaming.ts). */ const StreamingBlockRenderer = memo(function StreamingBlockRenderer({ text, remarkPlugins, rehypePlugins, components, }: { text: string; remarkPlugins: PluggableList; rehypePlugins: PluggableList; components: Components; }) { return ( {text} ); }); const MarkdownEngineImpl: React.FC = ({ content, className = '', sectionIds, demoteMarkdownH1ToH2 = false, brokenLinks = NO_BROKEN_LINKS, onInternalLinkClick, currentPath, onResolveLink, preprocessContent, componentOverrides, additionalRemarkPlugins, textSize, extraAllowedHtmlTags, streaming = false, }) => { const textSizes = useMemo(() => resolveTextSizeConfig(textSize), [textSize]); // Stable identity key for the caller's tag array — a fresh array with the // same contents must not bust these memos. const extraTagsKey = extraAllowedHtmlTags?.join('|') ?? ''; // Effective tag allowlist — shared source for pre-pass AND schema. // Both memos consume ONLY `extraTagsKey`, so the dep list is honest and // needs no eslint suppression. const effectiveTags = useMemo( () => buildEffectiveTagSet(extraTagsKey ? extraTagsKey.split('|') : undefined), [extraTagsKey], ); const processedContent = useMemo(() => { const preprocessed = preprocessContent ? preprocessContent(content) : content; // ORDER IS LOAD-BEARING: complete the streaming tail FIRST, then escape. // // Escaping first failed OPEN on the most common streaming shape. During a // stream every partially-emitted fence is unclosed, and both of the // pre-pass's fence notions (`PROTECTED_SPAN_RE` for the carve AND the // mask) only recognize CLOSED fences — so a `` inside the // still-open fence satisfied "is closed later", the prose opener above it // stayed live, and parse5 swallowed the rest of the message until the // fence closed (an LLM explaining `