141 lines
5.6 KiB
TypeScript
141 lines
5.6 KiB
TypeScript
"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>
|
|
);
|
|
}
|