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:
parent
a6e360aaa5
commit
e16aa03393
21 changed files with 608 additions and 59 deletions
26
ui/app/[dataspace]/[kind]/[slug]/page.tsx
Normal file
26
ui/app/[dataspace]/[kind]/[slug]/page.tsx
Normal 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} />;
|
||||
}
|
||||
18
ui/app/[dataspace]/[kind]/page.tsx
Normal file
18
ui/app/[dataspace]/[kind]/page.tsx
Normal 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} />;
|
||||
}
|
||||
15
ui/app/[dataspace]/page.tsx
Normal file
15
ui/app/[dataspace]/page.tsx
Normal 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} />;
|
||||
}
|
||||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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" />;
|
||||
}
|
||||
|
|
|
|||
31
ui/components/DataspaceHome.tsx
Normal file
31
ui/components/DataspaceHome.tsx
Normal 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} />
|
||||
</>
|
||||
);
|
||||
}
|
||||
26
ui/components/EntityDetail.test.tsx
Normal file
26
ui/components/EntityDetail.test.tsx
Normal 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");
|
||||
});
|
||||
});
|
||||
78
ui/components/EntityDetail.tsx
Normal file
78
ui/components/EntityDetail.tsx
Normal 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>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
|
@ -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>
|
||||
|
|
|
|||
15
ui/components/KindIndex.tsx
Normal file
15
ui/components/KindIndex.tsx
Normal 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} />
|
||||
</>
|
||||
);
|
||||
}
|
||||
16
ui/components/PageHeader.tsx
Normal file
16
ui/components/PageHeader.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
|
|
@ -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}
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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
34
ui/lib/catalog.test.ts
Normal 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
263
ui/lib/catalog.ts
Normal 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,
|
||||
};
|
||||
}
|
||||
|
|
@ -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,
|
||||
};
|
||||
}
|
||||
|
|
@ -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 };
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue