Skip to content

Commit 823b682

Browse files
dmealingclaude
andcommitted
feat(docs): neutral overview page with embedded Mermaid ER diagram + navigable index
docsFile() now emits a neutral README.md overview at the docs root: a title + one-line model description, the whole-model Mermaid erDiagram, and a navigable index grouping every entity page and every template.output page. Index links use docPageHref so they resolve in BOTH flat and package layouts. Extracted renderMermaidErBlock() as the single shared neutral ER builder; renderMermaidModel() (the standalone model.md body) and the docs overview both consume it — no duplicated graph logic. The standalone Tier-1 mermaidErDiagram generator is retained as a thin back-compat wrapper, with a tier note (ADR-0020) pointing to the neutral docs engine as the canonical home of the ER diagram. meta docs summary now reports the overview page; README is not miscounted as an entity/template. Per-entity focused diagrams deferred (would need new graph scoping; the overview ER is the required win). Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
1 parent 239a337 commit 823b682

7 files changed

Lines changed: 370 additions & 18 deletions

File tree

server/typescript/packages/cli/src/commands/docs.ts

Lines changed: 6 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -221,13 +221,14 @@ export async function docsCommand(args: string[], cwd: string): Promise<number>
221221
return 1;
222222
}
223223

224-
// Summary: docsFile() emits one page per entity first, then one per
225-
// template.output. The entity count is the matched object count; the rest
226-
// are template pages.
224+
// Summary: docsFile() emits ONE overview/index page (README.md) plus one page
225+
// per entity and one per template.output. The entity count is the matched
226+
// object count; the remaining non-overview pages are template pages.
227227
const entityCount = root.objects().filter(ctx.matches).length;
228-
const templateCount = files.length - entityCount;
228+
const overviewCount = files.filter((f) => f.path === "README.md").length;
229+
const templateCount = files.length - entityCount - overviewCount;
229230
log.info(
230-
`meta docs — wrote ${entityCount} entity page(s) + ${templateCount} template page(s) → ${outDir}`,
231+
`meta docs — wrote ${overviewCount} overview + ${entityCount} entity page(s) + ${templateCount} template page(s) → ${outDir}`,
231232
);
232233
return 0;
233234
}

server/typescript/packages/cli/test/docs-command.test.ts

Lines changed: 24 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -216,12 +216,35 @@ describe("meta docs — standalone neutral metadata docs", () => {
216216
expect(await docsCommand([root, "--out", out], root)).toBe(0);
217217

218218
const summary = logged.join("\n");
219-
// Mentions counts + destination.
219+
// Mentions counts + destination, including the overview/index page.
220+
expect(summary).toMatch(/1 overview/);
220221
expect(summary).toMatch(/1 entity/);
221222
expect(summary).toMatch(/1 template/);
222223
expect(summary).toContain(out);
223224
});
224225

226+
test("emits a neutral README.md overview with an embedded Mermaid ER diagram", async () => {
227+
const root = await project();
228+
const out = join(root, "out-overview");
229+
expect(await docsCommand([root, "--out", out], root)).toBe(0);
230+
231+
// The overview/index page lands at the docs root as README.md.
232+
expect(existsSync(join(out, "README.md"))).toBe(true);
233+
const readme = await readFile(join(out, "README.md"), "utf8");
234+
235+
// Carries the fenced Mermaid ER diagram + links to the model's pages.
236+
expect(readme).toContain("```mermaid");
237+
expect(readme).toContain("erDiagram");
238+
// Links every entity + template page emitted in the same run.
239+
expect(readme).toContain("(./Welcome.md)");
240+
expect(readme).toContain("(./WelcomePage.md)");
241+
242+
// Neutral — no language/toolchain leakage (".md" links allowed).
243+
expect(readme.replace(/\.md\b/g, "")).not.toMatch(/\.ts\b/);
244+
expect(readme).not.toContain("Zod");
245+
expect(readme).not.toContain("## Generated code");
246+
});
247+
225248
test("loads metaobjects.config.ts providers so custom types resolve", async () => {
226249
const root = await customTypeProject();
227250
const out = join(root, "out-custom");

server/typescript/packages/codegen-ts/src/generators/docs-file.ts

Lines changed: 91 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -18,19 +18,30 @@
1818
// byte-for-byte. If you're hacking on this and the conformance test
1919
// breaks, the refactor is the bug, not the fixture.
2020

21-
import type { MetaObject } from "@metaobjectsdev/metadata";
21+
import type { MetaObject, MetaRoot } from "@metaobjectsdev/metadata";
2222
import { TYPE_TEMPLATE, TEMPLATE_SUBTYPE_OUTPUT } from "@metaobjectsdev/metadata";
2323
import { render } from "@metaobjectsdev/render";
2424
import type { Generator, GeneratorFactory, EmittedFile } from "../generator.js";
2525
import {
2626
docPageOutputPath,
27+
docPageHref,
2728
docPageNode,
2829
assertNoDuplicateDocPaths,
30+
type DocPageNode,
2931
type DocPagePlacement,
3032
} from "../docs-paths.js";
3133
import { projectProvider } from "../render-engine/framework-provider.js";
34+
import { renderMermaidErBlock } from "../templates/mermaid-er.js";
3235
import { buildEntityDocData } from "./docs-data-builder.js";
3336
import { buildTemplateDocData } from "./template-doc-builder.js";
37+
import type { OutputLayout } from "../import-path.js";
38+
39+
// The neutral OVERVIEW/index page. GitHub (and most doc-site renderers) treat a
40+
// folder's README.md as its landing page, so the model overview lands here.
41+
const INDEX_FILENAME = "README.md";
42+
// The index lives at the docs ROOT (package-less) in BOTH layouts — links are
43+
// computed relative to root via docPageHref.
44+
const INDEX_NODE: DocPageNode = { name: "README" };
3445

3546
export interface DocsFileOpts {
3647
filter?: (entity: MetaObject) => boolean;
@@ -55,11 +66,18 @@ export const docsFile = function docsFile(opts?: DocsFileOpts): Generator {
5566
// Track every (path, fqn) so we can hard-error on a collision (defense
5667
// against silent doc-page overwrite) AFTER all pages are placed.
5768
const placements: DocPagePlacement[] = [];
69+
// Collect the placement nodes of each linkable page so the OVERVIEW/index
70+
// (README.md) can link them via the SAME docPageHref used everywhere else
71+
// (links resolve in flat AND package layout). Grouped entity vs template.
72+
const entityNodes: DocPageNode[] = [];
73+
const templateNodes: DocPageNode[] = [];
5874
const files: EmittedFile[] = ctx.loadedRoot
5975
.objects()
6076
.filter(ctx.matches)
6177
.map((entity: MetaObject) => {
62-
const path = docPageOutputPath(layout, docPageNode(entity));
78+
const node = docPageNode(entity);
79+
entityNodes.push(node);
80+
const path = docPageOutputPath(layout, node);
6381
placements.push({ path, fqn: entity.resolutionKey() });
6482
const payload = buildEntityDocData(entity, {
6583
dialect: rc.dialect,
@@ -92,7 +110,9 @@ export const docsFile = function docsFile(opts?: DocsFileOpts): Generator {
92110
// (`<name>.md`), agreeing with the entity Used-by back-link target.
93111
for (const child of ctx.loadedRoot.ownChildren()) {
94112
if (child.type !== TYPE_TEMPLATE || child.subType !== TEMPLATE_SUBTYPE_OUTPUT) continue;
95-
const path = docPageOutputPath(layout, docPageNode(child));
113+
const node = docPageNode(child);
114+
templateNodes.push(node);
115+
const path = docPageOutputPath(layout, node);
96116
placements.push({ path, fqn: child.resolutionKey() });
97117
const payload = buildTemplateDocData(child, { layout, loadedRoot: ctx.loadedRoot });
98118
let content: string;
@@ -113,6 +133,19 @@ export const docsFile = function docsFile(opts?: DocsFileOpts): Generator {
113133
files.push({ path, content });
114134
}
115135

136+
// Emit the neutral OVERVIEW/index page (README.md) at the docs root: the
137+
// whole-model Mermaid ER diagram (reusing the single shared
138+
// renderMermaidErBlock builder — no duplicated ER logic, ADR-0020) plus a
139+
// navigable index linking every entity + template page. Prepended so it is
140+
// the first file (and so README is included in the collision backstop).
141+
// Only emitted when at least one page exists — an all-filtered/empty run
142+
// produces nothing (no orphan landing page with an empty diagram).
143+
if (files.length > 0) {
144+
const indexContent = renderIndexPage(ctx.loadedRoot, layout, entityNodes, templateNodes);
145+
placements.push({ path: INDEX_FILENAME, fqn: "<overview>" });
146+
files.unshift({ path: INDEX_FILENAME, content: indexContent });
147+
}
148+
116149
// Hard backstop against silent overwrite (ALL layouts): two nodes that
117150
// resolve to the same output path → throw naming both FQNs + the path.
118151
assertNoDuplicateDocPaths(placements);
@@ -124,3 +157,58 @@ export const docsFile = function docsFile(opts?: DocsFileOpts): Generator {
124157
if (opts?.target) generator.target = opts.target;
125158
return generator;
126159
} as GeneratorFactory<DocsFileOpts>;
160+
161+
/** Build the neutral OVERVIEW/index page (README.md) body: a title + one-line
162+
* description, the whole-model Mermaid ER diagram (the shared
163+
* renderMermaidErBlock — ONE builder, no duplication), and a navigable index
164+
* grouping entity pages vs template pages. Every link is computed with
165+
* docPageHref(layout, INDEX_NODE, target) so it resolves in BOTH flat and
166+
* package layout (the index lives at the docs root). Fully neutral — Mermaid +
167+
* entity/template names + relationships only. */
168+
function renderIndexPage(
169+
root: MetaRoot,
170+
layout: OutputLayout,
171+
entityNodes: DocPageNode[],
172+
templateNodes: DocPageNode[],
173+
): string {
174+
const pkg = root.package;
175+
const out: string[] = [];
176+
out.push("# Data Model");
177+
out.push("");
178+
out.push(
179+
pkg !== undefined && pkg.length > 0
180+
? `Overview of the \`${pkg}\` metadata model — entities, their relationships, and output templates.`
181+
: "Overview of the metadata model — entities, their relationships, and output templates.",
182+
);
183+
out.push("");
184+
185+
// The whole-model ER diagram (shared neutral builder). Per-entity prose stays
186+
// on each entity page; only the fenced erDiagram block lives on the overview.
187+
out.push("## Diagram");
188+
out.push("");
189+
out.push(renderMermaidErBlock(root));
190+
out.push("");
191+
192+
// Navigable index — grouped, sorted by name for stable output. Links resolve
193+
// in both layouts because docPageHref derives them from the same placement.
194+
const byName = (a: DocPageNode, b: DocPageNode) => a.name.localeCompare(b.name);
195+
if (entityNodes.length > 0) {
196+
out.push("## Entities");
197+
out.push("");
198+
for (const node of [...entityNodes].sort(byName)) {
199+
out.push(`- [${node.name}](${docPageHref(layout, INDEX_NODE, node)})`);
200+
}
201+
out.push("");
202+
}
203+
if (templateNodes.length > 0) {
204+
out.push("## Templates");
205+
out.push("");
206+
for (const node of [...templateNodes].sort(byName)) {
207+
out.push(`- [${node.name}](${docPageHref(layout, INDEX_NODE, node)})`);
208+
}
209+
out.push("");
210+
}
211+
212+
// Trailing newline (one), matching the per-page convention.
213+
return out.join("\n").replace(/\n+$/, "\n");
214+
}

server/typescript/packages/codegen-ts/src/generators/mermaid-er.ts

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -12,6 +12,14 @@ export interface MermaidErOptions {
1212
* Emit a single Markdown file containing a Mermaid `erDiagram` plus per-entity
1313
* prose sections. The renderer walks the loaded root for all entities; the
1414
* default outFile is "docs/model.md".
15+
*
16+
* TIER NOTE (ADR-0020): the CANONICAL home of the neutral ER diagram is now the
17+
* neutral docs engine — the OVERVIEW/index page (`README.md`) emitted by
18+
* `docsFile()` / `meta docs` embeds the SAME `renderMermaidErBlock()` this
19+
* generator's `renderMermaidModel()` reuses. This standalone Tier-1 generator is
20+
* retained ONLY as a thin back-compat wrapper for adopters that already have
21+
* `mermaidErDiagram` in their `meta gen` config; it adds NO ER logic of its own
22+
* (no duplication) — it delegates to the shared `renderMermaidModel()` builder.
1523
*/
1624
export const mermaidErDiagram = function mermaidErDiagram(
1725
opts?: MermaidErOptions,

server/typescript/packages/codegen-ts/src/templates/mermaid-er.ts

Lines changed: 26 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -8,17 +8,20 @@ import type { MetaObject, MetaRoot } from "@metaobjectsdev/metadata";
88
import { DOC_ATTR_DESCRIPTION } from "@metaobjectsdev/metadata";
99
import { readDocAttrs } from "./jsdoc.js";
1010

11-
/** Render a docs/model.md body: Mermaid erDiagram + per-entity prose. Abstract
12-
* entities are excluded — they have no physical table to put in a diagram
13-
* (matches migrate-ts/expected-schema.ts's same filter). */
14-
export function renderMermaidModel(root: MetaRoot): string {
11+
/** Render JUST the fenced ```mermaid erDiagram``` block for the whole model
12+
* (entities + identity.reference relationships) — NO per-entity prose. This is
13+
* the single neutral ER-diagram builder; both `renderMermaidModel()` (the
14+
* standalone model.md body) AND the neutral docs OVERVIEW page (`README.md`,
15+
* emitted by the Tier-2 `meta docs` engine) consume it, so there is exactly
16+
* ONE place ER topology is computed — no duplicate graph logic (ADR-0020).
17+
*
18+
* Abstract entities are excluded — they have no physical table to put in a
19+
* diagram (matches migrate-ts/expected-schema.ts's same filter). */
20+
export function renderMermaidErBlock(root: MetaRoot): string {
1521
const entities = root
1622
.objects()
1723
.filter((o) => o.isEntity() && !o.isAbstract);
1824
const parts: string[] = [];
19-
20-
parts.push("# Data Model");
21-
parts.push("");
2225
parts.push("```mermaid");
2326
parts.push("erDiagram");
2427
for (const line of renderRelationships(entities)) parts.push(` ${line}`);
@@ -27,6 +30,22 @@ export function renderMermaidModel(root: MetaRoot): string {
2730
for (const line of renderEntityBlock(entity)) parts.push(` ${line}`);
2831
}
2932
parts.push("```");
33+
return parts.join("\n");
34+
}
35+
36+
/** Render a docs/model.md body: Mermaid erDiagram + per-entity prose. Abstract
37+
* entities are excluded — they have no physical table to put in a diagram
38+
* (matches migrate-ts/expected-schema.ts's same filter). Reuses the shared
39+
* `renderMermaidErBlock()` for the diagram so the ER logic is never duplicated. */
40+
export function renderMermaidModel(root: MetaRoot): string {
41+
const entities = root
42+
.objects()
43+
.filter((o) => o.isEntity() && !o.isAbstract);
44+
const parts: string[] = [];
45+
46+
parts.push("# Data Model");
47+
parts.push("");
48+
parts.push(renderMermaidErBlock(root));
3049
parts.push("");
3150

3251
for (const entity of entities) {

0 commit comments

Comments
 (0)