Files
scriptc/docs/src/components/node-compatibility-matrix.tsx
T

376 lines
18 KiB
TypeScript

"use client";
// Filter state is disposable developer UI state. Remount on Fast Refresh so
// an old search/status selection cannot make a newly generated ledger appear
// empty after an edit.
// @refresh reset
import { useEffect, useMemo, useState } from "react";
import compatibilityMeta from "@/generated/node-v24-compatibility-meta.json";
type Status =
| "supported"
| "partial"
| "refused"
| "not-implemented"
| "by-design"
| "unreviewed"
| "not-applicable";
type VerificationBasis =
| "test-backed"
| "explicit-refusal"
| "verified-absence"
| "declared-gap"
| "registry-gap"
| "architectural-policy"
| "unreviewed"
| "not-applicable"
| "derived";
type Verification = { label: string; detail: string };
type Tier = { status: Status; detail: string; verification: VerificationBasis };
type NodeStability = { index: string; level: number; text: string; inherited: boolean } | null;
type Row = {
id: string;
chapter: string;
kind: string;
name: string;
signature: string;
label: string;
apiSymbol: string;
depth: number;
scope: "api" | "documentation" | "metadata" | "configuration";
anchor: string;
nodeStability: NodeStability;
static: Tier;
};
type Chapter = { slug: string; title: string; entries: number; apiEntries: number };
type Snapshot = { nodeVersion: string; verificationBases: Record<VerificationBasis, Verification>; chapters: Chapter[]; rows: Row[] };
type TreeNode = { row: Row; children: TreeNode[] };
type VisibleNode = { node: TreeNode; level: number };
const labels: Record<Status, string> = {
supported: "Supported",
partial: "Partial",
refused: "Refused",
"not-implemented": "Not implemented",
"by-design": "By design",
unreviewed: "Unreviewed",
"not-applicable": "N/A",
};
const styles: Record<Status, string> = {
supported: "border-green-500/40 bg-green-100 text-green-900 dark:bg-green-200",
partial: "border-amber-500/50 bg-amber-100 text-amber-900 dark:bg-amber-200",
refused: "border-red-500/40 bg-red-100 text-red-900 dark:bg-red-200",
"not-implemented": "border-blue-500/40 bg-blue-100 text-blue-900 dark:bg-blue-200",
"by-design": "border-gray-alpha-500 bg-gray-alpha-200 text-gray-1000",
unreviewed: "border-gray-alpha-500 bg-gray-alpha-100 text-gray-900",
"not-applicable": "border-gray-alpha-400 bg-background-200 text-gray-700",
};
const statuses = ["supported", "partial", "not-implemented", "refused", "by-design", "unreviewed", "not-applicable"] as const;
const stabilityStyles: Record<number, string> = {
0: "border-red-500/40 bg-red-100 text-red-900 dark:bg-red-200",
1: "border-amber-500/50 bg-amber-100 text-amber-900 dark:bg-amber-200",
2: "border-green-500/40 bg-green-100 text-green-900 dark:bg-green-200",
3: "border-gray-alpha-500 bg-gray-alpha-100 text-gray-900",
};
function StatusBadge({ status, detail, verification }: { status: Status; detail?: string; verification?: Verification }) {
const tooltip = verification ? `${detail ?? ""}${detail ? "\n\n" : ""}Verification: ${verification.label}. ${verification.detail}` : detail;
return (
<span className="group relative inline-flex" tabIndex={tooltip ? 0 : undefined}>
<span
className={`inline-flex whitespace-nowrap rounded-full border px-2 py-0.5 text-[11px] font-medium ${styles[status]}`}
title={tooltip}
>
{labels[status]}
</span>
{tooltip ? (
<span role="tooltip" className="pointer-events-none invisible absolute left-0 top-full z-30 mt-2 w-64 rounded-md border border-gray-alpha-500 bg-background-100 p-2 text-left text-xs font-normal leading-5 text-gray-1000 opacity-0 shadow-popover transition-opacity group-hover:visible group-hover:opacity-100 group-focus-visible:visible group-focus-visible:opacity-100">
{detail ? <span className="block">{detail}</span> : null}
{verification ? <span className={detail ? "mt-2 block border-t border-gray-alpha-400 pt-2" : "block"}><strong>Verification:</strong> {verification.label}. {verification.detail}</span> : null}
</span>
) : null}
</span>
);
}
function StabilityBadge({ stability }: { stability: NodeStability }) {
if (stability === null) return <span className="text-xs text-gray-700" title="Node.js does not publish a stability index for this entry.">—</span>;
const detail = `Node.js Stability ${stability.index}: ${stability.text}${stability.inherited ? " (inherited from the nearest parent section)" : ""}`;
return (
<span className="group relative inline-flex" tabIndex={0}>
<span className={`inline-flex min-w-7 justify-center rounded-full border px-2 py-0.5 text-[11px] font-medium ${stabilityStyles[stability.level] ?? stabilityStyles[3]}`} title={detail}>
{stability.index}
</span>
<span role="tooltip" className="pointer-events-none invisible absolute left-1/2 top-full z-30 mt-2 w-72 -translate-x-1/2 rounded-md border border-gray-alpha-500 bg-background-100 p-2 text-left text-xs font-normal leading-5 text-gray-1000 opacity-0 shadow-popover transition-opacity group-hover:visible group-hover:opacity-100 group-focus-visible:visible group-focus-visible:opacity-100">
{detail}
</span>
</span>
);
}
function statusCounts(rows: Row[], tier: "static") {
return rows.reduce<Record<Status, number>>(
(counts, row) => {
if (row.scope !== "api" || row.depth === 0) return counts;
counts[row[tier].status] += 1;
return counts;
},
{
supported: 0,
partial: 0,
refused: 0,
"not-implemented": 0,
"by-design": 0,
unreviewed: 0,
"not-applicable": 0,
},
);
}
/** Node's all.json is preorder with an explicit heading depth. */
function buildTree(rows: Row[]): TreeNode[] {
const roots: TreeNode[] = [];
const stack: TreeNode[] = [];
for (const row of rows) {
const node: TreeNode = { row, children: [] };
while (stack.length > row.depth) stack.pop();
const parent = stack[row.depth - 1];
if (row.depth === 0 || parent === undefined) roots.push(node);
else parent.children.push(node);
stack[row.depth] = node;
stack.length = row.depth + 1;
}
return roots;
}
function filterTree(nodes: TreeNode[], matches: (row: Row) => boolean): TreeNode[] {
const filtered: TreeNode[] = [];
for (const node of nodes) {
const children = filterTree(node.children, matches);
if (matches(node.row) || children.length > 0) filtered.push({ row: node.row, children });
}
return filtered;
}
function flattenTree(nodes: TreeNode[], expanded: Set<string>, forceExpanded: boolean): VisibleNode[] {
const visible: VisibleNode[] = [];
const visit = (items: TreeNode[], level: number) => {
for (const node of items) {
visible.push({ node, level });
if (node.children.length > 0 && (forceExpanded || expanded.has(node.row.id))) {
visit(node.children, level + 1);
}
}
};
visit(nodes, 1);
return visible;
}
function nodeCount(nodes: TreeNode[]): number {
return nodes.reduce((count, node) => count + 1 + nodeCount(node.children), 0);
}
function expandableIds(nodes: TreeNode[]): Set<string> {
const ids = new Set<string>();
const visit = (items: TreeNode[]) => {
for (const node of items) {
if (node.children.length > 0) ids.add(node.row.id);
visit(node.children);
}
};
visit(nodes);
return ids;
}
function Chevron({ open }: { open: boolean }) {
return (
<svg aria-hidden="true" viewBox="0 0 16 16" className={`h-3.5 w-3.5 transition-transform ${open ? "rotate-90" : ""}`} fill="none" stroke="currentColor" strokeWidth="1.5" strokeLinecap="round" strokeLinejoin="round">
<path d="m6 3 5 5-5 5" />
</svg>
);
}
export function NodeCompatibilityMatrix() {
const [snapshot, setSnapshot] = useState<Snapshot | null>(null);
const [loadError, setLoadError] = useState(false);
const [query, setQuery] = useState("");
const [staticFilter, setStaticFilter] = useState<"all" | Status>("all");
const [expanded, setExpanded] = useState<Set<string>>(() => new Set());
const rows = snapshot?.rows ?? [];
const chapters = snapshot?.chapters ?? [];
const verificationBases = snapshot?.verificationBases;
const apiRowCount = useMemo(() => rows.filter((row) => row.scope === "api").length, [rows]);
const chapterTitles = useMemo(() => new Map(chapters.map((item) => [item.slug, item.title])), [chapters]);
const base = `https://nodejs.org/docs/v${snapshot?.nodeVersion ?? "24.15.0"}/api/`;
const staticCounts = useMemo(() => statusCounts(rows, "static"), [rows]);
const visibleStatuses = useMemo(() => statuses.filter((status) => staticCounts[status] > 0), [staticCounts]);
const tree = useMemo(() => buildTree(rows), [rows]);
useEffect(() => {
const controller = new AbortController();
fetch(`/compatibility/data?v=${compatibilityMeta.artifactVersion}`, { cache: "no-store", signal: controller.signal })
.then((response) => {
if (!response.ok) throw new Error(`compatibility data: ${response.status}`);
return response.json() as Promise<Snapshot>;
})
.then((data) => {
if (!Array.isArray(data.rows) || data.rows.length !== compatibilityMeta.rowCount) {
throw new Error(`compatibility data is stale or malformed (expected ${compatibilityMeta.rowCount} rows)`);
}
setSnapshot(data);
})
.catch((error: unknown) => {
if ((error as { name?: string }).name !== "AbortError") setLoadError(true);
});
return () => controller.abort();
}, []);
const needle = query.trim().toLocaleLowerCase();
const hasResultFilter = needle !== "" || staticFilter !== "all";
const rowMatches = (row: Row) => {
if (staticFilter !== "all" && row.static.status !== staticFilter) return false;
if (!needle) return true;
return [chapterTitles.get(row.chapter), row.kind, row.name, row.signature, row.apiSymbol, row.static.detail]
.join(" ")
.toLocaleLowerCase()
.includes(needle);
};
const resultTree = useMemo(
() => hasResultFilter ? filterTree(tree, rowMatches) : tree,
// rowMatches closes over these filter values.
// eslint-disable-next-line react-hooks/exhaustive-deps
[hasResultFilter, needle, staticFilter, tree],
);
const visible = useMemo(
() => flattenTree(resultTree, expanded, hasResultFilter),
[expanded, hasResultFilter, resultTree],
);
const matchedCount = useMemo(
() => hasResultFilter ? tree.reduce((count, node) => {
const visit = (item: TreeNode): number => (rowMatches(item.row) ? 1 : 0) + item.children.reduce((sum, child) => sum + visit(child), 0);
return count + visit(node);
}, 0) : nodeCount(tree),
// rowMatches closes over these filter values.
// eslint-disable-next-line react-hooks/exhaustive-deps
[hasResultFilter, needle, staticFilter, tree],
);
const changeQuery = (value: string) => setQuery(value);
const resetFilters = () => {
setQuery("");
setStaticFilter("all");
setExpanded(new Set());
};
const toggle = (id: string) => setExpanded((current) => {
const next = new Set(current);
if (next.has(id)) next.delete(id);
else next.add(id);
return next;
});
useEffect(() => {
if (!snapshot) return;
if (staticFilter !== "all" && staticCounts[staticFilter] === 0) setStaticFilter("all");
}, [snapshot, staticCounts, staticFilter]);
if (loadError) {
return <div className="flex h-full items-center justify-center p-6"><div className="rounded-xl border border-red-500/40 bg-red-100 p-4 text-sm text-red-900">The compatibility dataset could not be loaded.</div></div>;
}
if (!snapshot) {
return <div className="flex h-full items-center justify-center p-6 text-sm text-gray-900">Loading {compatibilityMeta.rowCount.toLocaleString()} Node.js API rows…</div>;
}
return (
<section aria-labelledby="compatibility-title" className="flex h-full min-h-0 flex-col bg-background-100">
<div className="shrink-0 border-b border-gray-alpha-400 bg-background-200 px-3 py-3 md:px-4">
<div className="flex flex-col gap-3 xl:flex-row xl:items-center">
<div className="min-w-fit">
<h1 id="compatibility-title" className="text-lg font-semibold leading-6 text-gray-1000">Node.js 24 Compatibility</h1>
<p className="text-xs text-gray-800">Native compilation · Node {snapshot.nodeVersion}</p>
</div>
<div className="grid min-w-0 flex-1 gap-2 md:grid-cols-[minmax(240px,1fr)_auto]">
<label className="relative block">
<span className="sr-only">Search the Node.js API inventory</span>
<svg aria-hidden="true" viewBox="0 0 16 16" className="pointer-events-none absolute left-3 top-1/2 h-4 w-4 -translate-y-1/2 text-gray-800" fill="none" stroke="currentColor" strokeWidth="1.5">
<circle cx="7" cy="7" r="4.5" /><path d="m10.5 10.5 3 3" />
</svg>
<input type="search" value={query} onChange={(event) => changeQuery(event.target.value)} placeholder={`Search ${apiRowCount.toLocaleString()} APIs and signatures…`} className="h-9 w-full rounded-md border border-gray-alpha-500 bg-background-100 pl-9 pr-3 text-sm text-gray-1000 placeholder:text-gray-700" />
</label>
<label className="flex items-center gap-2 text-xs text-gray-900">
<span>Static</span>
<select value={staticFilter} onChange={(event) => setStaticFilter(event.target.value as "all" | Status)} className="h-9 rounded-md border border-gray-alpha-500 bg-background-100 px-2 text-xs text-gray-1000">
<option value="all">All</option>
{statuses.filter((status) => staticCounts[status] > 0).map((status) => <option key={status} value={status}>{labels[status]} ({staticCounts[status]})</option>)}
</select>
</label>
</div>
<div className="flex shrink-0 items-center gap-3 text-xs text-gray-900">
<span aria-live="polite">{visible.length.toLocaleString()} visible · {matchedCount.toLocaleString()} {hasResultFilter ? "matching" : "API"} rows</span>
{!hasResultFilter ? (
<span className="flex gap-1">
<button type="button" onClick={() => setExpanded(expandableIds(tree))} className="rounded px-1.5 py-1 text-gray-900 hover:bg-gray-alpha-100 hover:text-gray-1000">Expand all</button>
<button type="button" onClick={() => setExpanded(new Set())} className="rounded px-1.5 py-1 text-gray-900 hover:bg-gray-alpha-100 hover:text-gray-1000">Collapse all</button>
</span>
) : null}
</div>
</div>
<div className="mt-2 flex flex-wrap items-center gap-2 text-xs text-gray-900">
{visibleStatuses.map((status) => <StatusBadge key={status} status={status} />)}
</div>
</div>
<div className="min-h-0 flex-1 overflow-auto overscroll-contain bg-background-100">
<table role="treegrid" aria-label="Node.js 24 compatibility hierarchy" className="compatibility-table min-w-[900px]">
<thead className="sticky top-0 z-10 bg-background-200/95 backdrop-blur-sm">
<tr><th className="w-[55%]">Node.js API hierarchy</th><th className="w-[15%] text-center">Node stability</th><th className="w-[30%]">Native support</th></tr>
</thead>
<tbody>
{visible.map(({ node, level }) => {
const row = node.row;
const hasChildren = node.children.length > 0;
const open = hasResultFilter || expanded.has(row.id);
const href = `${base}${row.chapter}.html${row.anchor ? `#${row.anchor}` : ""}`;
const isRoot = level === 1;
const displayName = isRoot ? chapterTitles.get(row.chapter) ?? row.label : row.label;
return (
<tr key={row.id} aria-level={level} aria-expanded={hasChildren ? open : undefined} className={`align-top hover:bg-gray-alpha-100/60 ${isRoot ? "bg-background-200/60" : ""}`}>
<td>
<div className="flex items-start gap-1.5" style={{ paddingLeft: `${(level - 1) * 18}px` }}>
{hasChildren ? (
<button type="button" onClick={() => toggle(row.id)} disabled={hasResultFilter} aria-label={`${open ? "Collapse" : "Expand"} ${displayName}`} className="mt-0.5 flex h-5 w-5 shrink-0 items-center justify-center rounded text-gray-800 hover:bg-gray-alpha-200 hover:text-gray-1000 disabled:cursor-default disabled:hover:bg-transparent">
<Chevron open={open} />
</button>
) : <span className="h-5 w-5 shrink-0" />}
<div className="min-w-0">
<a href={href} target="_blank" rel="noopener noreferrer" className={`${isRoot ? "font-sans text-sm font-medium" : "font-mono text-xs"} text-gray-1000 underline decoration-gray-alpha-500 underline-offset-2 hover:decoration-gray-1000`}>
{displayName}
</a>
<div className="mt-1 text-[11px] text-gray-800">
{row.kind}{row.apiSymbol && row.apiSymbol !== row.signature && !isRoot ? ` · ${row.apiSymbol}` : ""}
</div>
</div>
</div>
</td>
<td className="text-center"><StabilityBadge stability={row.nodeStability} /></td>
<td><StatusBadge status={row.static.status} detail={row.static.detail} verification={verificationBases?.[row.static.verification]} /></td>
</tr>
);
})}
</tbody>
</table>
{visible.length === 0 ? (
<div className="p-8 text-center text-sm text-gray-900">
<div>No API rows match these filters.</div>
<button type="button" onClick={resetFilters} className="mt-3 rounded-md border border-gray-alpha-500 px-3 py-1.5 font-medium text-gray-1000">Reset filters</button>
</div>
) : null}
</div>
</section>
);
}