"use client";
/**
* Default react-markdown component map for the unified markdown engine.
* ONE implementation of code blocks, headings, links, lists, tables etc.
* Compositions layer on top via `componentOverrides` (spread LAST by the
* engine, so caller overrides always win).
*/
import React from 'react';
import type { Components } from 'react-markdown';
import Image from '../../../embed-shims/next-image';
import { useAuthedImageSrc } from '../../../hooks/use-authed-image-src';
import { cn } from '../../../utils/cn';
import type { ResolveLinkResult } from '../../../types/doc-source';
import type { TextSizeElement } from './text-size';
import { MermaidDiagram } from './mermaid-diagram';
import {
extractText,
resolveFallbackHeadingId,
useAssignedHeadingIds,
useHeadingId,
} from './heading-ids';
import { slugifyHeadingText } from '../../../utils/markdown-heading-id';
import {
getHashTargetElement,
navigateSamePageHash,
HUB_HEADER_OFFSET_PX,
} from '../../../utils/same-page-hash-nav';
/**
* True when an image `src` is worth rendering. Shared by the base `img` and
* the rich composition's `img` / `video` overrides so the empty-`![]()`
* guard has exactly one definition.
*/
/**
* Inline content image (blog / docs / chat attachments). A standalone
* component — NOT inline JSX in the `img` renderer — because it must call
* `useAuthedImageSrc`: in bearer-mode native shells (`capacitor://`,
* `tauri://`) a gateway-hosted image can't load through a plain `
` (no
* Authorization header on native asset loads), so the hook swaps in an authed
* blob URL; everywhere else it returns `src` untouched. While the blob fetch
* is in flight `resolvedSrc` is null and the component renders nothing — same
* as the no-src case. Ported from #1548 into the unified engine (the original
* lived in the now-deleted `ui/simple-markdown-renderer.tsx`). Sizing matches
* the engine's cap: 400x400 intrinsic, `w-auto h-auto max-h-[400px]` drives
* the actual rendered box; click-to-expand surfaces provide full resolution.
*/
const MarkdownContentImage: React.FC<{ src: string; alt?: string }> = ({ src, alt }) => {
const resolvedSrc = useAuthedImageSrc(src);
if (!resolvedSrc) return null;
return (
);
};
export function hasRenderableSrc(src: unknown): src is string {
return typeof src === 'string' && src.trim() !== '';
}
export interface BuildBaseComponentsOptions {
textSizes: Record;
demoteMarkdownH1ToH2: boolean;
brokenLinks: readonly string[];
currentPath?: string;
onInternalLinkClick?: (path: string, options?: { expandFolder?: boolean; fromInternalLink?: boolean }) => void;
onResolveLink?: (href: string, currentPath: string) => Promise;
}
/**
* The standard leaf renderers (`code` block + inline, `blockquote`, `div`
* pass-through) as standalone functions.
*
* SSOT for compositions that must OVERRIDE these renderers for a narrow
* special case and then fall through: the rich composition intercepts embed
* fence languages, shortcode-expanded `div`s and Reddit's `reddit-embed-bq`
* blockquote, and delegates everything else here. Previously it reproduced
* these renderers byte-for-byte, so any class change here silently drifted
* on content surfaces.
*
* THESE MUST STAY HOOK-FREE: the rich composition calls them as plain
* functions from inside its own renderers (`standard.code(props)`), which is
* not a React render of a component and would break the rules of hooks.
* Hook-using renderers (the headings, which read the heading-line offset
* from context) live in `buildBaseComponents` below and are never delegated
* to this way.
*
* ODS-TOKENS FLAG (ODS_TOKEN_RULES §Typography / §General): inline code keeps
* an inline `text-[0.9em]`, carried over verbatim from the pre-unification
* renderers — ODS has no relative-to-parent code size, so mapping it onto an
* existing token would visibly change every inline code span. Same shape as
* the flag in ./text-size.ts — flagged for addition to ODS, not copied
* anywhere else.
*
* The code BLOCK's font is NOT flagged: the raw "JetBrains Mono", "SF Mono",
* Consolas stack the old renderer inlined is gone, replaced by Tailwind's
* `font-mono` (→ `var(--font-family-heading)`, the Azeret Mono ODS stack) —
* the same class the inline-code branch below already used, so the two code
* surfaces no longer disagree about their family.
*/
export function buildStandardLeafRenderers({
textSizes,
}: {
textSizes: Record;
}): Pick {
return {
// eslint-disable-next-line @typescript-eslint/no-explicit-any
code: ({ node, inline, className: codeClassName, children, ...props }: any) => {
const match = /language-(\w+)/.exec(codeClassName || '');
const language = match ? match[1] : '';
if (!inline && language === 'mermaid') {
return ;
}
if (!inline && match) {
return (
);
}
return (
{children}
);
},
// eslint-disable-next-line @typescript-eslint/no-explicit-any
blockquote: ({ children }: any) => (
{children}
),
// Pass-through `div` (overridable for embeds; `node` is dropped so it
// never reaches the DOM).
// eslint-disable-next-line @typescript-eslint/no-explicit-any
div: ({ node, className: divClassName, children, ...props }: any) => (
{children}
),
};
}
export function buildBaseComponents({
textSizes,
demoteMarkdownH1ToH2,
brokenLinks,
currentPath: propCurrentPath,
onInternalLinkClick,
onResolveLink,
}: BuildBaseComponentsOptions): Components {
const makeHeading = (
Tag: 'h1' | 'h2' | 'h3' | 'h4' | 'h5' | 'h6',
headingClassName: string,
) =>
// eslint-disable-next-line @typescript-eslint/no-explicit-any
({ node, children }: any) => {
// PURE LOOKUP — no counters, no render-order dependency. The map is
// built once from the processed source and reaches this renderer via
// context (see ./heading-ids.ts for why not via this options object).
const mapped = useHeadingId(node);
const taken = useAssignedHeadingIds();
// An AUTHORED anchor wins over everything. The sanitize schema allows
// `id` on every element (and disables clobbering) precisely so
// `` keeps its hand-picked anchor; overwriting it
// with the slug of its text silently broke every existing deep link
// pointing at it.
const explicit =
typeof node?.properties?.id === 'string' && node.properties.id !== ''
? node.properties.id
: undefined;
// Fallback for headings the source scan cannot see — in practice only
// caller-plugin-synthesized nodes, which carry no source position.
// Deduped against the ids the map already assigned (pure — see
// resolveFallbackHeadingId).
const id =
explicit ??
mapped ??
resolveFallbackHeadingId(slugifyHeadingText(extractText(children)), taken);
const EffectiveTag = Tag === 'h1' && demoteMarkdownH1ToH2 ? 'h2' : Tag;
return {children};
};
return {
// --- code + blockquote + div (shared leaf renderers, see above) ---
...buildStandardLeafRenderers({ textSizes }),
// --- headings ---
h1: makeHeading('h1', cn('font-sans font-bold mt-8 mb-4 first:mt-0 text-ods-text-primary', textSizes.h1)),
h2: makeHeading('h2', cn('font-sans font-semibold mt-8 mb-4 pb-2 border-b text-ods-text-primary border-ods-border', textSizes.h2)),
h3: makeHeading('h3', cn('font-sans font-semibold mt-6 mb-3 text-ods-text-primary', textSizes.h3)),
h4: makeHeading('h4', cn('font-sans font-semibold mt-4 mb-2 text-ods-text-primary', textSizes.h4)),
h5: makeHeading('h5', cn('font-sans font-semibold mt-3 mb-2 text-ods-text-primary', textSizes.h5)),
h6: makeHeading('h6', cn('font-sans font-semibold mt-3 mb-1 text-ods-text-primary', textSizes.h6)),
// --- paragraph ---
// eslint-disable-next-line @typescript-eslint/no-explicit-any
p: ({ children }: any) => (
{children}
),
// --- links ---
// eslint-disable-next-line @typescript-eslint/no-explicit-any
a: ({ href, children, className: linkClassName }: any) => {
const isBroken = brokenLinks.includes(href);
const isInternalDocLink =
propCurrentPath !== undefined &&
propCurrentPath !== null &&
href &&
!href.startsWith('http') &&
!href.startsWith('#');
if (isBroken) {
return (
{children}
[BROKEN]
);
}
if (isInternalDocLink && onInternalLinkClick) {
const currentPath = propCurrentPath ?? '';
return (
{
e.preventDefault();
e.stopPropagation();
if (onResolveLink) {
try {
const result = await onResolveLink(href, currentPath);
if (result.type === 'folder-no-readme' && result.action === 'expand_folder') {
onInternalLinkClick(result.resolvedPath!, { expandFolder: true, fromInternalLink: true });
} else if (result.type === 'not-found') {
return;
} else if (result.success && result.resolvedPath) {
onInternalLinkClick(result.resolvedPath, { fromInternalLink: true });
}
} catch (error) {
console.error('Error resolving link:', error);
}
} else {
onInternalLinkClick(href, { fromInternalLink: true });
}
}}
role="link"
tabIndex={0}
onKeyDown={(e) => {
if (e.key === 'Enter' || e.key === ' ') {
(e.currentTarget as HTMLElement).click();
}
}}
>
{children}
);
}
// In-page anchor. Doc TOCs are authored against GitHub's slugger
// (`## 📚 Table of Contents` → `#-table-of-contents`) while our heading
// ids trim the emoji's leftover hyphen, so the raw href resolves to
// nothing and the browser silently ignores the click. Resolve through
// `getHashTargetElement` — the same resolver deep links use — then hand
// the real id to the canonical hash-nav helper, which also lands the
// heading BELOW the sticky header instead of under it, matching how the
// "On this page" rail scrolls.
const isInPageAnchor = typeof href === 'string' && href.startsWith('#') && href.length > 1;
const handleAnchorClick = (e: React.MouseEvent) => {
// Let modifier / non-primary clicks keep their native behavior.
if (e.metaKey || e.ctrlKey || e.shiftKey || e.altKey || e.button !== 0) return;
const target = getHashTargetElement(href.slice(1));
// Nothing matched anywhere in the document → leave the browser's
// default alone rather than swallowing a click that isn't ours.
if (!target?.id) return;
e.preventDefault();
navigateSamePageHash(`#${target.id}`, { headerOffset: HUB_HEADER_OFFSET_PX });
};
return (
{children}
);
},
// --- images ---
// Inline content image renderer (blog, docs, chat attachments) —
// delegates to `MarkdownContentImage` (above), which handles bearer-mode
// authed loading for native shells and the 400x400 cap with click-to-
// expand for full resolution.
//
// TODO(security): LLM-rendered surfaces still auto-load ANY image origin,
// so a prompt-injected `` exfiltrates
// silently. An image-origin allowlist is a tracked follow-up: it requires
// a host-supplied origin list threaded from each chat composition (there
// is no safe default this library can pick), so it is deliberately absent
// rather than shipped unwired.
// eslint-disable-next-line @typescript-eslint/no-explicit-any
img: ({ src, alt }: any) => {
if (!hasRenderableSrc(src)) return null;
return ;
},
// --- lists ---
// eslint-disable-next-line @typescript-eslint/no-explicit-any
ul: ({ children }: any) => (
),
// eslint-disable-next-line @typescript-eslint/no-explicit-any
ol: ({ children }: any) => (
{children}
),
// eslint-disable-next-line @typescript-eslint/no-explicit-any
li: ({ children }: any) => (
{children}
),
// --- tables ---
// eslint-disable-next-line @typescript-eslint/no-explicit-any
table: ({ children }: any) => (
),
// eslint-disable-next-line @typescript-eslint/no-explicit-any
thead: ({ children }: any) => (
{children}
),
// eslint-disable-next-line @typescript-eslint/no-explicit-any
th: ({ children }: any) => (
{children}
|
),
// eslint-disable-next-line @typescript-eslint/no-explicit-any
td: ({ children }: any) => (
{children}
|
),
// --- horizontal rule ---
hr: () =>
,
};
}