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

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 };