"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 `