Add registry kind indexes and entity pages.

CONTENT-WP-0002-T02 reads kinds from each snapshot and serves both example dataspaces without oncology imports.

Assistant: grok
Assistant-Session: 01a1346c-fdff-7741-b1af-b7283661554b
This commit is contained in:
tegwick 2026-10-13 22:56:52 +02:00
parent a6e360aaa5
commit e16aa03393
21 changed files with 608 additions and 59 deletions

View file

@ -0,0 +1,26 @@
import type { Metadata } from "next";
import { notFound } from "next/navigation";
import { EntityDetail } from "@/components/EntityDetail";
import { findEntity } from "@/lib/catalog";
export async function generateMetadata({
params,
}: {
params: Promise<{ dataspace: string; kind: string; slug: string }>;
}): Promise<Metadata> {
const { dataspace, kind, slug } = await params;
const view = findEntity(dataspace, kind, slug);
if (!view) return { title: "Content Explorer" };
return { title: `${view.title} · ${view.kindLabel} · Content Explorer`, description: view.tldr };
}
export default async function EntityPage({
params,
}: {
params: Promise<{ dataspace: string; kind: string; slug: string }>;
}) {
const { dataspace, kind, slug } = await params;
const view = findEntity(dataspace, kind, slug);
if (!view) notFound();
return <EntityDetail entity={view} />;
}

View file

@ -0,0 +1,18 @@
import type { Metadata } from "next";
import { notFound } from "next/navigation";
import { KindIndex } from "@/components/KindIndex";
import { findKind } from "@/lib/catalog";
export async function generateMetadata({ params }: { params: Promise<{ dataspace: string; kind: string }> }): Promise<Metadata> {
const { dataspace, kind } = await params;
const view = findKind(dataspace, kind);
if (!view) return { title: "Content Explorer" };
return { title: `${view.plural} · ${view.dataspaceTitle} · Content Explorer` };
}
export default async function KindPage({ params }: { params: Promise<{ dataspace: string; kind: string }> }) {
const { dataspace, kind } = await params;
const view = findKind(dataspace, kind);
if (!view) notFound();
return <KindIndex kind={view} />;
}

View file

@ -0,0 +1,15 @@
import type { Metadata } from "next";
import { DataspaceHome } from "@/components/DataspaceHome";
import { findDataspace } from "@/lib/catalog";
export async function generateMetadata({ params }: { params: Promise<{ dataspace: string }> }): Promise<Metadata> {
const { dataspace } = await params;
const view = findDataspace(dataspace);
if (!view) return { title: "Content Explorer" };
return { title: `${view.title} · Content Explorer`, description: view.description };
}
export default async function DataspacePage({ params }: { params: Promise<{ dataspace: string }> }) {
const { dataspace } = await params;
return <DataspaceHome id={dataspace} />;
}

View file

@ -3,6 +3,7 @@ import type { ReactNode } from "react";
import { SiteFooter } from "@/components/SiteFooter";
import { SiteHeader } from "@/components/SiteHeader";
import { SkipLink } from "@/components/SkipLink";
import { listNavigation } from "@/lib/catalog";
import "./globals.css";
export const metadata: Metadata = {
@ -15,7 +16,7 @@ export default function RootLayout({ children }: { children: ReactNode }) {
<html lang="en">
<body>
<SkipLink />
<SiteHeader />
<SiteHeader dataspaces={listNavigation()} />
<main id="main">{children}</main>
<SiteFooter />
</body>

View file

@ -1,13 +1,5 @@
import { Hero } from "@/components/Hero";
import { RecordBrowser } from "@/components/RecordBrowser";
import { loadSystemsThinking } from "@/lib/fixture";
import { DataspaceHome } from "@/components/DataspaceHome";
export default function HomePage() {
const fixture = loadSystemsThinking();
return (
<>
<Hero dataspace={fixture.title} description={fixture.description} />
<RecordBrowser rows={fixture.rows} />
</>
);
return <DataspaceHome id="systems-thinking" />;
}

View file

@ -0,0 +1,31 @@
import { Hero } from "@/components/Hero";
import { RecordBrowser } from "@/components/RecordBrowser";
import { findDataspace, listNavigation } from "@/lib/catalog";
import { notFound } from "next/navigation";
export function DataspaceHome({ id }: { id: string }) {
const dataspace = findDataspace(id);
if (!dataspace) notFound();
const others = listNavigation().filter((item) => item.id !== dataspace.id);
return (
<>
<Hero
dataspace={dataspace.title}
description={dataspace.description}
kinds={dataspace.kinds.map((kind) => ({ href: kind.href, label: kind.plural }))}
/>
{others.length > 0 ? (
<p className="mx-auto max-w-7xl px-4 pt-8 text-sm text-muted sm:px-6">
Other fields:{" "}
{others.map((item, index) => (
<span key={item.id}>
{index > 0 ? ", " : null}
<a className="underline" href={`/${item.id}/`}>{item.title}</a>
</span>
))}
</p>
) : null}
<RecordBrowser rows={dataspace.rows} />
</>
);
}

View file

@ -0,0 +1,26 @@
import { renderToStaticMarkup } from "react-dom/server";
import { describe, expect, it } from "vitest";
import { findEntity, findKind } from "@/lib/catalog";
import { EntityDetail } from "./EntityDetail";
import { KindIndex } from "./KindIndex";
describe("kind and detail views", () => {
it("renders the registry plural and a linked record", () => {
const kind = findKind("software-architecture", "concept");
expect(kind).not.toBeNull();
const html = renderToStaticMarkup(<KindIndex kind={kind!} />);
expect(html).toContain(">Concepts<");
expect(html).toContain("Coupling");
expect(html).toContain('href="/software-architecture/concept/coupling/"');
});
it("renders the entity title, an unknown claim, and a neighbour", () => {
const entity = findEntity("systems-thinking", "concept", "system-boundary");
expect(entity).not.toBeNull();
const html = renderToStaticMarkup(<EntityDetail entity={entity!} />);
expect(html).toContain(">System boundary<");
expect(html).toContain("formal-measure");
expect(html).toContain("unknown");
expect(html).toContain("Stock and flow model");
});
});

View file

@ -0,0 +1,78 @@
import { PageHeader } from "@/components/PageHeader";
import type { EntityView } from "@/lib/catalog";
/**
* Generic record page: title, summary, sources, claims, and neighbouring relations.
* OnCo EntityDetail was not copied. That file special-cases domain panels.
*/
export function EntityDetail({ entity }: { entity: EntityView }) {
return (
<>
<PageHeader kicker={entity.kindLabel} title={entity.title} lede={entity.tldr} />
<div className="mx-auto max-w-7xl space-y-10 px-4 py-10 sm:px-6">
<p className="max-w-3xl text-body leading-relaxed">{entity.summary}</p>
{entity.tags.length > 0 ? (
<ul className="flex flex-wrap gap-2">
{entity.tags.map((tag) => (
<li key={tag} className="chip">{tag}</li>
))}
</ul>
) : null}
<section aria-labelledby="sources-heading">
<h2 id="sources-heading" className="mb-3 text-lg">Sources</h2>
{entity.sources.length === 0 ? (
<p className="text-sm text-muted">No sources.</p>
) : (
<ul className="space-y-2 text-sm">
{entity.sources.map((source) => (
<li key={source.title}>
{source.title}
{source.license ? <span className="text-muted"> · {source.license}</span> : null}
</li>
))}
</ul>
)}
</section>
<section aria-labelledby="claims-heading">
<h2 id="claims-heading" className="mb-3 text-lg">Claims</h2>
{entity.claims.length === 0 ? (
<p className="text-sm text-muted">No claims.</p>
) : (
<ul className="space-y-2 text-sm">
{entity.claims.map((claim) => (
<li key={`${claim.key}:${claim.status}:${claim.value ?? ""}`}>
<span className="font-medium">{claim.key}</span>
<span className="text-muted"> · {claim.status}</span>
{claim.value ? <span> · {claim.value}</span> : null}
</li>
))}
</ul>
)}
</section>
<section aria-labelledby="relations-heading">
<h2 id="relations-heading" className="mb-3 text-lg">Relations</h2>
{entity.relations.length === 0 ? (
<p className="text-sm text-muted">No relations.</p>
) : (
<ul className="space-y-2 text-sm">
{entity.relations.map((relation) => (
<li key={`${relation.direction}:${relation.wording}:${relation.otherTitle}`}>
<span className="text-muted">{relation.wording}</span>
{" · "}
{relation.otherHref ? (
<a className="underline" href={relation.otherHref}>{relation.otherTitle}</a>
) : (
relation.otherTitle
)}
</li>
))}
</ul>
)}
</section>
<p className="text-sm">
<a className="underline" href={entity.kindHref}>Back to {entity.kindLabel}</a>
</p>
</div>
</>
);
}

View file

@ -3,7 +3,15 @@
* Copyright (c) 2026 OnCo for the adapted pattern. MIT License. See ui/NOTICE.
* The heading and lede are Content Explorer copy.
*/
export function Hero({ dataspace, description }: { dataspace: string; description: string }) {
export function Hero({
dataspace,
description,
kinds = [],
}: {
dataspace: string;
description: string;
kinds?: { href: string; label: string }[];
}) {
return (
<section className="hero border-b border-border">
<div className="mx-auto max-w-7xl px-4 pt-14 pb-10 sm:px-6 sm:pt-20 sm:pb-12">
@ -11,10 +19,15 @@ export function Hero({ dataspace, description }: { dataspace: string; descriptio
<p className="kicker">{dataspace}</p>
<h1 className="display mt-4">Explore a field of knowledge.</h1>
<p className="mt-6 max-w-2xl text-[17px] leading-relaxed text-foreground/85 sm:text-xl">{description}</p>
<div className="mt-8">
<div className="mt-8 flex flex-wrap gap-2">
<a href="#records" className="btn btn-primary">
Browse records
</a>
{kinds.map((kind) => (
<a key={kind.href} href={kind.href} className="btn">
{kind.label}
</a>
))}
</div>
</div>
</div>

View file

@ -0,0 +1,15 @@
import { RecordBrowser } from "@/components/RecordBrowser";
import { PageHeader } from "@/components/PageHeader";
import type { KindView } from "@/lib/catalog";
/** Kind directory. The heading and rows come from the snapshot kind registry. */
export function KindIndex({ kind }: { kind: KindView }) {
const count = kind.rows.length;
const lede = count === 1 ? `1 record in ${kind.dataspaceTitle}.` : `${count} records in ${kind.dataspaceTitle}.`;
return (
<>
<PageHeader kicker={kind.dataspaceTitle} title={kind.plural} lede={lede} />
<RecordBrowser rows={kind.rows} />
</>
);
}

View file

@ -0,0 +1,16 @@
/**
* Page heading adapted from OnCo PageHeader: kicker, title, lede.
* Copyright (c) 2026 OnCo for the adapted pattern. MIT License. See ui/NOTICE.
* Garden art and domain logos are not included.
*/
export function PageHeader({ kicker, title, lede }: { kicker?: string; title: string; lede?: string }) {
return (
<header className="border-b border-border">
<div className="mx-auto max-w-7xl px-4 pt-8 pb-6 sm:px-6 sm:pt-10">
{kicker ? <p className="kicker mb-2">{kicker}</p> : null}
<h1 className="text-3xl font-semibold tracking-tight first-letter:uppercase sm:text-4xl">{title}</h1>
{lede ? <p className="mt-3 max-w-3xl text-[17px] leading-relaxed text-foreground/85 sm:text-lg">{lede}</p> : null}
</div>
</header>
);
}

View file

@ -6,17 +6,29 @@ import { nextSort, sortRows } from "@/lib/sort-rows";
import type { RecordRow, SortState } from "@/lib/types";
const columns = [
{ key: "title", label: "Title", sortable: true, render: (row: RecordRow) => row.title },
{ key: "kindLabel", label: "Kind", sortable: true, render: (row: RecordRow) => <span className="chip">{row.kindLabel}</span> },
{
key: "title",
label: "Title",
sortable: true,
render: (row: RecordRow) => row.href ? <a className="underline" href={row.href}>{row.title}</a> : row.title,
},
{
key: "kindLabel",
label: "Kind",
sortable: true,
render: (row: RecordRow) => row.kindHref
? <a className="chip" href={row.kindHref}>{row.kindLabel}</a>
: <span className="chip">{row.kindLabel}</span>,
},
{ key: "tldr", label: "Summary", sortable: true, render: (row: RecordRow) => row.tldr },
];
export function RecordBrowser({ rows }: { rows: RecordRow[] }) {
export function RecordBrowser({ rows, heading = "Records" }: { rows: RecordRow[]; heading?: string }) {
const [sort, setSort] = useState<SortState | undefined>({ key: "title", dir: 1 });
const ordered = sortRows(rows, sort, (row, key) => String(row[key as keyof RecordRow] ?? ""));
return (
<section id="records" aria-labelledby="records-heading" className="mx-auto max-w-7xl px-4 py-14 sm:px-6">
<h2 id="records-heading" className="mb-6 text-2xl">Records</h2>
<h2 id="records-heading" className="mb-6 text-2xl">{heading}</h2>
<ResultsTable
columns={columns}
rows={ordered}

View file

@ -5,7 +5,7 @@ export function SiteFooter() {
<p className="max-w-2xl text-sm leading-relaxed text-muted">
Content Explorer browses an interconnected field of knowledge. Interface patterns on this page are adapted
from OnCo under the MIT License, commit b594d7433bf4e408b44215e25e5304696d6f840e. The OnCo corpus is not
included. The sample records are the CC0 systems-thinking fixture.
included. The sample records are the CC0 systems-thinking and software-architecture fixtures.
</p>
</div>
</footer>

View file

@ -1,21 +1,33 @@
"use client";
import { usePathname } from "next/navigation";
import type { DataspaceNav } from "@/lib/catalog";
/**
* Sticky header shell adapted from OnCo SiteHeader.
* Copyright (c) 2026 OnCo. MIT License. See ui/NOTICE.
* The OnCo mark, command palette, and kind routes are not included.
* Kind routes arrive in CONTENT-WP-0002-T02; this header only links to the home page.
* The OnCo mark and command palette are not included.
* Kind links come from the snapshot registry passed in by the layout.
*/
export function SiteHeader() {
export function SiteHeader({ dataspaces }: { dataspaces: DataspaceNav[] }) {
const path = usePathname() ?? "/";
const active = dataspaces.find((item) => path === `/${item.id}` || path.startsWith(`/${item.id}/`)) ?? dataspaces.find((item) => item.id === "systems-thinking") ?? dataspaces[0];
return (
<header className="sticky top-0 z-40 h-14 border-b border-border bg-background/80 backdrop-blur-md supports-[backdrop-filter]:bg-background/75">
<div className="mx-auto flex h-14 max-w-7xl items-center gap-6 px-4 sm:px-6">
<a href="/" className="inline-flex h-10 items-center rounded-lg font-semibold tracking-tight" aria-label="Content Explorer home">
<header className="sticky top-0 z-40 border-b border-border bg-background/80 backdrop-blur-md supports-[backdrop-filter]:bg-background/75">
<div className="mx-auto flex h-14 max-w-7xl items-center gap-4 px-4 sm:gap-6 sm:px-6">
<a href="/" className="inline-flex h-10 shrink-0 items-center rounded-lg font-semibold tracking-tight" aria-label="Content Explorer home">
<span className="text-[15px]">Content Explorer</span>
</a>
<nav aria-label="Primary" className="flex items-center gap-4 text-sm">
<a href="#records" className="text-muted hover:text-foreground">
Records
</a>
</nav>
{active ? (
<nav aria-label="Primary" className="flex min-w-0 items-center gap-4 overflow-x-auto text-sm">
<a href={`/${active.id}/`} className="shrink-0 text-foreground">{active.title}</a>
{active.kinds.map((kind) => (
<a key={kind.id} href={kind.href} className="shrink-0 text-muted hover:text-foreground">
{kind.plural}
</a>
))}
</nav>
) : null}
</div>
</header>
);

34
ui/lib/catalog.test.ts Normal file
View file

@ -0,0 +1,34 @@
import { readFileSync } from "node:fs";
import { join } from "node:path";
import { describe, expect, it } from "vitest";
import { entitySlug, findDataspace, findEntity, findKind } from "./catalog";
const root = join(process.cwd(), "../examples/dataspaces");
function titles(dataspaceId: string, kindId: string): string[] {
const snapshot = JSON.parse(readFileSync(join(root, dataspaceId, "snapshot.json"), "utf8")) as {
entities: { kindId: string; title: string }[];
};
return snapshot.entities.filter((entity) => entity.kindId === kindId).map((entity) => entity.title).sort();
}
describe("catalog", () => {
it("takes the kind index from the snapshot registry", () => {
const concept = findKind("systems-thinking", "concept");
const pattern = findKind("software-architecture", "pattern");
expect(concept?.plural).toBe("Concepts");
expect(concept?.rows.map((row) => row.title).sort()).toEqual(titles("systems-thinking", "concept"));
expect(pattern?.rows.map((row) => row.title).sort()).toEqual(titles("software-architecture", "pattern"));
expect(concept?.rows.every((row) => row.href?.startsWith("/systems-thinking/concept/"))).toBe(true);
});
it("resolves one entity and hides unknown routes", () => {
expect(entitySlug("urn:ce:systems-thinking:concept:system-boundary", "concept")).toBe("system-boundary");
const feedback = findEntity("systems-thinking", "concept", "feedback");
expect(feedback?.title).toBe("Feedback");
expect(feedback?.relations.some((relation) => relation.otherTitle === "Causal loop diagram")).toBe(true);
expect(findEntity("systems-thinking", "concept", "missing")).toBeNull();
expect(findKind("systems-thinking", "no-such-kind")).toBeNull();
expect(findDataspace("software-architecture")?.title).toBe("Software Architecture");
});
});

263
ui/lib/catalog.ts Normal file
View file

@ -0,0 +1,263 @@
import { readdirSync, readFileSync } from "node:fs";
import { join } from "node:path";
import type { RecordRow } from "./types";
type KindRecord = { id: string; label: string; plural: string };
type EntityRecord = {
id: string;
kindId: string;
title: string;
tldr: string;
summary: string;
tags?: string[];
sourceRefs?: string[];
claimRefs?: string[];
};
type SourceRecord = { id: string; title: string; license: string };
type ClaimRecord = { id: string; subjectId: string; key: string; value?: string; status: string };
type RelationRecord = { id: string; predicate: string; from: string; to: string };
type SnapshotFile = {
dataspace: { id: string; title: string; description: string };
kinds: KindRecord[];
sources?: SourceRecord[];
entities: EntityRecord[];
relations?: RelationRecord[];
claims?: ClaimRecord[];
};
type ManifestFile = {
relationLabels?: Record<string, { forward?: string; inverse?: string }>;
};
export type DataspaceNav = {
id: string;
title: string;
kinds: { id: string; plural: string; href: string }[];
};
export type DataspaceView = {
id: string;
title: string;
description: string;
kinds: { id: string; label: string; plural: string; href: string }[];
rows: RecordRow[];
};
export type KindView = {
dataspaceId: string;
dataspaceTitle: string;
kindId: string;
label: string;
plural: string;
rows: RecordRow[];
};
export type RelationView = {
wording: string;
direction: "out" | "in";
otherTitle: string;
otherHref: string | null;
};
export type EntityView = {
id: string;
title: string;
tldr: string;
summary: string;
tags: string[];
dataspaceId: string;
dataspaceTitle: string;
kindId: string;
kindLabel: string;
kindHref: string;
sources: { title: string; license: string }[];
claims: { key: string; status: string; value: string | null }[];
relations: RelationView[];
};
type Loaded = {
snapshot: SnapshotFile;
labels: Map<string, { forward: string; inverse: string }>;
byId: Map<string, EntityRecord>;
};
const ROOT = join(process.cwd(), "../examples/dataspaces");
let cache: Map<string, Loaded> | null = null;
export function entitySlug(id: string, kindId: string): string {
const marker = `:${kindId}:`;
const at = id.lastIndexOf(marker);
if (at >= 0) return id.slice(at + marker.length);
const tail = id.split(":").pop();
return tail && tail.length > 0 ? tail : id;
}
export function kindHref(dataspaceId: string, kindId: string): string {
return `/${dataspaceId}/${kindId}/`;
}
export function entityHref(dataspaceId: string, kindId: string, id: string): string {
return `/${dataspaceId}/${kindId}/${encodeURIComponent(entitySlug(id, kindId))}/`;
}
function loadAll(): Map<string, Loaded> {
if (cache) return cache;
const loaded = new Map<string, Loaded>();
for (const name of readdirSync(ROOT).sort()) {
const snapshotPath = join(ROOT, name, "snapshot.json");
let snapshot: SnapshotFile;
try {
snapshot = JSON.parse(readFileSync(snapshotPath, "utf8")) as SnapshotFile;
} catch {
continue;
}
if (!snapshot.dataspace?.id || !Array.isArray(snapshot.kinds) || !Array.isArray(snapshot.entities)) continue;
let manifest: ManifestFile = {};
try {
manifest = JSON.parse(readFileSync(join(ROOT, name, "manifest.json"), "utf8")) as ManifestFile;
} catch {
manifest = {};
}
const labels = new Map<string, { forward: string; inverse: string }>();
for (const [predicate, wording] of Object.entries(manifest.relationLabels ?? {})) {
labels.set(predicate, {
forward: wording.forward ?? predicate,
inverse: wording.inverse ?? predicate,
});
}
loaded.set(snapshot.dataspace.id, {
snapshot,
labels,
byId: new Map(snapshot.entities.map((entity) => [entity.id, entity])),
});
}
cache = loaded;
return loaded;
}
function kindOf(loaded: Loaded, kindId: string): KindRecord | null {
return loaded.snapshot.kinds.find((kind) => kind.id === kindId) ?? null;
}
function rowFor(loaded: Loaded, entity: EntityRecord): RecordRow {
const kind = kindOf(loaded, entity.kindId);
return {
id: entity.id,
kindId: entity.kindId,
kindLabel: kind?.label ?? entity.kindId,
title: entity.title,
tldr: entity.tldr,
href: entityHref(loaded.snapshot.dataspace.id, entity.kindId, entity.id),
kindHref: kindHref(loaded.snapshot.dataspace.id, entity.kindId),
};
}
export function listNavigation(): DataspaceNav[] {
return [...loadAll().values()].map((loaded) => ({
id: loaded.snapshot.dataspace.id,
title: loaded.snapshot.dataspace.title,
kinds: loaded.snapshot.kinds.map((kind) => ({
id: kind.id,
plural: kind.plural,
href: kindHref(loaded.snapshot.dataspace.id, kind.id),
})),
}));
}
export function findDataspace(id: string): DataspaceView | null {
const loaded = loadAll().get(id);
if (!loaded) return null;
return {
id: loaded.snapshot.dataspace.id,
title: loaded.snapshot.dataspace.title,
description: loaded.snapshot.dataspace.description,
kinds: loaded.snapshot.kinds.map((kind) => ({
id: kind.id,
label: kind.label,
plural: kind.plural,
href: kindHref(loaded.snapshot.dataspace.id, kind.id),
})),
rows: loaded.snapshot.entities.map((entity) => rowFor(loaded, entity)),
};
}
export function findKind(dataspaceId: string, kindId: string): KindView | null {
const loaded = loadAll().get(dataspaceId);
if (!loaded) return null;
const kind = kindOf(loaded, kindId);
if (!kind) return null;
return {
dataspaceId,
dataspaceTitle: loaded.snapshot.dataspace.title,
kindId: kind.id,
label: kind.label,
plural: kind.plural,
rows: loaded.snapshot.entities.filter((entity) => entity.kindId === kind.id).map((entity) => rowFor(loaded, entity)),
};
}
function hrefForEntity(loaded: Loaded, id: string): string | null {
const entity = loaded.byId.get(id);
if (!entity) return null;
return entityHref(loaded.snapshot.dataspace.id, entity.kindId, entity.id);
}
export function findEntity(dataspaceId: string, kindId: string, slug: string): EntityView | null {
const loaded = loadAll().get(dataspaceId);
if (!loaded) return null;
const kind = kindOf(loaded, kindId);
if (!kind) return null;
const matches = loaded.snapshot.entities.filter(
(entity) => entity.kindId === kind.id && entitySlug(entity.id, kind.id) === slug,
);
if (matches.length !== 1) return null;
const entity = matches[0];
const sources = new Map((loaded.snapshot.sources ?? []).map((source) => [source.id, source]));
const relations: RelationView[] = [];
for (const relation of loaded.snapshot.relations ?? []) {
const wording = loaded.labels.get(relation.predicate);
if (relation.from === entity.id) {
const other = loaded.byId.get(relation.to);
relations.push({
wording: wording?.forward ?? relation.predicate,
direction: "out",
otherTitle: other?.title ?? relation.to,
otherHref: hrefForEntity(loaded, relation.to),
});
} else if (relation.to === entity.id) {
const other = loaded.byId.get(relation.from);
relations.push({
wording: wording?.inverse ?? relation.predicate,
direction: "in",
otherTitle: other?.title ?? relation.from,
otherHref: hrefForEntity(loaded, relation.from),
});
}
}
return {
id: entity.id,
title: entity.title,
tldr: entity.tldr,
summary: entity.summary,
tags: entity.tags ?? [],
dataspaceId,
dataspaceTitle: loaded.snapshot.dataspace.title,
kindId: kind.id,
kindLabel: kind.label,
kindHref: kindHref(dataspaceId, kind.id),
sources: (entity.sourceRefs ?? []).map((id) => {
const source = sources.get(id);
return { title: source?.title ?? id, license: source?.license ?? "" };
}),
claims: (loaded.snapshot.claims ?? [])
.filter((claim) => claim.subjectId === entity.id)
.map((claim) => ({ key: claim.key, status: claim.status, value: claim.value ?? null })),
relations,
};
}

View file

@ -1,27 +0,0 @@
import { readFileSync } from "node:fs";
import { join } from "node:path";
import type { RecordRow } from "./types";
type Snapshot = {
dataspace: { id: string; title: string; description: string };
kinds: { id: string; label: string; plural: string }[];
entities: { id: string; kindId: string; title: string; tldr: string }[];
};
export function loadSystemsThinking(): { title: string; description: string; rows: RecordRow[] } {
const path = join(process.cwd(), "../examples/dataspaces/systems-thinking/snapshot.json");
const snapshot = JSON.parse(readFileSync(path, "utf8")) as Snapshot;
const labels = new Map(snapshot.kinds.map((kind) => [kind.id, kind.label]));
const rows = snapshot.entities.map((entity) => ({
id: entity.id,
kindId: entity.kindId,
kindLabel: labels.get(entity.kindId) ?? entity.kindId,
title: entity.title,
tldr: entity.tldr,
}));
return {
title: snapshot.dataspace.title,
description: snapshot.dataspace.description,
rows,
};
}

View file

@ -4,6 +4,8 @@ export type RecordRow = {
kindLabel: string;
title: string;
tldr: string;
href?: string;
kindHref?: string;
};
export type SortState = { key: string; dir: 1 | -1 };

View file

@ -1,6 +1,11 @@
import path from "node:path";
import { fileURLToPath } from "node:url";
import { defineConfig } from "vitest/config";
const root = path.dirname(fileURLToPath(import.meta.url));
export default defineConfig({
resolve: { alias: { "@": root } },
esbuild: { jsx: "automatic" },
test: {
environment: "node",