import { Fragment, useMemo, type ReactElement, type ReactNode } from 'react' import type { Definition, List, ListItem, RootContent, Table } from 'mdast' import { definitionsOf, isLineBreak, linkHref, parseMarkdown } from './markdown' // Markdown drawn from its tree: an answer, or a document in its window. Nothing here becomes HTML from // a string, so neither can inject markup. Links open in the browser through the bridge, since the // frame can open nothing itself; the frame loads no images, so an image is a link to itself. In an // answer a citation, [p2], is drawn by `cite`; in a document the blocks a span of the source covers // are marked, the passage a search found. interface Render { definitions: Map open: (href: string) => void cite?: (ref: string, key: string) => ReactNode } const CITATION = /\[(p\d+)\]/g export function Markdown({ source, onLink, cite, mark, }: { source: string onLink: (href: string) => void cite?: (ref: string, key: string) => ReactNode /** A span of `source`, by character offsets, whose blocks are marked. */ mark?: { start: number; end: number } | null }): ReactElement { const tree = useMemo(() => parseMarkdown(source), [source]) const render: Render = { definitions: definitionsOf(tree), open: onLink, cite } return (
{tree.children.map((node, i) => mark && covers(node, mark) ? (
{block(node, i, render)}
) : ( block(node, i, render) ), )}
) } /** Whether a block of the source lies at least partly in the span. */ function covers(node: RootContent, span: { start: number; end: number }): boolean { const start = node.position?.start.offset const end = node.position?.end.offset return start !== undefined && end !== undefined && start < span.end && end > span.start } function blocks(nodes: RootContent[], render: Render): ReactNode[] { return nodes.map((node, i) => block(node, i, render)) } function block(node: RootContent, key: number, render: Render): ReactNode { switch (node.type) { case 'heading': { const Tag = `h${node.depth}` as const return {inlines(node.children, render)} } case 'paragraph': return

{inlines(node.children, render)}

case 'list': return list(node, key, render) case 'code': return (
          {node.value}
        
) case 'blockquote': return
{blocks(node.children, render)}
case 'table': return table(node, key, render) case 'thematicBreak': return
case 'html': return isLineBreak(node.value) ? (
) : (

{node.value}

) case 'footnoteDefinition': return (
[{node.label ?? node.identifier}]
{blocks(node.children, render)}
) case 'definition': return null default: return inline(node, key, render) } } /** A tight list's items hold their words directly; a loose list's hold paragraphs. */ function list(node: List, key: number, render: Render): ReactNode { const loose = node.spread === true || node.children.some((item) => item.spread === true) const items = node.children.map((item, i) => listItem(item, i, loose, render)) return node.ordered ? (
    {items}
) : ( ) } function listItem(item: ListItem, key: number, loose: boolean, render: Render): ReactNode { const task = typeof item.checked === 'boolean' return (
  • {task && {item.checked ? '☑' : '☐'}} {item.children.map((child, i) => !loose && child.type === 'paragraph' ? ( {inlines(child.children, render)} ) : ( block(child, i, render) ), )}
  • ) } function table(node: Table, key: number, render: Render): ReactNode { const [head, ...rows] = node.children const align = (column: number) => node.align?.[column] ?? undefined return (
    {head && ( {head.children.map((cell, i) => ( ))} )} {rows.map((row, r) => ( {row.children.map((cell, i) => ( ))} ))}
    {inlines(cell.children, render)}
    {inlines(cell.children, render)}
    ) } function inlines(nodes: RootContent[], render: Render): ReactNode[] { return nodes.map((node, i) => inline(node, i, render)) } function inline(node: RootContent, key: number, render: Render): ReactNode { switch (node.type) { case 'text': return render.cite ? cited(node.value, key, render.cite) : node.value case 'emphasis': return {inlines(node.children, render)} case 'strong': return {inlines(node.children, render)} case 'delete': return {inlines(node.children, render)} case 'inlineCode': return {node.value} case 'break': return
    case 'html': return isLineBreak(node.value) ?
    : node.value case 'link': return link(linkHref(node.url), inlines(node.children, render), key, render) case 'linkReference': return link(reference(node.identifier, render), inlines(node.children, render), key, render) case 'image': return image(linkHref(node.url), node.alt, key, render) case 'imageReference': return image(reference(node.identifier, render), node.alt, key, render) case 'footnoteReference': return [{node.label ?? node.identifier}] default: return 'children' in node ? {inlines(node.children, render)} : null } } /** An https link opens in the browser through main; any other reads as its words. */ function link(href: string | null, children: ReactNode, key: number, render: Render): ReactNode { if (!href) return {children} return ( { event.preventDefault() render.open(href) }} > {children} ) } function image(href: string | null, alt: string | null | undefined, key: number, render: Render): ReactNode { const name = alt?.trim() || null return href ? link(href, `Image: ${name ?? href}`, key, render) : name } function reference(identifier: string, render: Render): string | null { const definition = render.definitions.get(identifier) return definition ? linkHref(definition.url) : null } /** Text with its citations drawn as markers. */ function cited(text: string, key: number, cite: (ref: string, key: string) => ReactNode): ReactNode { const parts: ReactNode[] = [] let last = 0 for (const match of text.matchAll(CITATION)) { if (match.index > last) parts.push(text.slice(last, match.index)) parts.push(cite(match[1]!, `${key}-${match.index}`)) last = match.index + match[0].length } if (last === 0) return text parts.push(text.slice(last)) return {parts} }