import React, { useState, useRef, useCallback, useEffect, useMemo } from "react"; import { createPortal } from "react-dom"; import { isCodeFilePath, isCodeFilePathStrict, CODE_PATH_BARE_REGEX, parseCodePath } from "@plannotator/core/code-file"; import { forgeRefLinks } from "@plannotator/core/forge-refs"; import { ensureHighlight, highlightToHtml } from "../utils/codeHighlight"; import { useFenceTheme } from "../hooks/useFenceTheme"; import { transformPlainText } from "../utils/inlineTransforms"; import { hasLinkedDocExtension } from "../utils/markdownExtensions"; import { getImageSrc } from "./ImageThumbnail"; import { UNAVAILABLE_IMAGE_CLASS, useImageResolver } from "./ImageResolverContext"; import { useCodePathValidation, type CodePathValidationContextValue } from "./CodePathValidationContext"; import { isLocalLinkTarget, isNewTabClick, useLocalLinkResolver } from "./LocalLinkContext"; import type { ValidationEntry } from "../hooks/useValidatedCodePaths"; import { CodeFilePicker } from "./CodeFilePicker"; import { normalizeMathTex, renderMathToHtml } from "../utils/math"; import { useMathRenderer } from "../hooks/useMathRenderer"; import { HostTitle } from "./HostTitle"; export interface DocPreviewResult { contents?: string; filepath?: string; } export type DocPreviewFetcher = (path: string, base?: string) => Promise; /** * Default code-file hover-preview fetcher — Plannotator's `/api/doc` behavior, verbatim. */ const defaultDocPreviewFetcher: DocPreviewFetcher = async (path, base) => { const params = new URLSearchParams({ path }); if (base) params.set('base', base); const res = await fetch(`/api/doc?${params}`); return await res.json(); }; // Module-level fetcher, stable identity. Defaults to Plannotator's `/api/doc`. // A host (e.g. Workspaces) calls setDocPreviewFetcher once at startup to load // hover previews from its own backend. let docPreviewFetcher: DocPreviewFetcher = defaultDocPreviewFetcher; /** Override how code-file hover previews are fetched. Call once at app startup. */ export const setDocPreviewFetcher = (fetcher: DocPreviewFetcher): void => { docPreviewFetcher = fetcher; }; /** Reset to the default (Plannotator `/api/doc`) fetcher. Mainly for tests. */ export const resetDocPreviewFetcher = (): void => { docPreviewFetcher = defaultDocPreviewFetcher; }; /** Read the active fetcher at call time (so a late override is honored). */ export const getDocPreviewFetcher = (): DocPreviewFetcher => docPreviewFetcher; /** * Decide how a candidate code-file path should render based on validation state: * - 'link' → clickable, opens directly via onOpenCodeFile(resolvedOrInput) * - 'ambiguous-link' → clickable, opens a picker over `matches` * - 'plain' → not a link (file does not exist anywhere in the repo) * * No provider or `ready: false` falls back to optimistic 'link' behavior. */ function gateCodePath( candidate: string, validation: CodePathValidationContextValue | null, ): { render: 'link'; resolved?: string } | { render: 'ambiguous-link'; matches: string[] } | { render: 'plain' } { if (!validation || !validation.ready) return { render: 'link' }; const entry = validation.validated.get(candidate); // If the validator is ready but has no entry for this candidate, the // extractor intentionally excluded it (e.g., inside an HTML comment or // fenced code block). Demote rather than optimistically linking. if (!entry) return { render: 'plain' }; switch ((entry as ValidationEntry).status) { case 'found': return { render: 'link', resolved: (entry as Extract).resolved }; case 'ambiguous': return { render: 'ambiguous-link', matches: (entry as Extract).matches }; case 'unavailable': return { render: 'link' }; case 'missing': return { render: 'plain' }; default: return { render: 'link' }; // unknown status — degrade to optimistic } } function extToLanguage(filepath: string): string | undefined { const ext = filepath.split('.').pop()?.toLowerCase(); const map: Record = { ts: 'typescript', tsx: 'typescript', js: 'javascript', jsx: 'javascript', py: 'python', rb: 'ruby', rs: 'rust', go: 'go', java: 'java', css: 'css', scss: 'scss', json: 'json', yml: 'yaml', yaml: 'yaml', sql: 'sql', sh: 'bash', bash: 'bash', zsh: 'bash', md: 'markdown', html: 'html', xml: 'xml', toml: 'toml', swift: 'swift', kt: 'kotlin', }; return ext ? map[ext] : undefined; } const CodeSnippetPreview: React.FC<{ anchorEl: HTMLElement | null; contents: string; filepath: string; line: number; lineEnd?: number; onMouseEnter?: () => void; onMouseLeave?: () => void; }> = ({ anchorEl, contents, filepath, line, lineEnd, onMouseEnter, onMouseLeave }) => { const allLines = contents.split('\n'); const start = Math.max(0, line - 1); const end = Math.min(allLines.length, (lineEnd ?? line)); const snippet = allLines.slice(start, end).join('\n'); const fenceTheme = useFenceTheme(); // Bumped once the grammar is attached, to re-run the memo below with the // highlighter warm. Until then the snippet renders as plain text — no // auto-detection, and an unknown extension simply stays plain. const [highlighterGeneration, setHighlighterGeneration] = useState(0); const lang = extToLanguage(filepath); useEffect(() => { if (!lang) return; let cancelled = false; void ensureHighlight(lang, fenceTheme).then((ok) => { if (ok && !cancelled) setHighlighterGeneration((n) => n + 1); }); return () => { cancelled = true; }; }, [lang, fenceTheme]); const highlightedLines = useMemo(() => { // Highlight the snippet as one unit so multi-line constructs (block // comments, template literals) tokenise correctly, then split back into // rows: the highlighter joins lines with "\n" and never emits one inside a // span, so the split is exact. const html = lang ? highlightToHtml(snippet, lang, fenceTheme) : null; if (html !== null) return html.split('\n'); return snippet.split('\n').map(line => line.replace(/&/g, '&').replace(//g, '>')); // highlighterGeneration is the "grammar just became available" signal. // eslint-disable-next-line react-hooks/exhaustive-deps }, [snippet, lang, fenceTheme, highlighterGeneration]); if (!anchorEl) return null; const rect = anchorEl.getBoundingClientRect(); const spaceBelow = window.innerHeight - rect.bottom; const showAbove = spaceBelow < 200 && rect.top > spaceBelow; const top = showAbove ? undefined : rect.bottom + 4; const bottom = showAbove ? window.innerHeight - rect.top + 4 : undefined; const left = Math.max(8, Math.min(rect.left, window.innerWidth - 520)); return createPortal(
{filepath.split('/').pop()} {lineEnd && lineEnd !== line ? `lines ${line}–${lineEnd}` : `line ${line}`}
{snippet.split('\n').map((_, i) => ( ))}
{start + i + 1}
, document.body, ); }; const CodeFileLink: React.FC<{ candidate: string; display: string; onOpenCodeFile: (path: string) => void; baseDir?: string; }> = ({ candidate, display, onOpenCodeFile, baseDir }) => { const validation = useCodePathValidation(); const gate = gateCodePath(candidate, validation); const [pickerOpen, setPickerOpen] = useState(false); const [hoverPreview, setHoverPreview] = useState<{ contents: string; filepath: string } | null>(null); const hoverPreviewRef = useRef(hoverPreview); hoverPreviewRef.current = hoverPreview; const anchorRef = useRef(null); const showTimerRef = useRef | null>(null); const hideTimerRef = useRef | null>(null); const parsed = parseCodePath(candidate); const hasLineRef = parsed.line != null; const cancelHide = useCallback(() => { if (hideTimerRef.current) { clearTimeout(hideTimerRef.current); hideTimerRef.current = null; } }, []); const scheduleHide = useCallback(() => { cancelHide(); hideTimerRef.current = setTimeout(() => { setHoverPreview(null); }, 200); }, [cancelHide]); const handleMouseEnter = useCallback(() => { if (!hasLineRef || gate.render === 'plain') return; cancelHide(); if (hoverPreviewRef.current) return; showTimerRef.current = setTimeout(async () => { try { const data = await docPreviewFetcher(candidate, baseDir); if (data?.contents) setHoverPreview({ contents: data.contents, filepath: data.filepath ?? candidate }); } catch {} }, 150); }, [candidate, hasLineRef, gate.render, cancelHide, baseDir]); const handleMouseLeave = useCallback(() => { if (showTimerRef.current) { clearTimeout(showTimerRef.current); showTimerRef.current = null; } scheduleHide(); }, [scheduleHide]); const handlePreviewEnter = useCallback(() => { cancelHide(); }, [cancelHide]); const handlePreviewLeave = useCallback(() => { scheduleHide(); }, [scheduleHide]); useEffect(() => { return () => { if (showTimerRef.current) clearTimeout(showTimerRef.current); if (hideTimerRef.current) clearTimeout(hideTimerRef.current); }; }, []); if (gate.render === 'plain') { return ( {display} ); } const isAmbiguous = gate.render === 'ambiguous-link'; const lineSuffix = parsed.line != null ? `:${parsed.line}${parsed.lineEnd != null ? `-${parsed.lineEnd}` : ''}` : ''; const handleClick = () => { handleMouseLeave(); if (isAmbiguous) { setPickerOpen(true); return; } const resolvedPath = gate.render === 'link' && gate.resolved ? gate.resolved : candidate; onOpenCodeFile(gate.render === 'link' && gate.resolved ? resolvedPath + lineSuffix : candidate); }; return ( <> { anchorRef.current = el; }} role="button" tabIndex={0} data-ambiguous={isAmbiguous ? "true" : undefined} onClick={handleClick} onKeyDown={(e) => { if (e.key === 'Enter' || e.key === ' ') { e.preventDefault(); handleClick(); } }} onMouseEnter={handleMouseEnter} onMouseLeave={handleMouseLeave} className="code-file-link px-1.5 py-0.5 rounded bg-muted text-sm font-mono cursor-pointer hover:text-primary inline-flex items-center gap-1 transition-colors" title={isAmbiguous ? `${display} — multiple matches` : `View: ${display}`} > {display} {isAmbiguous && ( {(gate as { matches: string[] }).matches.length} )} {hoverPreview && hasLineRef && ( )} {pickerOpen && isAmbiguous && ( { setPickerOpen(false); onOpenCodeFile(path + lineSuffix); }} onDismiss={() => setPickerOpen(false)} /> )} ); }; const DANGEROUS_PROTOCOL = /^\s*(javascript|data|vbscript|file)\s*:/i; function sanitizeLinkUrl(url: string): string | null { if (DANGEROUS_PROTOCOL.test(url)) return null; return url; } const CodeFileIcon = () => ( ); const InlineMath: React.FC<{ tex: string }> = ({ tex }) => { const normalizedTex = normalizeMathTex(tex); const renderer = useMathRenderer(); const html = useMemo(() => renderMathToHtml(normalizedTex, false, renderer), [normalizedTex, renderer]); // Same wrapper in both branches (see MathBlock): the placeholder carries the // attributes annotation restore keys on, with the TeX as a text child. if (html === null) { return ( {normalizedTex} ); } return ( ); }; function canRenderSpacedDollarMath(tex: string): boolean { const trimmed = normalizeMathTex(tex); if (!trimmed) return false; if (!/^\s|\s$/.test(tex)) return true; return ( /\\[a-zA-Z]+/.test(trimmed) || /[=^_{}+\-*/<>|]/.test(trimmed) || /^[a-zA-Z]$/.test(trimmed) ); } // Trim trailing sentence punctuation from a bare URL, but keep closing // brackets when they balance an opener inside the URL (Wikipedia-style // https://…/Function_(mathematics) should keep its closing paren). export function trimUrlTail(url: string): string { const balanced = (u: string, close: string, open: string): boolean => { let opens = 0, closes = 0; for (const c of u) { if (c === open) opens++; else if (c === close) closes++; } return opens >= closes; }; while (url.length > 0) { const last = url[url.length - 1]; if (!/[.,;:!?)\]}>"']/.test(last)) break; if (last === ')' && balanced(url, ')', '(')) break; if (last === ']' && balanced(url, ']', '[')) break; if (last === '}' && balanced(url, '}', '{')) break; url = url.slice(0, -1); } return url; } // Scan a plain-text chunk for bare https?:// URLs and bare code file paths // at word boundaries, emitting them as interactive nodes. Surrounding text // passes through transformPlainText for emoji shortcodes + smart punctuation. function emitPlainTextWithBareUrls( text: string, previousChar: string, parts: React.ReactNode[], nextKey: () => number, onOpenCodeFile?: (path: string) => void, validation?: CodePathValidationContextValue | null, baseDir?: string, ): void { if (text.length === 0) return; type Span = { start: number; end: number; kind: 'url'; value: string } | { start: number; end: number; kind: 'path'; value: string }; const spans: Span[] = []; // Collect bare URLs const urlRe = /https?:\/\/[^\s<>"']+/g; let m: RegExpExecArray | null; while ((m = urlRe.exec(text)) !== null) { const before = m.index === 0 ? previousChar : text[m.index - 1]; if (/\w/.test(before)) continue; const url = trimUrlTail(m[0]); const safe = url.length > 0 ? sanitizeLinkUrl(url) : null; if (!safe) continue; spans.push({ start: m.index, end: m.index + url.length, kind: 'url', value: url }); urlRe.lastIndex = m.index + url.length; } // Collect bare code file paths (require /) if (onOpenCodeFile) { const pathRe = new RegExp(CODE_PATH_BARE_REGEX.source, 'g'); while ((m = pathRe.exec(text)) !== null) { const before = m.index === 0 ? previousChar : text[m.index - 1]; if (/\w/.test(before)) continue; const candidate = m[0]; if (!isCodeFilePathStrict(candidate)) continue; const overlaps = spans.some(s => m!.index < s.end && m!.index + candidate.length > s.start); if (overlaps) continue; spans.push({ start: m.index, end: m.index + candidate.length, kind: 'path', value: candidate }); } } if (spans.length === 0) { parts.push(transformPlainText(text)); return; } spans.sort((a, b) => a.start - b.start); let last = 0; for (const span of spans) { if (span.start > last) { parts.push(transformPlainText(text.slice(last, span.start))); } if (span.kind === 'url') { parts.push( {span.value} , ); } else { const cleanPath = span.value.replace(/#.*$/, ''); const gate = gateCodePath(cleanPath, validation ?? null); if (gate.render === 'plain') { // Bare prose, file doesn't exist — emit as plain text, no link styling. parts.push(transformPlainText(span.value)); } else { parts.push( , ); } } last = span.end; } if (last < text.length) { parts.push(transformPlainText(text.slice(last))); } } /** An image a host resolver chose; a load failure shows the placeholder. */ const HostResolvedImage: React.FC<{ src: string; alt: string; onImageClick?: (src: string, alt: string) => void; }> = ({ src, alt, onImageClick }) => { const [failedSrc, setFailedSrc] = useState(null); if (failedSrc === src) { return ( {alt || 'Image'} ); } return ( {alt} setFailedSrc(src)} onClick={(e) => { e.stopPropagation(); onImageClick?.(src, alt); }} /> ); }; /** * Scanner that walks a text string and emits React nodes for inline markdown: * emphasis (**bold**, *italic*, _italic_, ***both***), `code`, ~~strikethrough~~, * [label](url) / ![alt](src) / , bare https:// URLs, [[wiki-links]], * inline math ($...$), hex color swatches (#fff / #123abc), @mentions, #issue-refs, and backslash * escapes. Plain-text chunks outside these patterns pass through * `transformPlainText` for emoji shortcodes + smart punctuation. */ export const InlineMarkdown: React.FC<{ text: string; onOpenLinkedDoc?: (path: string) => void; /** * SYNCHRONOUS wiki-link resolution (host-backed, e.g. an in-memory cache). * Called with the RAW stored target of a `[[target]]` / `[[target|label]]` * wiki-link — before any path normalization (no `.md` appended), so opaque * ids like `doc_01XYZ` arrive verbatim. * * Absent, or returning null/undefined → exactly today's rendering (stored * label, live link). `label` → displayed instead of the stored label (the * stored label is the fallback, the target the last resort). `status: * 'deleted'` → a muted, non-interactive span (no anchor, no link icon, * `onOpenLinkedDoc` is NOT wired) titled "Document deleted". * * Deliberately sync-only: no async variant, no loading states. Back it * with a cache you keep hydrated. */ resolveLinkedDoc?: (target: string) => { label?: string; status?: 'active' | 'deleted' } | null; onOpenCodeFile?: (path: string) => void; onNavigateAnchor?: (hash: string) => void; imageBaseDir?: string; onImageClick?: (src: string, alt: string) => void; /** Repo path (`owner/repo`, `group/sub/project`) bare `#123` / `@user` refs link into. */ githubRepo?: string; /** Host of that repo's git remote; picks the forge refs link to. Absent means github.com. */ repoHost?: string; }> = ({ text, onOpenLinkedDoc, resolveLinkedDoc, onOpenCodeFile, onNavigateAnchor, imageBaseDir, onImageClick, githubRepo, repoHost }) => { const validation = useCodePathValidation(); const resolveLocalLink = useLocalLinkResolver(); const resolveImage = useImageResolver(); const refLinks = forgeRefLinks({ display: githubRepo, host: repoHost }); const parts: React.ReactNode[] = []; let remaining = text; let key = 0; let previousChar = ""; while (remaining.length > 0) { // HTML comments: skip entirely — they should be invisible per CommonMark. // The parser doesn't recognize as a block-level construct, so // comments inside paragraphs land here. Without this, path detection // would linkify paths inside comments. let match = remaining.match(/^/); if (match) { remaining = remaining.slice(match[0].length); previousChar = ">"; continue; } // Inline math: \( ... \) or $...$ — require non-space content and avoid // display fences. if (remaining.startsWith('\\(')) { let end = -1; for (let i = 2; i < remaining.length - 1; i++) { if (remaining[i] === '\\' && remaining[i + 1] === ')') { if (i > 0 && remaining[i - 1] === '\\') continue; end = i; break; } } if (end > 1) { const tex = remaining.slice(2, end); if (tex.trim()) { parts.push(); remaining = remaining.slice(end + 2); previousChar = ')'; continue; } } } // Backslash escaping: \. \* \_ \` \[ \~ \$ etc. — emit literal char, hide backslash match = remaining.match(/^\\([\\*_`\[\]~!.()\-#>+|{}&$])/); if (match) { parts.push(match[1]); remaining = remaining.slice(2); previousChar = match[1]; continue; } // Inline math: $...$ — require non-space content and avoid $$ display fences. if (remaining.startsWith('$') && previousChar !== '$' && !remaining.startsWith('$$')) { let end = -1; for (let i = 1; i < remaining.length; i++) { if (remaining[i] === '\\') { i++; continue; } if (remaining[i] === '$') { end = i; break; } } if (end > 1) { const tex = remaining.slice(1, end); // Money guard: `$A$B` where a digit immediately follows the closing `$` // is almost always two currency amounts ("$5-$10", "$50,000-$100,000", // "$5/mo and tier B is $10/mo"), not inline math — real inline math never // abuts a digit across the closing delimiter. Leave such text literal. const afterClose = remaining[end + 1]; const looksLikeMoney = /^\s*\d/.test(tex) && afterClose !== undefined && /\d/.test(afterClose); if (!looksLikeMoney && canRenderSpacedDollarMath(tex)) { parts.push(); remaining = remaining.slice(end + 1); previousChar = '$'; continue; } } } // Bare URL autolink: https://… preceded by word boundary. // Trailing sentence punctuation is trimmed so "See https://x.com." // renders the period outside the link. Closing brackets are kept when // they balance an earlier opener inside the URL (e.g. Wikipedia's // https://…/Function_(mathematics) keeps its trailing paren). if (!/\w/.test(previousChar)) { const bareMatch = remaining.match(/^https?:\/\/[^\s<>"']+/); if (bareMatch) { const url = trimUrlTail(bareMatch[0]); const safe = url.length > 0 ? sanitizeLinkUrl(url) : null; if (safe) { parts.push( {url} , ); remaining = remaining.slice(url.length); previousChar = url[url.length - 1]; continue; } } } // Autolinks: or match = remaining.match(/^<(https?:\/\/[^>]+)>/); if (match) { const url = match[1]; parts.push( {url} , ); remaining = remaining.slice(match[0].length); previousChar = ">"; continue; } match = remaining.match(/^<([^@>\s]+@[^>\s]+)>/); if (match) { const email = match[1]; parts.push( {email} , ); remaining = remaining.slice(match[0].length); previousChar = ">"; continue; } // Keep custom autolinks literal; their contents should not // recurse into markdown parsing. match = remaining.match(/^<([A-Za-z][A-Za-z0-9.+-]{0,31}:[^\s<>]*)>/); if (match) { const content = match[1]; parts.push({`<${content}>`}); remaining = remaining.slice(match[0].length); previousChar = ">"; continue; } // Strikethrough: ~~text~~ match = remaining.match(/^~~([\s\S]+?)~~/); if (match) { parts.push( , ); remaining = remaining.slice(match[0].length); previousChar = match[0][match[0].length - 1] || previousChar; continue; } // Bold + italic: ***text*** match = remaining.match(/^\*\*\*([\s\S]+?)\*\*\*/); if (match) { parts.push( , ); remaining = remaining.slice(match[0].length); previousChar = match[0][match[0].length - 1] || previousChar; continue; } // Bold: **text** ([\s\S]+? allows matching across hard line breaks) match = remaining.match(/^\*\*([\s\S]+?)\*\*/); if (match) { parts.push( , ); remaining = remaining.slice(match[0].length); previousChar = match[0][match[0].length - 1] || previousChar; continue; } // Italic: *text* or _text_ (avoid intraword underscores) match = remaining.match(/^\*([\s\S]+?)\*/); if (match) { parts.push( , ); remaining = remaining.slice(match[0].length); previousChar = match[0][match[0].length - 1] || previousChar; continue; } match = !/\w/.test(previousChar) ? remaining.match(/^_([^_\s](?:[\s\S]*?[^_\s])?)_(?!\w)/) : null; if (match) { parts.push( , ); remaining = remaining.slice(match[0].length); previousChar = match[0][match[0].length - 1] || previousChar; continue; } // Inline code: `code` — when the content is a code file path, render as clickable match = remaining.match(/^`([^`]+)`/); if (match) { const codeContent = match[1]; if (isCodeFilePath(codeContent) && onOpenCodeFile) { const cleanPath = codeContent.replace(/#.*$/, ''); parts.push( , ); } else { parts.push( {codeContent} , ); } remaining = remaining.slice(match[0].length); previousChar = match[0][match[0].length - 1] || previousChar; continue; } // Hex color swatch — 3/4-digit forms need an a-f letter to avoid matching issue refs like #123. match = remaining.match( /^(#(?:[0-9a-fA-F]{8}|[0-9a-fA-F]{6}|(?=[0-9a-fA-F]*[a-fA-F])[0-9a-fA-F]{4}|(?=[0-9a-fA-F]*[a-fA-F])[0-9a-fA-F]{3}))(?![0-9a-fA-F\w])/, ); if (match) { const hex = match[1]; parts.push( {hex} , ); remaining = remaining.slice(match[0].length); previousChar = match[0][match[0].length - 1] || previousChar; continue; } // Issue / PR reference: #123 — only at word boundary, digits only. // Hex-swatch branch above already consumed #fff / #123abc etc., so a bare // #\d+ here is safe to treat as an issue ref. if (!/\w/.test(previousChar)) { match = remaining.match(/^#(\d+)(?!\w)/); if (match) { const num = match[1]; const href = refLinks?.issue(num) ?? null; const label = `#${num}`; parts.push( href ? ( {label} ) : ( {label} ), ); remaining = remaining.slice(match[0].length); previousChar = match[0][match[0].length - 1]; continue; } } // @mention — only at word boundary. GitHub-style handle pattern. if (!/\w/.test(previousChar)) { match = remaining.match(/^@([a-zA-Z][a-zA-Z0-9_-]{0,38})(?!\w)/); if (match) { const handle = match[1]; const href = refLinks?.user(handle) ?? null; const label = `@${handle}`; parts.push( href ? ( {label} ) : ( {label} ), ); remaining = remaining.slice(match[0].length); previousChar = match[0][match[0].length - 1]; continue; } } // Wikilinks: [[filename]] or [[filename|display text]] match = remaining.match(/^\[\[([^\]|]+)(?:\|([^\]]+))?\]\]/); if (match) { const target = match[1].trim(); const storedLabel = match[2]?.trim(); // Host resolution gets the RAW target (pre-`.md` normalization): host // targets are opaque doc ids, not paths. null → today's rendering. const resolution = resolveLinkedDoc?.(target) ?? null; const display = resolution?.label || storedLabel || target; const targetPath = hasLinkedDocExtension(target) ? target : `${target}.md`; if (resolution?.status === 'deleted') { // Deleted doc: muted NON-link — no anchor, no pointer, no link icon — // even when onOpenLinkedDoc is present. parts.push( {display} , ); } else if (onOpenLinkedDoc) { parts.push( { e.preventDefault(); onOpenLinkedDoc(targetPath); }} className="text-primary underline underline-offset-2 hover:text-primary/80 inline-flex items-center gap-1 cursor-pointer" title={`Open: ${target}`} > {display} , ); } else { parts.push( {display} , ); } remaining = remaining.slice(match[0].length); previousChar = match[0][match[0].length - 1] || previousChar; continue; } // Images: ![alt](path) match = remaining.match(/^!\[([^\]]*)\]\(([^)]+)\)/); if (match) { const alt = match[1]; const src = match[2]; // A host's resolver (RenderedMarkdown `resolveImage`) decides first; // null from it, or no resolver, keeps the default resolution. const resolvedImage = resolveImage ? resolveImage(src) : null; if (resolvedImage?.kind === 'unavailable') { parts.push( {alt || 'Image'} , ); remaining = remaining.slice(match[0].length); previousChar = match[0][match[0].length - 1] || previousChar; continue; } const imgSrc = resolvedImage?.kind === 'src' ? resolvedImage.src : /^(https?:\/\/|data:|blob:)/i.test(src) ? src : getImageSrc(src, imageBaseDir); parts.push( resolveImage ? ( // With a host resolver, an image that fails to load becomes the // same placeholder instead of a broken-image icon. ) : ( {alt} { e.stopPropagation(); onImageClick?.(imgSrc, alt); }} /> ), ); remaining = remaining.slice(match[0].length); previousChar = match[0][match[0].length - 1] || previousChar; continue; } // Links: [text](url) — url may contain balanced parens (e.g. Wikipedia // /Function_(mathematics)). Plain `[^)]+` would truncate at the first // inner close-paren, so we scan the destination manually tracking depth. const linkParsed = (() => { if (remaining[0] !== '[') return null; let i = 1; let depth = 1; while (i < remaining.length && depth > 0) { const ch = remaining[i]; if (ch === '\\' && i + 1 < remaining.length) { i += 2; continue; } if (ch === '[') depth++; else if (ch === ']') depth--; if (depth === 0) break; i++; } if (depth !== 0 || remaining[i + 1] !== '(') return null; const textEnd = i; let j = i + 2; let parenDepth = 1; while (j < remaining.length && parenDepth > 0) { const ch = remaining[j]; if (ch === '\\' && j + 1 < remaining.length) { j += 2; continue; } if (ch === '(') parenDepth++; else if (ch === ')') { parenDepth--; if (parenDepth === 0) break; } else if (ch === '\n') return null; j++; } if (parenDepth !== 0) return null; const linkText = remaining.slice(1, textEnd); const linkUrl = remaining.slice(i + 2, j); if (!linkText || !linkUrl) return null; return { linkText, linkUrl, consumed: j + 1 }; })(); if (linkParsed) { const { linkText, linkUrl, consumed } = linkParsed; const safeLinkUrl = sanitizeLinkUrl(linkUrl); // Dangerous protocol stripped — render as plain text, not a dead link if (safeLinkUrl === null) { parts.push({linkText}); remaining = remaining.slice(consumed); previousChar = ')'; continue; } // Local doc: .md / .mdx / .txt / .html / .htm, optionally with #fragment. // Fragment is stripped before handing to onOpenLinkedDoc (overlay has // no anchor-scroll support today). const isLocalDoc = hasLinkedDocExtension(linkUrl, { allowFragment: true }) && !linkUrl.startsWith("http://") && !linkUrl.startsWith("https://"); const isCodeFile = !isLocalDoc && isCodeFilePath(linkUrl); const linkedDocPath = isLocalDoc ? linkUrl.replace(/#.*$/, '') : linkUrl; const codeFilePath = isCodeFile ? linkUrl.replace(/#.*$/, '') : linkUrl; const isInPageAnchor = safeLinkUrl.startsWith('#'); // A host's resolver (Viewer `resolveLocalLink`) decides local paths // first; null from it, or no resolver, keeps the branches below. const localLink = resolveLocalLink && isLocalLinkTarget(linkUrl) ? resolveLocalLink(linkUrl) : null; if (localLink?.kind === 'open') { const openLink = localLink.open; const hostHref = localLink.href; parts.push( { // With the host's own address, a new-tab click goes there. if (hostHref && isNewTabClick(e)) return; e.preventDefault(); e.stopPropagation(); openLink(); }} // Without it, a middle click would open the path on the host's own origin. onAuxClick={hostHref ? undefined : (e) => e.preventDefault()} className="text-primary underline underline-offset-2 hover:text-primary/80 inline-flex items-center gap-1 cursor-pointer" data-local-link="open" > {linkText} , ); } else if (localLink?.kind === 'unavailable') { parts.push( {linkText} , ); } else if (isInPageAnchor) { parts.push( { e.preventDefault(); onNavigateAnchor(safeLinkUrl); } : undefined} className="text-primary underline underline-offset-2 hover:text-primary/80" > {linkText} , ); } else if (isLocalDoc && onOpenLinkedDoc) { parts.push( { e.preventDefault(); onOpenLinkedDoc(linkedDocPath); }} className="text-primary underline underline-offset-2 hover:text-primary/80 inline-flex items-center gap-1 cursor-pointer" title={`Open: ${linkUrl}`} > {linkText} , ); } else if (isCodeFile && onOpenCodeFile) { parts.push( { e.preventDefault(); onOpenCodeFile(codeFilePath); }} className="text-primary underline underline-offset-2 hover:text-primary/80 inline-flex items-center gap-1 cursor-pointer" title={`View: ${linkUrl}`} > {linkText} , ); } else if (isLocalDoc) { // No handler — render as plain link (e.g., in shared/portal views) parts.push( {linkText} , ); } else { parts.push( {linkText} , ); } remaining = remaining.slice(consumed); previousChar = ')'; continue; } // Hard line break: two+ trailing spaces + newline, or backslash + newline match = remaining.match(/ {2,}\n|\\\n/); if (match && match.index !== undefined) { const before = remaining.slice(0, match.index); if (before) { parts.push( , ); } parts.push(
); remaining = remaining.slice(match.index + match[0].length); previousChar = "\n"; continue; } // Find next special character or consume one regular character. // `h` is intentionally NOT in this class — plain-text chunks may contain // `h` mid-word (e.g. ":heart:", "hello"), and splitting on it breaks // multi-char patterns like emoji shortcodes. Bare URLs are instead // detected inline via emitPlainTextWithBareUrls() below. const nextSpecial = remaining.slice(1).search(/[\*_`\[!~\\<#@$]/); const plainText = nextSpecial === -1 ? remaining : remaining.slice(0, nextSpecial + 1); emitPlainTextWithBareUrls(plainText, previousChar, parts, () => key++, onOpenCodeFile, validation, imageBaseDir); previousChar = plainText[plainText.length - 1] || previousChar; if (nextSpecial === -1) { break; } remaining = remaining.slice(nextSpecial + 1); } return <>{parts}; };