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" ;
2222import { TYPE_TEMPLATE , TEMPLATE_SUBTYPE_OUTPUT } from "@metaobjectsdev/metadata" ;
2323import { render } from "@metaobjectsdev/render" ;
2424import type { Generator , GeneratorFactory , EmittedFile } from "../generator.js" ;
2525import {
2626 docPageOutputPath ,
27+ docPageHref ,
2728 docPageNode ,
2829 assertNoDuplicateDocPaths ,
30+ type DocPageNode ,
2931 type DocPagePlacement ,
3032} from "../docs-paths.js" ;
3133import { projectProvider } from "../render-engine/framework-provider.js" ;
34+ import { renderMermaidErBlock } from "../templates/mermaid-er.js" ;
3235import { buildEntityDocData } from "./docs-data-builder.js" ;
3336import { 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
3546export 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+ }
0 commit comments