disclosure-bureau/web/components/markdown-body.tsx

142 lines
5.6 KiB
TypeScript
Raw Normal View History

"use client";
/**
* Renders Disclosure Bureau markdown including wiki-links `[[id]]`
* as semantic HTML with clickable navigation.
*
* Wiki-link resolution rules (matches CLAUDE.md §7):
* [[doc-id/pNNN]] /d/<doc-id>/pNNN
* [[people/<id>]] /e/people/<id>
* [[org/<id>]] /e/organizations/<id>
* [[loc/<id>]] /e/locations/<id>
* [[event/<id>]] /e/events/<id>
* [[uap/<id>]] /e/uap-objects/<id>
* [[vehicle/<id>]] /e/vehicles/<id>
* [[op/<id>]] /e/operations/<id>
* [[concept/<id>]] /e/concepts/<id>
* [[table/<id>]] /t/<id>
* [[image/<id>]] /i/<id>
* [[<doc-id>]] /d/<doc-id>
* [[X|alias]] same target, display text = alias
*/
import Link from "next/link";
import ReactMarkdown from "react-markdown";
import remarkGfm from "remark-gfm";
import { InlineCitation } from "./inline-citation";
import type { Components } from "react-markdown";
const CLASS_MAP: Record<string, string> = {
people: "people",
person: "people",
org: "organizations",
organization: "organizations",
organizations: "organizations",
loc: "locations",
location: "locations",
locations: "locations",
event: "events",
events: "events",
uap: "uap-objects",
"uap-object": "uap-objects",
"uap-objects": "uap-objects",
vehicle: "vehicles",
vehicles: "vehicles",
op: "operations",
operation: "operations",
operations: "operations",
concept: "concepts",
concepts: "concepts",
};
function resolveWikiLink(target: string): { href: string; entityClass?: string } {
const t = target.trim();
// chunk anchor — [[doc-id/p007#c0042]] → /d/<doc>#c0042 (V2 page hosts the anchors)
const chunkM = t.match(/^([A-Za-z0-9._-]+)\/(p\d{3})#(c\d{4})$/);
if (chunkM) return { href: `/d/${chunkM[1]}#${chunkM[3]}` };
// alt form: [[doc-id#c0042]]
const altChunkM = t.match(/^([A-Za-z0-9._-]+)#(c\d{4})$/);
if (altChunkM) return { href: `/d/${altChunkM[1]}#${altChunkM[2]}` };
// table/<id>, image/<id>
if (t.startsWith("table/")) return { href: `/t/${t.slice(6)}` };
if (t.startsWith("image/")) return { href: `/i/${t.slice(6)}` };
// entity link <class>/<id>
const m = t.match(/^([a-z-]+)\/([A-Za-z0-9._-]+)$/);
if (m) {
const cls = CLASS_MAP[m[1]];
if (cls) return { href: `/e/${cls}/${m[2]}`, entityClass: cls };
// doc-id/pNNN
if (/^p\d{3}$/.test(m[2])) return { href: `/d/${m[1]}/${m[2]}` };
}
// bare doc-id
return { href: `/d/${t}` };
}
/**
* Pre-process raw markdown to convert `[[wiki|alias]]` and `[[wiki]]` syntax
* into standard markdown link `[alias](/path)`. Simpler + more robust than
* adding a remark plugin.
*/
function preprocessWikiLinks(md: string): string {
// Allow `#anchor` inside the target part: [[doc-id/p007#c0042]]
return md.replace(/\[\[([^\]|]+?)(?:\|([^\]]+))?\]\]/g, (_full, target: string, alias?: string) => {
const { href } = resolveWikiLink(target);
const label = (alias ?? target).trim();
return `[${label}](dbw:${href})`;
});
}
const CHUNK_HREF_RE = /^\/d\/([A-Za-z0-9._-]+)\#(c\d{4})$/;
const components: Components = {
a({ href, children, ...rest }) {
const h = href ?? "";
if (h.startsWith("dbw:")) {
const real = h.slice(4);
// Chunk-anchor link → render as rich inline citation with bbox crop
const chunkM = real.match(CHUNK_HREF_RE);
if (chunkM) {
const label = typeof children === "string" ? children : undefined;
return <InlineCitation docId={chunkM[1]} chunkId={chunkM[2]} label={label} />;
}
const target = real.split("/")[1] ?? real;
// Pick a color class based on the URL prefix
const cls =
real.startsWith("/e/people") ? "wiki-link wiki-link--person" :
real.startsWith("/e/organizations") ? "wiki-link wiki-link--org" :
real.startsWith("/e/locations") ? "wiki-link wiki-link--loc" :
real.startsWith("/e/events") ? "wiki-link wiki-link--event" :
real.startsWith("/e/uap-objects") ? "wiki-link wiki-link--uap" :
real.startsWith("/e/vehicles") ? "wiki-link wiki-link--vehicle" :
real.startsWith("/e/operations") ? "wiki-link wiki-link--operation" :
real.startsWith("/e/concepts") ? "wiki-link wiki-link--concept" :
real.startsWith("/d/") ? "wiki-link wiki-link--doc" :
"wiki-link";
return <Link href={real} className={cls} title={target}>{children}</Link>;
}
return <a href={h} target="_blank" rel="noopener noreferrer" {...rest}>{children}</a>;
},
h1: ({ children }) => <h1 className="md-h1">{children}</h1>,
h2: ({ children }) => <h2 className="md-h2">{children}</h2>,
h3: ({ children }) => <h3 className="md-h3">{children}</h3>,
h4: ({ children }) => <h4 className="md-h4">{children}</h4>,
blockquote: ({ children }) => <blockquote className="md-quote">{children}</blockquote>,
table: ({ children }) => <div className="md-table-wrap"><table>{children}</table></div>,
code: ({ children, className }) => {
const isBlock = (className ?? "").includes("language-");
return isBlock
? <code className={className}>{children}</code>
: <code className="md-inline-code">{children}</code>;
},
};
export function MarkdownBody({ children }: { children: string }) {
const processed = preprocessWikiLinks(children);
return (
<div className="markdown-body">
<ReactMarkdown remarkPlugins={[remarkGfm]} components={components}>
{processed}
</ReactMarkdown>
</div>
);
}