From 60fe0df727a8275a255845934d3f0fcbec61f3dd Mon Sep 17 00:00:00 2001 From: Doug Mealing Date: Sat, 4 Jul 2026 10:59:30 -0400 Subject: [PATCH 01/22] =?UTF-8?q?docs(spec):=20docs-site=20=E2=80=94=20HTM?= =?UTF-8?q?L=20documentation=20site=20as=20a=20meta=20docs=20--site=20surf?= =?UTF-8?q?ace=20(3-phase=20design)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Opus 4.8 (1M context) --- .../specs/2026-07-04-docs-site-design.md | 131 ++++++++++++++++++ 1 file changed, 131 insertions(+) create mode 100644 docs/superpowers/specs/2026-07-04-docs-site-design.md diff --git a/docs/superpowers/specs/2026-07-04-docs-site-design.md b/docs/superpowers/specs/2026-07-04-docs-site-design.md new file mode 100644 index 000000000..168f84e34 --- /dev/null +++ b/docs/superpowers/specs/2026-07-04-docs-site-design.md @@ -0,0 +1,131 @@ +# Docs Site — Design + +**Status:** approved-direction, phase-decomposed · **Date:** 2026-07-04 · **Branch:** `feat/docs-site` + +## Problem + +`meta docs` today emits Tier-2 neutral documentation as **markdown** surfaces (`--model` entity/template +pages, `--api` surface, `--metamodel`) plus `mermaid-er` diagrams. That is ideal for the in-repo / GitHub / +agent surface (README-rendered, token-frugal `AGENT-API.md`). It is **not** a browsable, web-publishable +reference: no navigation shell, no search, no rich per-object pages, and the ER diagrams don't distinguish +object kinds or cover the full relationship model. + +A rich, multi-page **HTML documentation site** generator was prototyped end-to-end in a downstream adopter and +proven at ~650 pages: a 3-region shell (package nav tree · grouped collapsible content sections · sticky +"on this page" TOC + legend), Cmd+K fuzzy search, per-object/package/prompt/output pages that surface +validators, index tuning detail, relationships, inheritance, and field provenance, and a diagram system that +encodes **domain by fill color** and **object kind by shape/glyph** (rectangle=entity, stadium=value object, +parallelogram=view), fit-to-width in bounded scrollable boxes. Output is deterministic and link-checked. + +This design brings that generator into the metaobjects monorepo as a first-class, reusable capability that any +metaobjects project can adopt — a new **web-publishing** doc surface that sits alongside (does not replace) +the markdown surfaces. + +## Goals / Non-goals + +**Goals:** a versioned `@metaobjectsdev/docs-site` package + a `meta docs --site` surface; the rich HTML site +available to any metaobjects model; the diagrams eventually cover **every relationship the metamodel supports** +(built on the shared relationship IR, not a hand-rolled subset); hybrid ownership per ADR-0034 (engine as a +dependency; thin config + templates + assets scaffolded into the consumer and owned there); deterministic, +link-checked output; a generic `acme` fixture + tests. + +**Non-goals:** replacing the markdown `--model`/`--api`/`--metamodel` surfaces or `api-docs` (they serve the +GitHub/agent surface and stay); a runtime/server component (this is a static generator); non-mustache theming +engines. No consumer-specific content ships in the package (public repo). + +## Architecture — where it lands + +- **Package:** `server/typescript/packages/docs-site` → `@metaobjectsdev/docs-site`, sibling to + `codegen-ts-react` / `-angular` / `-tanstack`. Depends on workspace packages `@metaobjectsdev/metadata` + + `@metaobjectsdev/render` and on `yaml`. +- **Public engine API:** `generateSite(opts: SiteOptions): Promise` — `SiteOptions = { sourceDirs: + string[]; outDir: string; title: string; stamp: string; commit: string; core?: { n?: number }; + templatesDir?: string }`. The `templatesDir` override is the scaffold-and-own seam (a consumer copy wins over + the bundled template of the same name). +- **CLI surface:** a new `--site` surface on `meta docs` (sibling to `--model`/`--api`/`--metamodel`). When + requested, the command loads the model, resolves the source dirs, and calls `generateSite`. Markdown surfaces + and `mermaid-er` are untouched — `--site` is additive (a consumer can emit markdown for GitHub AND a site for + the web). +- **Doctrine fit (ADR-0022):** the site is a **Tier-2 neutral** documentation output owned by `meta docs`, not + a native `meta gen` generator (it documents the model, not the generated code's API surface). + +## Phase decomposition + +This is delivered in three phases on one branch (`feat/docs-site`), each an independent, testable unit. + +### Phase 1 — Port as `meta docs --site` (this spec's primary deliverable) + +Bring the proven generator in **as-is** (keeping its current graph), wired as the `--site` surface. + +- Create `server/typescript/packages/docs-site` with the ported `src/` (loader, link-graph, page builders, + mermaid emitters, site orchestrator, link-check), `templates/`, `assets/`, the **generic `acme` fixture**, + and the **byte-identical golden test + unit tests** (they are already model-agnostic — verbatim port). +- Wire the `--site` surface into `meta docs` (`server/typescript/packages/cli/src/commands/docs.ts`): parse + `--site`, thread `outDir`/`title`, call `generateSite`. +- **Keep the current LinkGraph/builders/mermaid** — the shared-IR consolidation is Phase 2. Phase 1 ships the + proven output, now living in metaobjects and runnable by any project. +- **Success:** `@metaobjectsdev/docs-site` builds in the monorepo; its ported tests pass (golden byte-identical + + deterministic on double-generate, link-check green); `meta docs --site --out ` on the `acme` fixture + emits a working site. + +Detailed steps live in the Phase-1 implementation plan. + +### Phase 2 — Consolidate the graph onto the shared relationship IR + +Keep the **presentation layer** (templates, assets, mermaid theming, the kind-shape/domain-color diagram +doctrine, page structure) and **replace the graph/derivation layer** so the diagrams cover every relationship +the metamodel supports. + +- metaobjects' relationship engine (`derive-m2m-fields`, and the relations IR that `buildApiModel` exposes) + already models what the ported LinkGraph does not: M:N through junction entities (`@through`), belongs-to vs + has-many directions (1:N inverse), self-joins (directed via `@sourceRefField`, symmetric via `@symmetric`), + `@cardinality`/`@onDelete`, and attributes resolved through `extends` (ADR-0039). +- Source the site's edges from that shared derivation instead of the hand-rolled `LinkGraph` edge set; keep the + raw-metadata reads the neutral pages need (fields, indexes, validators, identities) that the API-surface IR + may not expose — extend the shared IR where a neutral-doc datum is missing rather than re-deriving + relationships. +- **Success:** the site's neighborhood + core diagrams render M:N-through-junction, direction, and self-join + relationships; a fixture exercising each; golden regenerated + deterministic; presentation output unchanged + except for the newly-covered edges. New metamodel relationship types flow into the docs automatically. + +### Phase 3 — Scaffold-and-own wiring + first consumers + +- Per ADR-0034, `meta init` (or `meta docs --scaffold-site`) copies the thin config + the mustache templates + + CSS/JS assets into the consumer's `codegen/` (or `docs/`) so the app owns its theme; the engine stays a + versioned dependency. The bundled templates are the fallback; a consumer copy of the same name wins via + `templatesDir`. +- Convert the prototyping adopter from its private copy of the generator to consuming `@metaobjectsdev/docs-site` + (dogfood), and document the adoption in the agent-context docs surface. +- **Success:** a fresh `meta init`-scaffolded project can run `meta docs --site` and re-theme by editing its + owned templates; the reference adopter builds its site from the package with no local generator copy. + +## Testing & determinism + +- The ported unit + golden tests run in the monorepo's bun test suite. The golden fixture site is byte-identical + across regenerations; a link checker fails generation on any dangling link. +- Escaping invariant (carried from the prototype): `@metaobjectsdev/render`'s `render()` does not HTML-escape, + so every authored/free-text value reaching a single-stache slot or HTML attribute passes through the single + canonical `esc` at the builder boundary; triple-stache slots receive only builder-produced escaped HTML. +- Deterministic output: all emitted lists/edges/nodes/classDefs sort; no Map/Set insertion-order leakage. + +## Constraints + +- **Public repo:** metaobjects is public. No consumer/client names, no absolute local paths, no private content + — the package ships only the generic `acme` fixture and generic docs. Verify the diff before every commit. +- **Node IDs / labels:** diagram node IDs are sanitized to valid mermaid identifiers; flowchart edge labels + strip parser-breaking chars; large attribute-ERDs are capped (kind/detail) since they are fragile in mermaid. +- **Additive:** markdown `--model`/`--api`/`--metamodel` + `api-docs` + `mermaid-er` are unchanged. + +## Risks + +- The shared relationship IR (`buildApiModel`) is API-surface-oriented; Phase 2 may need to extend it to expose + neutral-doc data (indexes/validators/per-field) — scoped as "extend, don't re-derive." +- Mermaid `securityLevel: "loose"` is enabled for HTML labels; labels are identifier-derived and sanitized, but + any future free-text label must be escaped first (tracked follow-up). +- Diagram node IDs are keyed by simple name (rare same-name cross-package collision) — key by fqn if it surfaces. + +## Rollout + +All three phases land on `feat/docs-site` (metaobjects worktree). Implemented via superpowers writing-plans → +subagent-driven-development, one plan per phase, preserving the golden/link-check gates. A PR opens when the +branch is ready; a subsequent session may complete later phases. From ac8e7b1d6547b2eab4c7f0752e4c8ba7310031b0 Mon Sep 17 00:00:00 2001 From: Doug Mealing Date: Sat, 4 Jul 2026 11:22:34 -0400 Subject: [PATCH 02/22] =?UTF-8?q?docs(plan):=20docs-site=20Phase=201=20?= =?UTF-8?q?=E2=80=94=20port=20as=20@metaobjectsdev/docs-site=20+=20meta=20?= =?UTF-8?q?docs=20--site=20(3=20tasks)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Opus 4.8 (1M context) --- .../plans/2026-07-04-docs-site-phase1.md | 221 ++++++++++++++++++ 1 file changed, 221 insertions(+) create mode 100644 docs/superpowers/plans/2026-07-04-docs-site-phase1.md diff --git a/docs/superpowers/plans/2026-07-04-docs-site-phase1.md b/docs/superpowers/plans/2026-07-04-docs-site-phase1.md new file mode 100644 index 000000000..c4c9e7bc6 --- /dev/null +++ b/docs/superpowers/plans/2026-07-04-docs-site-phase1.md @@ -0,0 +1,221 @@ +# Docs Site — Phase 1 (Port as `meta docs --site`) Implementation Plan + +> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. + +**Goal:** Bring the proven HTML documentation-site generator into the metaobjects monorepo as `@metaobjectsdev/docs-site`, wired as a new `meta docs --site` surface, keeping its current graph/output verbatim. + +**Architecture:** A verbatim port of a model-agnostic bun/TypeScript generator (loader → link-graph → page builders → mermaid emitters → site orchestrator → link-check) into a new workspace package, plus a thin `--site` branch in the `meta docs` CLI command that calls the package's `generateSite()`. No behavior change to the generator; the shared-relationship-IR consolidation is Phase 2. + +**Tech Stack:** bun, TypeScript (monorepo `tsconfig.base.json`), `@metaobjectsdev/metadata` + `@metaobjectsdev/render` (workspace deps), `yaml`, mustache templates, Tailwind/daisyUI + mermaid (CDN, referenced by the site's chrome). + +## Global Constraints + +- **Public repo:** metaobjects is public. The package ships ONLY the generic `acme` fixture + generic docs — no downstream/consumer/client names, no absolute local paths. Verify `git diff` before every commit. +- **Verbatim port:** the generator's `src/`, `templates/`, `assets/`, `test/`, and `test/fixture/` are copied UNCHANGED from the reference implementation. Do not "improve" them here — Phase 2 owns graph changes. The ONLY new/edited files are the package manifest/tsconfig, `src/index.ts`, and the CLI `--site` wiring. +- **Determinism + link-check preserved:** the byte-identical golden test and the link checker (generation throws on dangling links) must stay green; two regenerations must be byte-identical. +- **Escaping invariant:** `@metaobjectsdev/render`'s `render()` does not HTML-escape; the ported builders already escape at the boundary via the single canonical `esc`. Do not change this. +- **Package conventions:** match the sibling TS packages (`codegen-ts-react` etc.): `"type": "module"`, `exports` with a `bun → ./src/index.ts` condition, `tsc -p .` build to `dist`, `bun test`, workspace deps as `workspace:*`, `"license": "Apache-2.0"`, `publishConfig.access: public`. + +## Source of the ported files + +The verbatim-port files come from the reference implementation's `metadata-docs` tree. The exact file list is fixed (below). If a reviewer/implementer does not have that tree mounted, the files are available on the `feat/docs-site` branch history where the controller placed them; treat the on-branch copies as the source of truth. Do NOT re-derive them. + +**Port these UNCHANGED** into `server/typescript/packages/docs-site/`: +- `src/`: `badges.ts`, `coverage.ts`, `link-check.ts`, `link-graph.ts`, `load.ts`, `mermaid.ts`, `mustache-highlight.ts`, `package-docs.ts`, `site.ts`, `yaml-comments.ts`, and `src/builders/`: `extras.ts`, `index-data.ts`, `object-data.ts`, `output-data.ts`, `package-data.ts`, `prompt-data.ts`. +- `templates/`: the 9 `*.mustache` files (`chrome-head`, `chrome-foot`, `index.html`, `package.html`, `object.html`, `prompt.html`, `output.html`, `enums.html`, `coverage.html`). +- `assets/`: `site.css`, `site.js`. +- `test/`: the 14 `*.test.ts` files (`badges`, `coverage`, `golden`, `graph-v2`, `index-package-v2`, `link-check`, `link-graph`, `load`, `mermaid`, `mustache-highlight`, `object-data`, `object-data-v2`, `package-docs`, `package-index-data`, `prompt-output-extras`, `site` — note some map 1:1) and `test/fixture/` (`input/acme/**` + `golden/**`). +- **Do NOT port** the reference `generate.ts` (it is the downstream entry point; the CLI replaces it) or `package.json`/`bun.lock` (the monorepo manifest replaces them). + +--- + +## File Structure + +New package `server/typescript/packages/docs-site/`: +- `package.json` — NEW (manifest; deps `@metaobjectsdev/metadata` + `@metaobjectsdev/render` `workspace:*`, `yaml`). +- `tsconfig.json`, `tsconfig.typecheck.json` — NEW (extend `../../tsconfig.base.json`, matching siblings). +- `README.md`, `LICENSE` — NEW (LICENSE copied from a sibling package). +- `src/**` — ported verbatim + `src/index.ts` NEW (public API barrel). +- `templates/**`, `assets/**` — ported verbatim (live at package root so `resolve(import.meta.dir, "../templates")` resolves from both `src/` and `dist/`). +- `test/**` — ported verbatim. + +Modified: +- `server/typescript/packages/cli/src/commands/docs.ts` — add the `--site` surface. + +--- + +## Task 1: Scaffold the package + port the generator; `bun test` green + +**Files:** +- Create: `server/typescript/packages/docs-site/package.json`, `tsconfig.json`, `tsconfig.typecheck.json`, `README.md`, `LICENSE`, `src/index.ts` +- Create (verbatim port): all `src/**`, `templates/**`, `assets/**`, `test/**` per "Source of the ported files" +- Test: the ported `test/**` (run via `bun test`) + +**Interfaces:** +- Produces: `@metaobjectsdev/docs-site` exporting `generateSite(opts: SiteOptions): Promise`, `SiteOptions = { sourceDirs: string[]; outDir: string; title: string; stamp: string; commit: string; core?: { n?: number }; templatesDir?: string }`, `SiteResult = { pages: string[]; coverage: CoverageReport; anomalies: Anomaly[]; dangling: string[] }` (re-exported from `./src/site`). + +- [ ] **Step 1: Create the package dir + port the files verbatim.** + +```bash +cd server/typescript/packages +mkdir -p docs-site +# from the reference tree (or the branch copies), copy UNCHANGED: +cp -R /src docs-site/src +cp -R /templates docs-site/templates +cp -R /assets docs-site/assets +cp -R /test docs-site/test +# LICENSE from a sibling: +cp codegen-ts-react/LICENSE docs-site/LICENSE +``` +Confirm no `generate.ts`, no `package.json`, no `bun.lock`, no `node_modules`, no `mockup/` were copied. + +- [ ] **Step 2: Write `docs-site/package.json`** (manifest matching siblings; deps as workspace): + +```json +{ + "name": "@metaobjectsdev/docs-site", + "version": "0.15.6", + "description": "HTML documentation-site generator for metaobjects models — a browsable multi-page site (nav, search, per-object/package pages, kind-aware ER diagrams). The `meta docs --site` surface.", + "type": "module", + "main": "./dist/index.js", + "types": "./dist/index.d.ts", + "exports": { + ".": { "bun": "./src/index.ts", "types": "./dist/index.d.ts", "default": "./dist/index.js" } + }, + "files": ["dist", "src", "templates", "assets", "README.md", "LICENSE"], + "scripts": { "build": "tsc -p .", "typecheck": "tsc -p tsconfig.typecheck.json", "test": "bun test" }, + "license": "Apache-2.0", + "author": "Doug Mealing ", + "homepage": "https://metaobjects.dev", + "bugs": { "url": "https://github.com/metaobjectsdev/metaobjects/issues" }, + "repository": { "type": "git", "url": "https://github.com/metaobjectsdev/metaobjects.git", "directory": "server/typescript/packages/docs-site" }, + "keywords": ["metaobjects", "docs", "documentation", "html", "site", "erd"], + "publishConfig": { "access": "public" }, + "dependencies": { + "@metaobjectsdev/metadata": "workspace:*", + "@metaobjectsdev/render": "workspace:*", + "yaml": "^2.9.0" + }, + "devDependencies": { "bun-types": "latest", "typescript": "^5.6.0" } +} +``` + +- [ ] **Step 3: Write `docs-site/tsconfig.json` and `tsconfig.typecheck.json`** (match siblings): + +`tsconfig.json`: +```json +{ "extends": "../../tsconfig.base.json", "compilerOptions": { "outDir": "./dist", "rootDir": "./src" }, "include": ["src/**/*"], "exclude": ["dist", "test", "node_modules"] } +``` +`tsconfig.typecheck.json` (copy the sibling's — typically includes `src` + `test` with `noEmit`; mirror `codegen-ts-react/tsconfig.typecheck.json` exactly). + +- [ ] **Step 4: Write `docs-site/src/index.ts`** (public API barrel): + +```ts +export { generateSite } from "./site"; +export type { SiteOptions, SiteResult } from "./site"; +``` +(If `site.ts` does not already export `SiteOptions`/`SiteResult` as named types, confirm it does — the reference version exports both. Do not add new logic.) + +- [ ] **Step 5: Install + run the ported tests.** From the repo root: + +```bash +bun install +bun test server/typescript/packages/docs-site +``` +Expected: all ported tests pass (the golden byte-identical test + graph/builder/mermaid/link-check unit tests). If the golden test fails, the FIRST suspect is template/asset resolution: `resolve(import.meta.dir, "../templates")` must resolve to `docs-site/templates`. Since bun runs `src/site.ts` directly, `import.meta.dir` = `docs-site/src`, so `../templates` = `docs-site/templates` ✓ and `../assets` = `docs-site/assets` ✓. If a test references a path that assumed the old repo layout, fix the TEST's fixture path only (never the generator's behavior); report any such change. + +- [ ] **Step 6: Hygiene check + commit.** + +```bash +grep -rniE "|/home/" server/typescript/packages/docs-site/ && echo "LEAK — stop" || echo clean +git add server/typescript/packages/docs-site +git commit -m "feat(docs-site): port HTML documentation-site generator as @metaobjectsdev/docs-site (verbatim, acme fixture, tests green)" +``` + +--- + +## Task 2: Build to `dist` + typecheck green (dist template/asset resolution) + +**Files:** +- Modify: none (verifies the Task-1 package builds); may adjust `files`/paths only if the build reveals a gap. + +**Interfaces:** +- Consumes: the Task-1 package. +- Produces: a built `dist/` with working template/asset resolution, so downstream consumers importing the built package (not the `bun` src condition) still find templates. + +- [ ] **Step 1: Build.** `bun run --cwd server/typescript/packages/docs-site build` (i.e. `tsc -p .`). Expected: `dist/` emitted, no type errors. +- [ ] **Step 2: Typecheck.** `bun run --cwd server/typescript/packages/docs-site typecheck`. Expected: clean. +- [ ] **Step 3: Verify dist template resolution.** From a scratch script, import the BUILT package (force the `default`/`dist` condition, not `bun`) and generate against the fixture into a tmp dir; confirm it does not throw and writes pages. Because `dist/site.js` sits at `docs-site/dist/`, `resolve(import.meta.dir, "../templates")` = `docs-site/templates` ✓ — templates/assets are NOT under `dist`, and are included in `files`, so both bun-src and dist runs resolve them. If the built run cannot find templates, the fix is to ensure `templates`/`assets` are in `package.json` `files` (already are) and that the resolution is `../templates` from `dist` (it is) — do NOT copy templates into `dist`. +- [ ] **Step 4: Commit** (only if any manifest/path adjustment was needed): + +```bash +git commit -am "build(docs-site): confirm dist build + template/asset resolution from built output" +``` + +--- + +## Task 3: Wire the `meta docs --site` surface + end-to-end verify + +**Files:** +- Modify: `server/typescript/packages/cli/src/commands/docs.ts` (add `--site` flag + emit branch) +- Modify: `server/typescript/packages/cli/package.json` (add `@metaobjectsdev/docs-site` as a `workspace:*` dependency) +- Modify: `server/typescript/packages/cli/src/index.ts` (help text: mention `--site`) + +**Interfaces:** +- Consumes: `generateSite` from `@metaobjectsdev/docs-site`. + +- [ ] **Step 1: Add the dependency.** In `cli/package.json` dependencies add `"@metaobjectsdev/docs-site": "workspace:*"`; run `bun install` from the repo root. + +- [ ] **Step 2: Parse the `--site` flag.** In `docs.ts`'s arg loop (alongside `--model`/`--api`/`--metamodel`), add: + +```ts +} else if (a === "--site") { + wantSite = true; +``` +Declare `let wantSite = false;` next to `wantModel`/`wantApi`/`wantMetamodel`, and return it from the parse result (extend the options type with `site: boolean` mirroring `metamodel: boolean`). + +- [ ] **Step 3: Emit the site when `--site` is set.** In the command's run body, AFTER the existing surface handling, add a branch that resolves the metadata source dir(s) the command already computes (the `` root's `metaobjects/` source path used to load the model — reuse the SAME dirs the model loader reads), then calls `generateSite`: + +```ts +import { generateSite } from "@metaobjectsdev/docs-site"; +// ... after model/api/metamodel handling, before the final summary: +if (opts.site) { + const outDir = /* the resolved out dir, default ./docs/site when --site */; + const r = await generateSite({ + sourceDirs: /* the resolved metadata source dirs (same the model loader uses) */, + outDir, + title: /* project/model title — reuse the command's existing title source, else "Metadata" */, + stamp: new Date().toISOString().slice(0, 10), + commit: "", // CLI has no repo commit context by default; empty is fine + core: { n: 15 }, + }); + // reuse the command's existing "wrote N files" reporting shape for the site +} +``` +Notes for the implementer: (a) find how the command resolves the metadata source directory it feeds to the loader — pass those exact dirs as `sourceDirs`; (b) default the site out dir to `./docs/site` (parallel to the markdown `./docs`); (c) `--templates ` (already parsed) should be threaded as `templatesDir` so the scaffold-and-own override works for the site too; (d) `--site` is additive — if combined with `--model`/`--api`, emit both. + +- [ ] **Step 4: Help text.** In `cli/src/index.ts` `docs` help, add a line: ` --site generate the browsable HTML documentation site (docs/site/)`. + +- [ ] **Step 5: End-to-end verify.** Build the cli (`bun run --cwd server/typescript/packages/cli build` if it builds; else run via bun src). Run the CLI's `docs --site` against the ported `acme` fixture model into a tmp dir: + - it MUST NOT throw (link-check green), + - it writes `index.html` + per-package/object pages + `assets/`, + - two runs are byte-identical (determinism). + Grep the output for `party.?lore`/`/home/` → none. + +- [ ] **Step 6: Hygiene + commit.** + +```bash +grep -rniE "|/home/" server/typescript/packages/cli/src/commands/docs.ts && echo "LEAK — stop" || echo clean +git add server/typescript/packages/cli +git commit -m "feat(cli): meta docs --site surface — emit the HTML documentation site via @metaobjectsdev/docs-site" +``` + +--- + +## Self-Review + +**Spec coverage (Phase 1):** new `@metaobjectsdev/docs-site` package → Task 1; `meta docs --site` surface, additive to markdown → Task 3; verbatim port of generator + `acme` fixture + tests → Task 1; deterministic + link-checked → Tasks 1 & 3; public-repo hygiene → hygiene checks in Tasks 1 & 3; dist build + template resolution → Task 2; escaping/determinism invariants preserved (no generator edits) → Global Constraints. Phase 2 (shared-IR graph) and Phase 3 (scaffold-and-own + consumers) are out of scope by design. + +**Placeholder scan:** the `/* ... */` comments in Task 3 Step 3 are integration-point pointers, not code placeholders — the surrounding code + notes tell the implementer exactly what to substitute (the command's own resolved source dirs / out / title). They are unavoidable because they reference the CLI command's existing local variables, which the implementer reads in-file. Everything else is concrete. + +**Type consistency:** `generateSite`/`SiteOptions`/`SiteResult` names match across Tasks 1 and 3; `wantSite`/`site: boolean` mirror the existing `wantMetamodel`/`metamodel: boolean` pattern in `docs.ts`; `templatesDir` matches `SiteOptions`. From eb605ece04d7433217d94f8240b9fb8364b34cbe Mon Sep 17 00:00:00 2001 From: Doug Mealing Date: Sat, 4 Jul 2026 11:23:42 -0400 Subject: [PATCH 03/22] feat(docs-site): port HTML documentation-site generator as @metaobjectsdev/docs-site Verbatim port of the model-agnostic generator (loader, link-graph, page builders, mermaid emitters, site orchestrator, link-check) + templates + assets + generic acme fixture + tests, wired as a workspace package. 29 tests pass; golden byte-identical. Phase 1 of docs-site (meta docs --site); CLI wiring in a follow-up task. Co-Authored-By: Claude Opus 4.8 (1M context) --- server/typescript/packages/docs-site/LICENSE | 189 ++++++++++++ .../packages/docs-site/assets/site.css | 13 + .../packages/docs-site/assets/site.js | 25 ++ .../packages/docs-site/package.json | 20 ++ .../packages/docs-site/src/badges.ts | 26 ++ .../packages/docs-site/src/builders/extras.ts | 61 ++++ .../docs-site/src/builders/index-data.ts | 89 ++++++ .../docs-site/src/builders/object-data.ts | 250 ++++++++++++++++ .../docs-site/src/builders/output-data.ts | 35 +++ .../docs-site/src/builders/package-data.ts | 134 +++++++++ .../docs-site/src/builders/prompt-data.ts | 61 ++++ .../packages/docs-site/src/coverage.ts | 29 ++ .../packages/docs-site/src/index.ts | 2 + .../packages/docs-site/src/link-check.ts | 24 ++ .../packages/docs-site/src/link-graph.ts | 153 ++++++++++ .../typescript/packages/docs-site/src/load.ts | 47 +++ .../packages/docs-site/src/mermaid.ts | 137 +++++++++ .../docs-site/src/mustache-highlight.ts | 43 +++ .../packages/docs-site/src/package-docs.ts | 33 +++ .../typescript/packages/docs-site/src/site.ts | 275 ++++++++++++++++++ .../packages/docs-site/src/yaml-comments.ts | 33 +++ .../docs-site/templates/chrome-foot.mustache | 17 ++ .../docs-site/templates/chrome-head.mustache | 21 ++ .../templates/coverage.html.mustache | 24 ++ .../docs-site/templates/enums.html.mustache | 14 + .../docs-site/templates/index.html.mustache | 54 ++++ .../docs-site/templates/object.html.mustache | 46 +++ .../docs-site/templates/output.html.mustache | 16 + .../docs-site/templates/package.html.mustache | 43 +++ .../docs-site/templates/prompt.html.mustache | 28 ++ .../packages/docs-site/test/badges.test.ts | 14 + .../packages/docs-site/test/coverage.test.ts | 28 ++ .../test/fixture/golden/acme/ai/ItemView.html | 109 +++++++ .../fixture/golden/acme/ai/NpcPayload.html | 111 +++++++ .../fixture/golden/acme/ai/NpcResponse.html | 95 ++++++ .../fixture/golden/acme/ai/OrderLine.html | 118 ++++++++ .../test/fixture/golden/acme/ai/OrphanVO.html | 90 ++++++ .../test/fixture/golden/acme/ai/index.html | 115 ++++++++ .../fixture/golden/acme/ai/npcReview.html | 83 ++++++ .../golden/acme/ai/npcReviewOutput.html | 74 +++++ .../golden/acme/common/BaseEntity.html | 133 +++++++++ .../fixture/golden/acme/common/index.html | 75 +++++ .../fixture/golden/acme/shop/Customer.html | 137 +++++++++ .../golden/acme/shop/LineItemView.html | 121 ++++++++ .../test/fixture/golden/acme/shop/Order.html | 153 ++++++++++ .../fixture/golden/acme/shop/OrphanLog.html | 92 ++++++ .../test/fixture/golden/acme/shop/index.html | 109 +++++++ .../fixture/golden/assets/search-index.json | 1 + .../test/fixture/golden/assets/site.css | 13 + .../test/fixture/golden/assets/site.js | 25 ++ .../test/fixture/golden/coverage.html | 78 +++++ .../docs-site/test/fixture/golden/enums.html | 72 +++++ .../docs-site/test/fixture/golden/index.html | 130 +++++++++ .../test/fixture/input/acme/ai/meta.ai.yaml | 39 +++ .../fixture/input/acme/ai/npc-review.mustache | 7 + .../input/acme/common/meta.common.yaml | 14 + .../fixture/input/acme/shop/_package.yaml | 4 + .../fixture/input/acme/shop/meta.shop.yaml | 39 +++ .../packages/docs-site/test/golden.test.ts | 18 ++ .../packages/docs-site/test/graph-v2.test.ts | 21 ++ .../docs-site/test/index-package-v2.test.ts | 25 ++ .../docs-site/test/link-check.test.ts | 14 + .../docs-site/test/link-graph.test.ts | 26 ++ .../packages/docs-site/test/load.test.ts | 13 + .../packages/docs-site/test/mermaid.test.ts | 60 ++++ .../docs-site/test/mustache-highlight.test.ts | 30 ++ .../docs-site/test/object-data-v2.test.ts | 28 ++ .../docs-site/test/object-data.test.ts | 33 +++ .../docs-site/test/package-docs.test.ts | 18 ++ .../docs-site/test/package-index-data.test.ts | 34 +++ .../test/prompt-output-extras.test.ts | 62 ++++ .../packages/docs-site/test/site.test.ts | 15 + .../packages/docs-site/tsconfig.json | 1 + .../docs-site/tsconfig.typecheck.json | 9 + 74 files changed, 4428 insertions(+) create mode 100644 server/typescript/packages/docs-site/LICENSE create mode 100644 server/typescript/packages/docs-site/assets/site.css create mode 100644 server/typescript/packages/docs-site/assets/site.js create mode 100644 server/typescript/packages/docs-site/package.json create mode 100644 server/typescript/packages/docs-site/src/badges.ts create mode 100644 server/typescript/packages/docs-site/src/builders/extras.ts create mode 100644 server/typescript/packages/docs-site/src/builders/index-data.ts create mode 100644 server/typescript/packages/docs-site/src/builders/object-data.ts create mode 100644 server/typescript/packages/docs-site/src/builders/output-data.ts create mode 100644 server/typescript/packages/docs-site/src/builders/package-data.ts create mode 100644 server/typescript/packages/docs-site/src/builders/prompt-data.ts create mode 100644 server/typescript/packages/docs-site/src/coverage.ts create mode 100644 server/typescript/packages/docs-site/src/index.ts create mode 100644 server/typescript/packages/docs-site/src/link-check.ts create mode 100644 server/typescript/packages/docs-site/src/link-graph.ts create mode 100644 server/typescript/packages/docs-site/src/load.ts create mode 100644 server/typescript/packages/docs-site/src/mermaid.ts create mode 100644 server/typescript/packages/docs-site/src/mustache-highlight.ts create mode 100644 server/typescript/packages/docs-site/src/package-docs.ts create mode 100644 server/typescript/packages/docs-site/src/site.ts create mode 100644 server/typescript/packages/docs-site/src/yaml-comments.ts create mode 100644 server/typescript/packages/docs-site/templates/chrome-foot.mustache create mode 100644 server/typescript/packages/docs-site/templates/chrome-head.mustache create mode 100644 server/typescript/packages/docs-site/templates/coverage.html.mustache create mode 100644 server/typescript/packages/docs-site/templates/enums.html.mustache create mode 100644 server/typescript/packages/docs-site/templates/index.html.mustache create mode 100644 server/typescript/packages/docs-site/templates/object.html.mustache create mode 100644 server/typescript/packages/docs-site/templates/output.html.mustache create mode 100644 server/typescript/packages/docs-site/templates/package.html.mustache create mode 100644 server/typescript/packages/docs-site/templates/prompt.html.mustache create mode 100644 server/typescript/packages/docs-site/test/badges.test.ts create mode 100644 server/typescript/packages/docs-site/test/coverage.test.ts create mode 100644 server/typescript/packages/docs-site/test/fixture/golden/acme/ai/ItemView.html create mode 100644 server/typescript/packages/docs-site/test/fixture/golden/acme/ai/NpcPayload.html create mode 100644 server/typescript/packages/docs-site/test/fixture/golden/acme/ai/NpcResponse.html create mode 100644 server/typescript/packages/docs-site/test/fixture/golden/acme/ai/OrderLine.html create mode 100644 server/typescript/packages/docs-site/test/fixture/golden/acme/ai/OrphanVO.html create mode 100644 server/typescript/packages/docs-site/test/fixture/golden/acme/ai/index.html create mode 100644 server/typescript/packages/docs-site/test/fixture/golden/acme/ai/npcReview.html create mode 100644 server/typescript/packages/docs-site/test/fixture/golden/acme/ai/npcReviewOutput.html create mode 100644 server/typescript/packages/docs-site/test/fixture/golden/acme/common/BaseEntity.html create mode 100644 server/typescript/packages/docs-site/test/fixture/golden/acme/common/index.html create mode 100644 server/typescript/packages/docs-site/test/fixture/golden/acme/shop/Customer.html create mode 100644 server/typescript/packages/docs-site/test/fixture/golden/acme/shop/LineItemView.html create mode 100644 server/typescript/packages/docs-site/test/fixture/golden/acme/shop/Order.html create mode 100644 server/typescript/packages/docs-site/test/fixture/golden/acme/shop/OrphanLog.html create mode 100644 server/typescript/packages/docs-site/test/fixture/golden/acme/shop/index.html create mode 100644 server/typescript/packages/docs-site/test/fixture/golden/assets/search-index.json create mode 100644 server/typescript/packages/docs-site/test/fixture/golden/assets/site.css create mode 100644 server/typescript/packages/docs-site/test/fixture/golden/assets/site.js create mode 100644 server/typescript/packages/docs-site/test/fixture/golden/coverage.html create mode 100644 server/typescript/packages/docs-site/test/fixture/golden/enums.html create mode 100644 server/typescript/packages/docs-site/test/fixture/golden/index.html create mode 100644 server/typescript/packages/docs-site/test/fixture/input/acme/ai/meta.ai.yaml create mode 100644 server/typescript/packages/docs-site/test/fixture/input/acme/ai/npc-review.mustache create mode 100644 server/typescript/packages/docs-site/test/fixture/input/acme/common/meta.common.yaml create mode 100644 server/typescript/packages/docs-site/test/fixture/input/acme/shop/_package.yaml create mode 100644 server/typescript/packages/docs-site/test/fixture/input/acme/shop/meta.shop.yaml create mode 100644 server/typescript/packages/docs-site/test/golden.test.ts create mode 100644 server/typescript/packages/docs-site/test/graph-v2.test.ts create mode 100644 server/typescript/packages/docs-site/test/index-package-v2.test.ts create mode 100644 server/typescript/packages/docs-site/test/link-check.test.ts create mode 100644 server/typescript/packages/docs-site/test/link-graph.test.ts create mode 100644 server/typescript/packages/docs-site/test/load.test.ts create mode 100644 server/typescript/packages/docs-site/test/mermaid.test.ts create mode 100644 server/typescript/packages/docs-site/test/mustache-highlight.test.ts create mode 100644 server/typescript/packages/docs-site/test/object-data-v2.test.ts create mode 100644 server/typescript/packages/docs-site/test/object-data.test.ts create mode 100644 server/typescript/packages/docs-site/test/package-docs.test.ts create mode 100644 server/typescript/packages/docs-site/test/package-index-data.test.ts create mode 100644 server/typescript/packages/docs-site/test/prompt-output-extras.test.ts create mode 100644 server/typescript/packages/docs-site/test/site.test.ts create mode 100644 server/typescript/packages/docs-site/tsconfig.json create mode 100644 server/typescript/packages/docs-site/tsconfig.typecheck.json diff --git a/server/typescript/packages/docs-site/LICENSE b/server/typescript/packages/docs-site/LICENSE new file mode 100644 index 000000000..a31887862 --- /dev/null +++ b/server/typescript/packages/docs-site/LICENSE @@ -0,0 +1,189 @@ + Apache License + Version 2.0, January 2004 + http://www.apache.org/licenses/ + + TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION + + 1. Definitions. + + "License" shall mean the terms and conditions for use, reproduction, + and distribution as defined by Sections 1 through 9 of this document. + + "Licensor" shall mean the copyright owner or entity authorized by + the copyright owner that is granting the License. + + "Legal Entity" shall mean the union of the acting entity and all + other entities that control, are controlled by, or are under common + control with that entity. For the purposes of this definition, + "control" means (i) the power, direct or indirect, to cause the + direction or management of such entity, whether by contract or + otherwise, or (ii) ownership of fifty percent (50%) or more of the + outstanding shares, or (iii) beneficial ownership of such entity. + + "You" (or "Your") shall mean an individual or Legal Entity + exercising permissions granted by this License. + + "Source" form shall mean the preferred form for making modifications, + including but not limited to software source code, documentation + source, and configuration files. + + "Object" form shall mean any form resulting from mechanical + transformation or translation of a Source form, including but + not limited to compiled object code, generated documentation, + and conversions to other media types. + + "Work" shall mean the work of authorship made available under + the License, as indicated by a copyright notice that is included in + or attached to the work (an example is provided in the Appendix below). + + "Derivative Works" shall mean any work, whether in Source or Object + form, that is based on (or derived from) the Work and for which the + editorial revisions, annotations, elaborations, or other transformations + represent, as a whole, an original work of authorship. For the purposes + of this License, Derivative Works shall not include works that remain + separable from, or merely link (or bind by name) to the interfaces of, + the Work and Derivative Works thereof. + + "Contribution" shall mean, as submitted to the Licensor for inclusion + in the Work by the copyright owner or by an individual or Legal Entity + authorized to submit on behalf of the copyright owner. For the purposes + of this definition, "submitted" means any form of electronic, verbal, + or written communication sent to the Licensor or its representatives, + including but not limited to communication on electronic mailing lists, + source code control systems, and issue tracking systems that are managed + by, or on behalf of, the Licensor for the purpose of recording and + discussing modifications to the Work, but excluding communication that + is conspicuously marked or designated in writing by the copyright owner + as "Not a Contribution." + + "Contributor" shall mean Licensor and any Legal Entity on behalf of + whom a Contribution has been received by the Licensor and included + within the Work. + + 2. Grant of Copyright License. Subject to the terms and conditions of + this License, each Contributor hereby grants to You a perpetual, + worldwide, non-exclusive, no-charge, royalty-free, irrevocable + copyright license to reproduce, prepare Derivative Works of, + publicly display, publicly perform, sublicense, and distribute the + Work and such Derivative Works in Source or Object form. + + 3. Grant of Patent License. Subject to the terms and conditions of + this License, each Contributor hereby grants to You a perpetual, + worldwide, non-exclusive, no-charge, royalty-free, irrevocable + (except as stated in this section) patent license to make, have made, + use, offer to sell, sell, import, and otherwise transfer the Work, + where such license applies only to those patent claims licensable + by such Contributor that are necessarily infringed by their + Contribution(s) alone or by the combination of their Contribution(s) + with the Work to which such Contribution(s) was submitted. If You + institute patent litigation against any entity (including a cross-claim + or counterclaim in a lawsuit) alleging that the Work or any + Contribution embodied within the Work constitutes direct or contributory + patent infringement, then any patent licenses granted to You under + this License for that Work shall terminate as of the date such + litigation is filed. + + 4. Redistribution. You may reproduce and distribute copies of the + Work or Derivative Works thereof in any medium, with or without + modifications, and in Source or Object form, provided that You + meet the following conditions: + + (a) You must give any other recipients of the Work or Derivative + Works a copy of this License; and + + (b) You must cause any modified files to carry prominent notices + stating that You changed the files; and + + (c) You must retain, in the Source form of any Derivative Works + that You distribute, all copyright, patent, trademark, and + attribution notices from the Source form of the Work, + excluding those notices that do not pertain to any part of + the Derivative Works; and + + (d) If the Work includes a "NOTICE" text file as part of its + distribution, You must include a readable copy of the + attribution notices contained within such NOTICE file, in + at least one of the following places: within a NOTICE text + file distributed as part of the Derivative Works; within + the Source form or documentation, if provided along with the + Derivative Works; or, within a display generated by the + Derivative Works, if and wherever such third-party notices + normally appear. The contents of the NOTICE file are for + informational purposes only and do not modify the License. + You may add Your own attribution notices within Derivative + Works that You distribute, alongside or in addition to the + NOTICE text from the Work, provided that such additional + attribution notices cannot be construed as modifying the License. + + You may add Your own license statement for Your modifications and + may provide additional grant of rights to use, reproduce, modify, + prepare derivative works of, distribute, and sublicense such modifications, + as an additional requirement of this License. + + 5. Submission of Contributions. Unless You explicitly state otherwise, + any Contribution intentionally submitted for inclusion in the Work + by You to the Licensor shall be under the terms and conditions of + this License, without any additional terms or conditions. + Notwithstanding the above, nothing herein shall supersede or modify + the terms of any separate license agreement you may have executed + with Licensor regarding such Contributions. + + 6. Trademarks. This License does not grant permission to use the trade + names, trademarks, service marks, or product names of the Licensor, + except as required for reasonable and customary use in describing the + origin of the Work and reproducing the content of the NOTICE file. + + 7. Disclaimer of Warranty. Unless required by applicable law or + agreed to in writing, Licensor provides the Work (and each + Contributor provides its Contributions) on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or + implied, including, without limitation, any conditions of TITLE, + NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A PARTICULAR + PURPOSE. You are solely responsible for determining the + appropriateness of using or reproducing the Work and assume any + risks associated with Your exercise of permissions under this License. + + 8. Limitation of Liability. In no event and under no legal theory, + whether in tort (including negligence), contract, or otherwise, + unless required by applicable law (such as deliberate and grossly + negligent acts) or agreed to in writing, shall any Contributor be + liable to You for damages, including any direct, indirect, special, + incidental, or exemplary damages of any character arising as a + result of this License or out of the use or inability to use the + Work (including but not limited to damages for loss of goodwill, + work stoppage, computer failure or malfunction, or all other + commercial damages or losses), even if such Contributor has been + advised of the possibility of such damages. + + 9. Accepting Warranty or Additional Liability. While redistributing + the Work or Derivative Works thereof, You may choose to offer, + and charge a fee for, acceptance of support, warranty, indemnity, + or other liability obligations and/or rights consistent with this + License. However, in accepting such obligations, You may offer + only obligations consistent to this License provided that the + obligations are consistent with this License. + + END OF TERMS AND CONDITIONS + + APPENDIX: How to apply the Apache License to your work. + + To apply the Apache License to your work, attach the following + boilerplate notice, with the fields enclosed by brackets "[]" + replaced with your own identifying information. (Don't include + the brackets!) The text should be enclosed in the appropriate + comment syntax for the file format in question. Also add information + on how to contact you electronically and/or by mail. + + Copyright 2026 MetaObjects Contributors + + Licensed under the Apache License, Version 2.0 (the "License"); + you may not use this file except in compliance with the License. + You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + + Unless required by applicable law or agreed to in writing, software + distributed under the License is distributed on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + See the License for the specific language governing permissions and + limitations under the License. diff --git a/server/typescript/packages/docs-site/assets/site.css b/server/typescript/packages/docs-site/assets/site.css new file mode 100644 index 000000000..56686b419 --- /dev/null +++ b/server/typescript/packages/docs-site/assets/site.css @@ -0,0 +1,13 @@ +.pl-mu { background:#0b1220; color:#cbd5e1; font-family:ui-monospace,monospace; font-size:.78rem; line-height:1.55; padding:1rem; border-radius:.5rem; overflow-x:auto; white-space:pre; } +.mu-sec { color:#f472b6; font-weight:600; } .mu-var { color:#7dd3fc; } .mu-raw { color:#fbbf24; } +.mu-par { color:#4ade80; } .mu-com { color:#64748b; font-style:italic; } +.mu-unresolved { text-decoration: underline dotted #ef4444; } +/* diagrams fit the container WIDTH (mermaid useMaxWidth), and the box scrolls if a diagram is tall. + Native scroll = mobile-friendly; caps runaway height so a diagram never dominates the page. */ +.pl-diagram { max-height: min(72vh, 560px); overflow: auto; border-radius: .5rem; } +.pl-diagram pre.mermaid { margin: 0; } +.pl-diagram svg { width: 100%; height: auto; max-width: none; display: block; } +.pl-toc a { display:block; opacity:.6; border-left:2px solid transparent; padding-left:.5rem; } +.pl-toc a:hover, .pl-toc a.active { opacity:1; border-left-color:#60a5fa; } +section[id] { scroll-margin-top:1rem; } +@media print { .pl-sidebar, .pl-toc { display:none } } diff --git a/server/typescript/packages/docs-site/assets/site.js b/server/typescript/packages/docs-site/assets/site.js new file mode 100644 index 000000000..b8e629e19 --- /dev/null +++ b/server/typescript/packages/docs-site/assets/site.js @@ -0,0 +1,25 @@ +// mermaid: theme comes from each diagram's %%{init}%% prelude; useMaxWidth fits diagrams to the +// container width. The .pl-diagram box scrolls (native, mobile-friendly) if a diagram is tall. +mermaid.initialize({ startOnLoad: false, securityLevel: "loose", er: { useMaxWidth: true }, flowchart: { useMaxWidth: true } }); +mermaid.run({ querySelector: ".mermaid" }); +// scroll-spy TOC +const secs = [...document.querySelectorAll("section[id]")]; +const tocLinks = new Map([...document.querySelectorAll(".pl-toc a")].map((a) => [a.getAttribute("href").replace(/^#/, ""), a])); +if (secs.length && tocLinks.size) { + const io = new IntersectionObserver((es) => es.forEach((e) => { const a = tocLinks.get(e.target.id); if (a) a.classList.toggle("active", e.isIntersecting); }), { rootMargin: "0px 0px -70% 0px" }); + secs.forEach((s) => io.observe(s)); +} +// Cmd+K search +const modal = document.getElementById("search-modal"), box = document.getElementById("search"), results = document.getElementById("search-results"); +const openBtn = document.getElementById("search-open"); +const open = () => { modal?.showModal(); box.value = ""; results.innerHTML = ""; box.focus(); }; +openBtn?.addEventListener("click", open); +document.addEventListener("keydown", (e) => { if ((e.key === "k" && (e.metaKey || e.ctrlKey)) || (e.key === "/" && document.activeElement !== box)) { e.preventDefault(); open(); } }); +let INDEX = null; +box?.addEventListener("input", async () => { + INDEX ??= await (await fetch(window.__REL_ROOT__ + "assets/search-index.json")).json(); + const q = box.value.trim().toLowerCase(); + if (!q) { results.innerHTML = ""; return; } + const scored = INDEX.map((e) => { const t = e.t.toLowerCase(); const i = t.indexOf(q); return i < 0 ? null : { e, s: (i === 0 ? 0 : 1) + t.length / 500 }; }).filter(Boolean).sort((a, b) => a.s - b.s).slice(0, 40); + results.innerHTML = scored.map(({ e }) => `${e.t} ${e.k}`).join(""); +}); diff --git a/server/typescript/packages/docs-site/package.json b/server/typescript/packages/docs-site/package.json new file mode 100644 index 000000000..2336f778e --- /dev/null +++ b/server/typescript/packages/docs-site/package.json @@ -0,0 +1,20 @@ +{ + "name": "@metaobjectsdev/docs-site", + "version": "0.15.6", + "description": "HTML documentation-site generator for metaobjects models — a browsable multi-page site (nav, search, per-object/package pages, kind-aware ER diagrams). Powers the `meta docs --site` surface.", + "type": "module", + "main": "./dist/index.js", + "types": "./dist/index.d.ts", + "exports": { ".": { "bun": "./src/index.ts", "types": "./dist/index.d.ts", "default": "./dist/index.js" } }, + "files": ["dist", "src", "templates", "assets", "README.md", "LICENSE"], + "scripts": { "build": "tsc -p .", "typecheck": "tsc -p tsconfig.typecheck.json", "test": "bun test" }, + "license": "Apache-2.0", + "author": "Doug Mealing ", + "homepage": "https://metaobjects.dev", + "bugs": { "url": "https://github.com/metaobjectsdev/metaobjects/issues" }, + "repository": { "type": "git", "url": "https://github.com/metaobjectsdev/metaobjects.git", "directory": "server/typescript/packages/docs-site" }, + "keywords": ["metaobjects", "docs", "documentation", "html", "site", "erd"], + "publishConfig": { "access": "public" }, + "dependencies": { "@metaobjectsdev/metadata": "workspace:*", "@metaobjectsdev/render": "workspace:*", "yaml": "^2.9.0" }, + "devDependencies": { "bun-types": "latest", "typescript": "^5.6.0" } +} diff --git a/server/typescript/packages/docs-site/src/badges.ts b/server/typescript/packages/docs-site/src/badges.ts new file mode 100644 index 000000000..707bbd146 --- /dev/null +++ b/server/typescript/packages/docs-site/src/badges.ts @@ -0,0 +1,26 @@ +export const esc = (s: unknown) => + String(s).replace(/&/g, "&").replace(//g, ">").replace(/"/g, """); + +export interface Badge { text: string; cls: string; href?: string; title?: string; } + +export function badge(b: Badge): string { + const cls = `badge ${b.cls} badge-xs`; + const title = b.title ? ` title="${esc(b.title)}"` : ""; + return b.href + ? `${esc(b.text)}` + : `${esc(b.text)}`; +} + +export const LEGEND: { label: string; cls: string }[] = [ + { label: "reference (fk)", cls: "badge-soft badge-info" }, + { label: "contains (nested)", cls: "badge-soft badge-secondary" }, + { label: "indexed / pk", cls: "badge-soft badge-success" }, + { label: "required", cls: "badge-soft badge-error" }, + { label: "deprecated", cls: "badge-soft badge-warning" }, + { label: "enum", cls: "badge-soft badge-accent" }, + { label: "optional", cls: "badge-soft badge-neutral" }, +]; + +export function legendHtml(): string { + return LEGEND.map((l) => `${esc(l.label)}`).join(" "); +} diff --git a/server/typescript/packages/docs-site/src/builders/extras.ts b/server/typescript/packages/docs-site/src/builders/extras.ts new file mode 100644 index 000000000..420eef5ee --- /dev/null +++ b/server/typescript/packages/docs-site/src/builders/extras.ts @@ -0,0 +1,61 @@ +import { existsSync } from "node:fs"; +import { join } from "node:path"; +import { LinkGraph, fqnOf } from "../link-graph"; + +export interface EnumRow { owner: string; ownerHref: string; field: string; values: string[]; deflt: string; } +export function buildEnumsPage(g: LinkGraph): EnumRow[] { + const rows: EnumRow[] = []; + for (const o of g.nodes().filter((n) => n.kind === "object")) { + for (const f of o.node.childrenOfType("field")) { + const values = f.attr("values"); + if (Array.isArray(values)) rows.push({ owner: o.name, ownerHref: o.href, field: f.name, values: values.map(String), deflt: String(f.attr("default") ?? "") }); + } + } + return rows.sort((a, b) => (a.owner + a.field).localeCompare(b.owner + b.field)); +} + +export interface Anomaly { kind: string; subject: string; href: string; detail: string; } +export function findAnomalies(g: LinkGraph, sourceDirs: string[]): Anomaly[] { + const out: Anomaly[] = []; + const objs = g.nodes().filter((n) => n.kind === "object"); + for (const o of objs) { + const fqn = fqnOf(o.node); + if (!o.node.isAbstract && g.degree(fqn) === 0) out.push({ kind: "orphan", subject: o.name, href: o.href, detail: "no inbound or outbound references" }); + if (o.node.isAbstract && g.extendedBy(fqn).length === 0) out.push({ kind: "unextended-abstract", subject: o.name, href: o.href, detail: "abstract with no descendants" }); + const fkFields = new Set(); + for (const i of o.node.childrenOfType("identity").filter((i) => i.subType === "reference")) { + const fv = i.attr("fields"); + const names = Array.isArray(fv) ? fv.map(String) : [String(fv ?? "")]; + for (const n of names) fkFields.add(n); + } + for (const f of o.node.childrenOfType("field")) { + if (/(^|[a-z])Id$/.test(f.name) && (f.subType === "string" || f.subType === "uuid") && f.attr("objectRef") === undefined && !fkFields.has(f.name) && !o.node.isAbstract) + out.push({ kind: "implied-ref", subject: `${o.name}.${f.name}`, href: `${o.href}#f-${f.name}`, detail: "looks like a reference but declares none" }); + } + } + // unreachable payload VOs: object.value not reached from any template payload tree + const reachable = new Set(); + for (const t of g.nodes().filter((n) => n.kind !== "object")) { + const q = g.refsFrom(fqnOf(t.node)).filter((r) => r.kind === "payload").map((r) => r.to); + while (q.length) { const cur = q.shift()!; if (reachable.has(cur)) continue; reachable.add(cur); for (const r of g.refsFrom(cur)) if (r.kind === "field") q.push(r.to); } + } + for (const o of objs.filter((o) => o.node.subType === "value" && g.nodes().some((t) => t.kind !== "object" && t.pkg === o.pkg))) + if (!reachable.has(fqnOf(o.node)) && g.degree(fqnOf(o.node)) === 0) + out.push({ kind: "unreachable-vo", subject: o.name, href: o.href, detail: "value object not reachable from any template payload" }); + for (const t of g.nodes().filter((n) => n.kind !== "object")) { + const ref = String(t.node.attr("textRef") ?? ""); + if (ref && !sourceDirs.some((d) => existsSync(join(d, ...ref.split("/")) + ".mustache"))) + out.push({ kind: "unresolved-textref", subject: t.name, href: t.href, detail: `@textRef ${ref} does not resolve (forward-pointing)` }); + } + return out.sort((a, b) => (a.kind + a.subject).localeCompare(b.kind + b.subject)); +} + +export interface SearchEntry { t: string; h: string; k: "object" | "prompt" | "output" | "field"; } +export function buildSearchIndex(g: LinkGraph): SearchEntry[] { + const out: SearchEntry[] = []; + for (const n of g.nodes()) { + out.push({ t: `${n.pkg}::${n.name}`, h: n.href, k: n.kind }); + if (n.kind === "object") for (const f of n.node.childrenOfType("field")) out.push({ t: `${n.name}.${f.name}`, h: `${n.href}#f-${f.name}`, k: "field" }); + } + return out.sort((a, b) => a.t.localeCompare(b.t)); +} diff --git a/server/typescript/packages/docs-site/src/builders/index-data.ts b/server/typescript/packages/docs-site/src/builders/index-data.ts new file mode 100644 index 000000000..49dab2238 --- /dev/null +++ b/server/typescript/packages/docs-site/src/builders/index-data.ts @@ -0,0 +1,89 @@ +import { LinkGraph, fqnOf } from "../link-graph"; +import type { CoverageTracker } from "../coverage"; +import { flowchartDomain, packageFlowchart } from "../mermaid"; +import { harvestPackageDocs } from "../package-docs"; +import { esc } from "../badges"; + +export interface PkgCard { pkg: string; href: string; objectCount: number; promptCount: number; contractCount: number; purpose: string; } +export interface CoreConfig { pin?: string[]; exclude?: string[]; n?: number; } +export interface IndexPageData { title: string; stamp: string; commit: string; stats: { objects: number; tables: number; packages: number; promptVos: number; prompts: number; contracts: number; enums: number }; coreMermaid: string; coreCaption: string; coreLegend: { pkg: string; fill: string; stroke: string }[]; packageMermaid: string; fullEdges: { from: string; to: string; n: number }[]; dataPackages: PkgCard[]; promptPackages: PkgCard[]; } + +export function buildIndexPage(g: LinkGraph, cov: CoverageTracker, opts: { title: string; stamp: string; commit: string; core?: CoreConfig; sourceDirs?: string[] }): IndexPageData { + const objs = g.nodes().filter((n) => n.kind === "object"); + const tpls = g.nodes().filter((n) => n.kind !== "object"); + const pkgs = [...new Set(g.nodes().map((n) => n.pkg))].sort(); + const promptPkgSet = new Set(tpls.map((t) => t.pkg)); + const shortPkg = (p: string) => p.split("::").pop()!; + // core map = a CONNECTED cluster of the most-connected objects of ALL kinds (entities, projections, + // value objects), traversing every edge type (fk, field.object, origin, extends, relationship). + // Seed by total degree, pull in the seeds' neighbors so nothing dangles, cap the total, drop isolates. + const CORE_MAX = 28; + const degOf = (fqn: string) => g.refsFrom(fqn).length + g.refsTo(fqn).length; + // seed a BALANCED mix so all object kinds appear (payload VOs otherwise dominate by degree): + // top entities (data-model backbone) + top value objects (payload structure) + projections (views). + const topByType = (st: string, k: number) => objs.filter((n) => !n.node.isAbstract && n.node.subType === st) + .map((dn) => ({ dn, fqn: fqnOf(dn.node), deg: degOf(fqnOf(dn.node)) })) + .sort((a, b) => b.deg - a.deg || a.dn.name.localeCompare(b.dn.name)).slice(0, k); + const seeds = [...topByType("entity", 8), ...topByType("value", 5), ...topByType("projection", 3)]; + const shown = new Map(); + for (const s of seeds) shown.set(s.fqn, s.dn); + // rank candidate neighbors by how many seeds touch them, add until the cap + const cand = new Map(); + for (const s of seeds) { + for (const e of g.refsFrom(s.fqn)) cand.set(e.to, (cand.get(e.to) ?? 0) + 1); + for (const e of g.refsTo(s.fqn)) cand.set(e.from, (cand.get(e.from) ?? 0) + 1); + } + for (const [f] of [...cand.entries()].filter(([f]) => !shown.has(f)).sort((a, b) => b[1] - a[1] || a[0].localeCompare(b[0]))) { + if (shown.size >= CORE_MAX) break; + const dn = g.byFqn(f); if (dn && dn.kind === "object") shown.set(f, dn); + } + // edges among the shown set — collapse parallel edges between a pair to ONE unlabeled edge + // (a projection can join a source object on 6-12 fields; the overview only needs the connection). + const heroEdges: { from: string; to: string; label: string }[] = []; + const seenEdge = new Set(); + for (const [f, dn] of shown) for (const e of g.refsFrom(f)) if (shown.has(e.to)) { + const to = g.byFqn(e.to)!.name; + const key = `${dn.name}|${to}`; + if (!seenEdge.has(key)) { seenEdge.add(key); heroEdges.push({ from: dn.name, to, label: "" }); } + } + const connected = new Set(heroEdges.flatMap((e) => [e.from, e.to])); + const heroNodes = [...shown.values()].filter((dn) => connected.has(dn.name)); + const hero = flowchartDomain(heroNodes.map((dn) => ({ name: dn.name, pkg: dn.pkg, kind: dn.node.subType })), heroEdges); + const coreMermaid = hero.mermaid, coreLegend = hero.legend; + const coreCaption = `${heroNodes.length} of the most-connected objects (entities, views, and payloads), colored by domain.`; + // package docs for purpose cards + const pdocs = harvestPackageDocs(opts.sourceDirs ?? []); + // package edges + const pkgEdges = new Map(); + for (const o of objs) for (const r of g.refsFrom(fqnOf(o.node))) { + if (r.kind === "extends") continue; + const t = g.byFqn(r.to); if (!t || t.pkg === o.pkg) continue; + const k = `${shortPkg(o.pkg)}→${shortPkg(t.pkg)}`; pkgEdges.set(k, (pkgEdges.get(k) ?? 0) + 1); + } + const fullEdges = [...pkgEdges.entries()].sort().map(([k, n]) => { const [from, to] = k.split("→"); return { from, to, n }; }); + const counts = new Map(); for (const o of objs) counts.set(shortPkg(o.pkg), (counts.get(shortPkg(o.pkg)) ?? 0) + 1); + const card = (p: string): PkgCard => { + const doc = pdocs.get(p); + const purpose = esc((doc?.title ? doc.title + " — " : "") + (doc?.description ?? "")).slice(0, 160); + return { pkg: p, href: `${p.split("::").join("/")}/index.html`, + objectCount: objs.filter((o) => o.pkg === p).length, + promptCount: tpls.filter((t) => t.pkg === p && t.kind === "prompt").length, + contractCount: tpls.filter((t) => t.pkg === p && t.kind === "output").length, + purpose }; + }; + const enums = objs.reduce((n, o) => n + o.node.childrenOfType("field").filter((f) => Array.isArray(f.attr("values"))).length, 0); + return { title: opts.title, stamp: opts.stamp, commit: opts.commit, + stats: { objects: objs.length, + tables: objs.filter((o) => o.node.childrenOfType("source").length > 0).length, + packages: pkgs.length, + promptVos: objs.filter((o) => promptPkgSet.has(o.pkg)).length, + prompts: tpls.filter((t) => t.kind === "prompt").length, + contracts: tpls.filter((t) => t.kind === "output").length, enums }, + coreMermaid, + coreCaption, + coreLegend, + packageMermaid: packageFlowchart(fullEdges.filter((e) => e.n >= 2), counts), + fullEdges, + dataPackages: pkgs.filter((p) => !promptPkgSet.has(p)).map(card), + promptPackages: pkgs.filter((p) => promptPkgSet.has(p)).map(card) }; +} diff --git a/server/typescript/packages/docs-site/src/builders/object-data.ts b/server/typescript/packages/docs-site/src/builders/object-data.ts new file mode 100644 index 000000000..2334a06a9 --- /dev/null +++ b/server/typescript/packages/docs-site/src/builders/object-data.ts @@ -0,0 +1,250 @@ +import type { MetaData } from "@metaobjectsdev/metadata"; +import { LinkGraph, fqnOf } from "../link-graph"; +import type { CoverageTracker } from "../coverage"; +import { esc, badge } from "../badges"; +import { inheritanceTree, erDiagramRich, flowchartDomain, RICH_MAX, type ErEdge, type ErNode, type ErAttr } from "../mermaid"; + +// capped box attributes for the rich neighborhood ERD: PK → FKs(target) → enums → required, ≤6 + overflow count +function neighborAttrs(o: MetaData): { attrs: ErAttr[]; more: number } { + const pk = new Set(), fk = new Map(); + for (const id of o.childrenOfType("identity")) { + const flds = id.attr("fields"); + const names = Array.isArray(flds) ? flds.map(String) : flds !== undefined ? [String(flds)] : []; + if (id.subType === "primary") names.forEach((n) => pk.add(n)); + if (id.subType === "reference") { const tgt = String(id.attr("references") ?? "").split("::").pop() ?? ""; names.forEach((n) => fk.set(n, tgt)); } + } + const fields = o.childrenOfType("field"); + const isEnum = (f: MetaData) => Array.isArray(f.attr("values")); + const isReq = (f: MetaData) => f.attr("required") === true || f.attr("required") === "true"; + const attrs: ErAttr[] = []; const seen = new Set(); + const push = (f: MetaData, key: ErAttr["key"], note: string) => { if (seen.has(f.name) || attrs.length >= 6) return; seen.add(f.name); attrs.push({ type: f.subType, name: f.name, key, note }); }; + for (const f of fields) if (pk.has(f.name)) push(f, "PK", ""); + for (const f of fields) if (fk.has(f.name)) push(f, "FK", fk.get(f.name)!); + for (const f of fields) if (isEnum(f)) push(f, "", "enum"); + for (const f of fields) if (isReq(f)) push(f, "", "req"); + const relevant = fields.filter((f) => pk.has(f.name) || fk.has(f.name) || isEnum(f) || isReq(f)).length; + return { attrs, more: Math.max(0, relevant - attrs.length) }; +} + +export interface EnumValue { value: string; deflt: boolean; desc: string; } +export interface FieldRow { name: string; type: string; isArray: boolean; required: boolean; badgesHtml: string; desc: string; enumValues: EnumValue[]; refHref?: string; refName?: string; inheritedFrom?: { name: string; href: string }; anchor: string; } +export interface IndexRow { name: string; kind: string; fields: string; extra: string; unique: boolean; } +export interface ValidatorRow { scope: "field" | "object"; subject: string; rule: string; // human-readable, HTML-escaped +} +export interface RelationRow { name: string; toName: string; toHref: string; cardinality: string; } +export interface OriginRow { field: string; from: string; via: string; } +export interface HierRow { name: string; href: string; level: number; self: boolean; } +export interface ObjectPageData { + name: string; kindBadge: string; isAbstract: boolean; isView: boolean; generation: string; + pkg: string; href: string; breadcrumbHtml: string; desc: string; tableName?: string; pkHtml?: string; + ownFields: FieldRow[]; inheritedFields: FieldRow[]; indexes: IndexRow[]; validators: ValidatorRow[]; + relations: RelationRow[]; origins: OriginRow[]; hierarchy: HierRow[]; inheritanceMermaid?: string; + neighborhoodMermaid?: string; neighborhoodLegend?: { pkg: string; fill: string; stroke: string }[]; neighborhoodMore?: number; + referencedBy: { name: string; href: string; via: string }[]; + references: { name: string; href: string; via: string }[]; usedByTemplates: { name: string; href: string }[]; + sourceFile: string; +} + +function fieldRow(f: MetaData, ownerHref: string, g: LinkGraph, cov: CoverageTracker, ctxPkg: string): FieldRow { + cov.consumeNode(f); + const a = (n: string) => { const v = f.attr(n); if (v !== undefined) cov.consumeAttr(f, n); return v; }; + const bits: string[] = []; + const reqVal = a("required"); const required = reqVal === true || reqVal === "true"; + if (required) bits.push(badge({ text: "required", cls: "badge-soft badge-error" })); + if (a("deprecated") !== undefined) bits.push(badge({ text: "deprecated", cls: "badge-soft badge-warning" })); + const len = a("maxLength"); if (len !== undefined) bits.push(badge({ text: `≤${len}`, cls: "badge-soft badge-neutral" })); + const dbt = a("dbColumnType"); if (dbt !== undefined) bits.push(badge({ text: String(dbt), cls: "badge-soft badge-neutral" })); + const def = a("default"); if (def !== undefined) bits.push(badge({ text: `default ${def}`, cls: "badge-soft badge-neutral" })); + if (a("xmlText") !== undefined) bits.push(badge({ text: "@xmlText", cls: "badge-soft badge-neutral" })); + // enum values as data (rendered per-value in the template) + const valuesAttr = a("values"); + const enumValues: EnumValue[] = Array.isArray(valuesAttr) + ? valuesAttr.map((v) => ({ value: esc(String(v)), deflt: String(def ?? "") === String(v), desc: "" })) + : []; + if (enumValues.length) bits.push(badge({ text: "enum", cls: "badge-soft badge-accent" })); + // field-level validators as badges + for (const v of f.childrenOfType("validator")) { + cov.consumeNode(v); + if (v.subType === "regex") { cov.consumeAttr(v, "pattern"); bits.push(badge({ text: `regex ${v.attr("pattern")}`, cls: "badge-soft badge-neutral" })); } + if (v.subType === "numeric") { + const mm = ["min", "max"].filter((k) => v.attr(k) !== undefined).map((k) => { cov.consumeAttr(v, k); return `${k}=${v.attr(k)}`; }); + if (mm.length) bits.push(badge({ text: mm.join(" "), cls: "badge-soft badge-neutral" })); + } + } + // reference vs containment badge. A field of subType `object` with an objectRef CONTAINS a nested + // object (composition); a scalar field with an objectRef REFERENCES another object by id. + let refHref: string | undefined, refName: string | undefined; + const oref = a("objectRef"); + if (typeof oref === "string") { + const t = g.byFqn(oref.includes("::") ? oref : `${ctxPkg}::${oref}`) ?? g.byFqn(oref); + if (t) { + refHref = g.relHref(ownerHref, t.href); refName = t.name; + const contains = f.subType === "object"; + bits.push(badge({ + text: contains ? `⊃ ${t.name}` : `→ ${t.name}`, + cls: contains ? "badge-soft badge-secondary" : "badge-soft badge-info", + href: refHref, + title: contains ? "contains (nested object)" : "reference", + })); + } + } + const desc = esc(a("description") ?? ""); + return { name: f.name, type: f.subType, isArray: f.resolvedIsArray(), required, badgesHtml: bits.join(" "), desc, enumValues, refHref, refName, inheritedFrom: undefined, anchor: `f-${f.name}` }; +} + +export function buildObjectPage(fqn: string, g: LinkGraph, cov: CoverageTracker): ObjectPageData { + const dn = g.byFqn(fqn); + if (!dn || dn.kind !== "object") throw new Error(`not an object: ${fqn}`); + const o = dn.node; + cov.consumeNode(o); + cov.consumeAttr(o, "description"); + + // inheritance hierarchy rows (ancestors nearest-last so level increases downward) + self + direct children + const anc = g.ancestors(fqn); // nearest-first + const hierarchy: HierRow[] = []; + anc.slice().reverse().forEach((n, i) => hierarchy.push({ name: n.name, href: g.relHref(dn.href, n.href), level: i, self: false })); + const selfLevel = anc.length; + hierarchy.push({ name: dn.name, href: "", level: selfLevel, self: true }); + const kids = g.extendedBy(fqn).slice().sort((a, b) => a.name.localeCompare(b.name)); + for (const k of kids) hierarchy.push({ name: k.name, href: g.relHref(dn.href, k.href), level: selfLevel + 1, self: false }); + const inheritanceMermaid = hierarchy.length > 1 + ? inheritanceTree(hierarchy.map((h) => ({ name: h.name, level: h.level, self: h.self }))) : undefined; + + // storage: table vs view, generation, pk + let tableName: string | undefined, isView = false, pkHtml: string | undefined, generation = ""; + for (const s of o.childrenOfType("source")) { + cov.consumeNode(s); + const t = s.attr("table"); if (t !== undefined) { cov.consumeAttr(s, "table"); tableName = String(t); } + const kind = s.attr("kind"); if (kind !== undefined) { cov.consumeAttr(s, "kind"); if (String(kind) === "view") isView = true; } + } + // indexes: identity (pk/secondary) + index.lookup with tuning detail + const indexes: IndexRow[] = []; + for (const id of o.childrenOfType("identity")) { + cov.consumeNode(id); + const flds = id.attr("fields"); if (flds !== undefined) cov.consumeAttr(id, "fields"); + const fields = Array.isArray(flds) ? flds.join(", ") : String(flds ?? ""); + if (id.subType === "primary") { + pkHtml = `${esc(fields)}`; + const gen = id.attr("generation"); if (gen !== undefined) { cov.consumeAttr(id, "generation"); generation = String(gen); } + indexes.push({ name: id.name, kind: "primary", fields, extra: "", unique: true }); + } else if (id.subType === "secondary") { + indexes.push({ name: id.name, kind: "unique", fields, extra: "", unique: true }); + } else if (id.subType === "reference") { + const ref = id.attr("references"); if (ref !== undefined) cov.consumeAttr(id, "references"); + const enf = id.attr("enforce"); if (enf !== undefined) cov.consumeAttr(id, "enforce"); + indexes.push({ name: id.name, kind: "fk", fields, extra: [ref ? `→ ${esc(String(ref))}` : "", enf === false || enf === "false" ? "logical" : ""].filter(Boolean).join(" · "), unique: false }); + } + } + for (const ix of o.childrenOfType("index")) { + cov.consumeNode(ix); + const flds = ix.attr("fields"); if (flds !== undefined) cov.consumeAttr(ix, "fields"); + const fields = Array.isArray(flds) ? flds.join(", ") : String(flds ?? ""); + const extras: string[] = []; + for (const k of ["orders", "where", "expr", "using"]) { + const v = ix.attr(k); if (v !== undefined) { cov.consumeAttr(ix, k); extras.push(esc(`${k} ${Array.isArray(v) ? v.join(",") : v}`)); } + } + const uniq = ix.attr("unique"); if (uniq !== undefined) cov.consumeAttr(ix, "unique"); + indexes.push({ name: ix.name, kind: "index", fields, extra: extras.join(" · "), unique: uniq === true || uniq === "true" }); + } + indexes.sort((a, b) => a.kind.localeCompare(b.kind) || a.name.localeCompare(b.name)); + + // validators: field-level shown as badges already; collect OBJECT-level here + const validators: ValidatorRow[] = []; + for (const v of o.childrenOfType("validator")) { + cov.consumeNode(v); + const at = (k: string) => { const x = v.attr(k); if (x !== undefined) cov.consumeAttr(v, k); return x; }; + let rule = ""; + if (v.subType === "comparison") rule = `${esc(String(at("left") ?? ""))} ${esc(String(at("op") ?? ""))} ${esc(String(at("right") ?? ""))}`; + else if (v.subType === "requiredWhen") rule = `${esc(String(at("field") ?? ""))} required when ${esc(String(at("when") ?? ""))} = ${esc(String(at("equals") ?? ""))}`; + else if (v.subType === "presentIff") rule = `${esc(String(at("field") ?? ""))} present iff ${esc(String(at("when") ?? ""))} = ${esc(String(at("equals") ?? ""))}`; + else if (v.subType === "atLeastOne") { const fs = at("fields"); rule = `at least one of ${esc(Array.isArray(fs) ? fs.map(String).join(", ") : String(fs ?? ""))}`; } + else rule = esc(v.subType); + validators.push({ scope: "object", subject: v.name || v.subType, rule }); + } + validators.sort((a, b) => a.subject.localeCompare(b.subject)); + + // relationships + const relations: RelationRow[] = g.relationshipsOf(fqn).map((r) => { + const t = g.byFqn(r.toFqn); + return { name: r.name, toName: t?.name ?? r.toFqn, toHref: t ? g.relHref(dn.href, t.href) : "", cardinality: r.cardinality }; + }); + for (const rel of o.childrenOfType("relationship")) { cov.consumeNode(rel); cov.consumeAttr(rel, "objectRef"); cov.consumeAttr(rel, "cardinality"); } + + // origin provenance + const origins: OriginRow[] = g.originsOf(fqn).map((r) => ({ field: r.field, from: esc(r.from), via: esc(r.via) })) + .sort((a, b) => a.field.localeCompare(b.field)); + for (const f of o.childrenOfType("field")) for (const org of f.childrenOfType("origin")) { cov.consumeNode(org); cov.consumeAttr(org, "from"); cov.consumeAttr(org, "via"); } + + // fields own vs inherited + const ownNames = new Set(o.ownChildren().filter((c) => c.type === "field").map((c) => c.name)); + const ownFields: FieldRow[] = [], inheritedFields: FieldRow[] = []; + for (const f of o.childrenOfType("field")) { + const row = fieldRow(f, dn.href, g, cov, dn.pkg); + if (ownNames.has(f.name)) ownFields.push(row); + else { + for (let s = o.superResolved; s; s = s.superResolved) { + if (s.ownChildren().some((c) => c.type === "field" && c.name === f.name)) { + const t = g.byFqn(fqnOf(s)); + row.inheritedFrom = t ? { name: t.name, href: g.relHref(dn.href, t.href) } : { name: s.name, href: "#" }; + break; + } + } + inheritedFields.push(row); + } + } + + // neighborhood diagram — rich (attrs + domain-fill/role-stroke) when small, simple domain map when large. + // Cap the neighbor count so hub entities (30+ neighbors) don't produce an unreadable megadiagram; + // the full set is always available in the Referenced-by / References sections below. + const NB_MAX = 16; + // include every traversal kind (fk, field.object, origin, relationship, extends) so projections and + // value objects are never orphaned; the dedicated Inheritance section still shows the full chain. + const nbCandidates = new Map(); + for (const r of g.refsFrom(fqn)) { const t = g.byFqn(r.to); if (t && !nbCandidates.has(r.to)) nbCandidates.set(r.to, { node: t, edge: { parent: t.name, child: dn.name, label: r.kind === "extends" ? "extends" : r.via } }); } + for (const r of g.refsTo(fqn)) { const s = g.byFqn(r.from); if (s && s.kind === "object" && !nbCandidates.has(r.from)) nbCandidates.set(r.from, { node: s, edge: { parent: dn.name, child: s.name, label: r.kind === "extends" ? "extends" : r.via } }); } + const sortedNeighbors = [...nbCandidates.entries()].sort(([, a], [, b]) => a.node.name.localeCompare(b.node.name)); + const neighborhoodMore = Math.max(0, sortedNeighbors.length - NB_MAX); + const nbNodes = new Map(); const nbEdges: ErEdge[] = []; + nbNodes.set(fqn, dn); + for (const [k, { node, edge }] of sortedNeighbors.slice(0, NB_MAX)) { nbNodes.set(k, node); nbEdges.push(edge); } + const roleOf = (n: typeof dn): ErNode["role"] => + fqnOf(n.node) === fqn ? "focal" + : n.node.childrenOfType("source").some((s) => String(s.attr("kind") ?? "") === "view") ? "view" + : n.pkg !== dn.pkg ? "external" : "normal"; + let neighborhoodMermaid: string | undefined; + let neighborhoodLegend: { pkg: string; fill: string; stroke: string }[] | undefined; + if (nbEdges.length > 0) { + if (nbNodes.size <= RICH_MAX) { + const erNodes: ErNode[] = [...nbNodes.values()].map((n) => { const { attrs, more } = neighborAttrs(n.node); return { name: n.name, pkg: n.pkg, role: roleOf(n), kind: n.node.subType, attrs, more }; }); + neighborhoodMermaid = erDiagramRich(erNodes, nbEdges); + } else { + const r = flowchartDomain([...nbNodes.values()].map((n) => ({ name: n.name, pkg: n.pkg, kind: n.node.subType })), nbEdges.map((e) => ({ from: e.parent, to: e.child, label: e.label }))); + neighborhoodMermaid = r.mermaid; neighborhoodLegend = r.legend; + } + } + + // backlinks / forward refs (non-extends) + const referencedBy = g.refsTo(fqn).filter((r) => r.kind !== "extends").map((r) => { const s = g.byFqn(r.from)!; return { name: s.name, href: g.relHref(dn.href, s.href), via: r.via }; }).sort((a, b) => a.name.localeCompare(b.name) || a.via.localeCompare(b.via)); + const references = g.refsFrom(fqn).filter((r) => r.kind !== "extends").map((r) => { const t = g.byFqn(r.to)!; return { name: t.name, href: g.relHref(dn.href, t.href), via: r.via }; }).sort((a, b) => a.name.localeCompare(b.name) || a.via.localeCompare(b.via)); + + // used-by templates (unchanged BFS) + const usedByTemplates: { name: string; href: string }[] = []; + for (const t of g.nodes().filter((n) => n.kind !== "object")) { + const seen = new Set(); const q = g.refsFrom(fqnOf(t.node)).filter((r) => r.kind === "payload").map((r) => r.to); let hit = false; + while (q.length && seen.size < 100) { const cur = q.shift()!; if (seen.has(cur)) continue; seen.add(cur); if (cur === fqn) { hit = true; break; } for (const r of g.refsFrom(cur)) if (r.kind === "field") q.push(r.to); } + if (hit) usedByTemplates.push({ name: t.name, href: g.relHref(dn.href, t.href) }); + } + + const src = (o.source as { files?: string[] }).files?.[0] ?? ""; + const crumbs = [ + `index`, + `${esc(dn.pkg)}`, + esc(dn.name), + ]; + return { + name: dn.name, kindBadge: o.subType, isAbstract: o.isAbstract, isView, generation, + pkg: dn.pkg, href: dn.href, breadcrumbHtml: crumbs.join(" / "), desc: esc(o.attr("description") ?? ""), + tableName, pkHtml, ownFields, inheritedFields, indexes, validators, relations, origins, + hierarchy, inheritanceMermaid, neighborhoodMermaid, neighborhoodLegend, neighborhoodMore, referencedBy, references, usedByTemplates, sourceFile: src, + }; +} diff --git a/server/typescript/packages/docs-site/src/builders/output-data.ts b/server/typescript/packages/docs-site/src/builders/output-data.ts new file mode 100644 index 000000000..239af6553 --- /dev/null +++ b/server/typescript/packages/docs-site/src/builders/output-data.ts @@ -0,0 +1,35 @@ +import { existsSync } from "node:fs"; +import { join } from "node:path"; +import { LinkGraph } from "../link-graph"; +import type { CoverageTracker } from "../coverage"; +import type { CommentDocs } from "../yaml-comments"; +import { esc } from "../badges"; + +export interface OutputPageData { name: string; pkg: string; href: string; breadcrumbHtml: string; format: string; kind: string; textRef: string; textRefResolves: boolean; payloadName: string; payloadHref: string; desc: string; fields: { name: string; type: string; isArray: boolean; wire: string; note: string; refHtml: string }[]; } + +export function buildOutputPage(fqn: string, g: LinkGraph, cov: CoverageTracker, docs: CommentDocs, sourceDirs: string[] = []): OutputPageData { + const dn = g.byFqn(fqn)!; + const t = dn.node; + cov.consumeNode(t); cov.consumeAttr(t, "payloadRef"); cov.consumeAttr(t, "textRef"); cov.consumeAttr(t, "format"); + const format = String(t.attr("format") ?? "text"); + const pRef = g.refsFrom(fqn).find((r) => r.kind === "payload"); + const payload = pRef ? g.byFqn(pRef.to) : undefined; + const fields = (payload?.node.childrenOfType("field") ?? []).map((f) => { + const ref = f.attr("objectRef"); + const target = typeof ref === "string" ? (g.byFqn(ref.includes("::") ? ref : `${dn.pkg}::${ref}`) ?? g.byFqn(ref)) : undefined; + const hasXmlText = f.attr("xmlText") !== undefined; + const wire = hasXmlText ? "@xmlText body" : target ? "nested" : format === "xml" ? (f.resolvedIsArray() ? "element" : "attr") : "property"; + const noteAttr = f.attr("description"); + const note = esc(noteAttr ?? docs.fieldNote.get(`${payload!.name}.${f.name}`) ?? ""); + if (noteAttr !== undefined) cov.consumeAttr(f, "description"); + return { name: f.name, type: f.subType, isArray: f.resolvedIsArray(), wire, note, + refHtml: target ? `${esc(target.name)}` : "" }; + }); + const textRef = String(t.attr("textRef") ?? ""); + const textRefResolves = sourceDirs.some((d) => existsSync(join(d, ...textRef.split("/")) + ".mustache")); + return { name: dn.name, pkg: dn.pkg, href: dn.href, + breadcrumbHtml: `index / ${esc(dn.pkg)} / ${esc(dn.name)}`, + format, kind: String(t.attr("kind") ?? "document"), textRef, textRefResolves, + payloadName: payload?.name ?? "", payloadHref: payload ? esc(g.relHref(dn.href, payload.href)) : "", + desc: payload ? esc(payload.node.attr("description") ?? docs.objectDesc.get(payload.name) ?? "") : "", fields }; +} diff --git a/server/typescript/packages/docs-site/src/builders/package-data.ts b/server/typescript/packages/docs-site/src/builders/package-data.ts new file mode 100644 index 000000000..689116202 --- /dev/null +++ b/server/typescript/packages/docs-site/src/builders/package-data.ts @@ -0,0 +1,134 @@ +import type { MetaData } from "@metaobjectsdev/metadata"; +import { LinkGraph, fqnOf } from "../link-graph"; +import type { CoverageTracker } from "../coverage"; +import { esc } from "../badges"; +import { erDiagramRich, flowchartDomain, domainColor, RICH_MAX, type ErEdge, type ErAttr, type ErNode } from "../mermaid"; +import { harvestPackageDocs, keyEntities } from "../package-docs"; + +// capped box attributes for the rich package ERD: PK → FKs(target) → enums → required, ≤6 + overflow count +// (mirrors neighborAttrs in object-data.ts — kept local to avoid cross-builder coupling) +function pkgAttrs(o: MetaData): { attrs: ErAttr[]; more: number } { + const pk = new Set(), fk = new Map(); + for (const id of o.childrenOfType("identity")) { + const flds = id.attr("fields"); + const names = Array.isArray(flds) ? flds.map(String) : flds !== undefined ? [String(flds)] : []; + if (id.subType === "primary") names.forEach((n) => pk.add(n)); + if (id.subType === "reference") { const tgt = String(id.attr("references") ?? "").split("::").pop() ?? ""; names.forEach((n) => fk.set(n, tgt)); } + } + const fields = o.childrenOfType("field"); + const isEnum = (f: MetaData) => Array.isArray(f.attr("values")); + const isReq = (f: MetaData) => f.attr("required") === true || f.attr("required") === "true"; + const attrs: ErAttr[] = []; const seen = new Set(); + const push = (f: MetaData, key: ErAttr["key"], note: string) => { if (seen.has(f.name) || attrs.length >= 6) return; seen.add(f.name); attrs.push({ type: f.subType, name: f.name, key, note }); }; + for (const f of fields) if (pk.has(f.name)) push(f, "PK", ""); + for (const f of fields) if (fk.has(f.name)) push(f, "FK", fk.get(f.name)!); + for (const f of fields) if (isEnum(f)) push(f, "", "enum"); + for (const f of fields) if (isReq(f)) push(f, "", "req"); + const relevant = fields.filter((f) => pk.has(f.name) || fk.has(f.name) || isEnum(f) || isReq(f)).length; + return { attrs, more: Math.max(0, relevant - attrs.length) }; +} + +export interface ObjRow { name: string; href: string; kind: string; table: string; fieldCount: number; extendsName: string; } +export interface TplRow { name: string; href: string; payloadName: string; payloadHref: string; format: string; textRef: string; } +export interface PackagePageData { + pkg: string; pkgPath: string; tree: string; breadcrumbHtml: string; + title: string; descHtml: string; + keyCards: { name: string; href: string; inbound: number }[]; + erdMermaid: string; erdLegend: { pkg: string; fill: string; stroke: string }[]; + abstracts: ObjRow[]; objects: ObjRow[]; prompts: TplRow[]; outputs: TplRow[]; + referencedBy: { pkg: string; href: string; n: number }[]; +} + +export function buildPackagePage(pkg: string, g: LinkGraph, cov: CoverageTracker, sourceDirs?: string[]): PackagePageData { + const dirs = sourceDirs ?? []; + const members = g.nodes().filter((n) => n.pkg === pkg); + const pageHref = `${members[0].pkgPath}/index.html`; + const objRow = (n: (typeof members)[0]): ObjRow => ({ + name: n.name, href: `${n.name}.html`, kind: n.node.subType, + table: String(n.node.childrenOfType("source").map((s) => s.attr("table")).find((t) => t !== undefined) ?? ""), + fieldCount: n.node.childrenOfType("field").length, + extendsName: n.node.superResolved?.name ?? "", + }); + const tplRow = (n: (typeof members)[0]): TplRow => { + cov.consumeNode(n.node); cov.consumeAttr(n.node, "payloadRef"); cov.consumeAttr(n.node, "textRef"); cov.consumeAttr(n.node, "format"); + const p = g.refsFrom(fqnOf(n.node)).find((r) => r.kind === "payload"); + const pt = p ? g.byFqn(p.to) : undefined; + return { name: n.name, href: `${n.name}.html`, payloadName: pt?.name ?? String(n.node.attr("payloadRef") ?? ""), payloadHref: pt ? g.relHref(pageHref, pt.href) : "", format: String(n.node.attr("format") ?? "text"), textRef: String(n.node.attr("textRef") ?? "") }; + }; + const objs = members.filter((m) => m.kind === "object"); + const abstracts = objs.filter((m) => m.node.isAbstract).map(objRow).sort((a, b) => a.name.localeCompare(b.name)); + const objects = objs.filter((m) => !m.node.isAbstract).map(objRow).sort((a, b) => a.name.localeCompare(b.name)); + const prompts = members.filter((m) => m.kind === "prompt").map(tplRow).sort((a, b) => a.name.localeCompare(b.name)); + const outputs = members.filter((m) => m.kind === "output").map(tplRow).sort((a, b) => a.name.localeCompare(b.name)); + + // authored prose + key-entity cards + const pd = harvestPackageDocs(dirs).get(pkg); + const title = esc(pd?.title ?? pkg.split("::").pop() ?? pkg); + const descHtml = esc(pd?.description ?? ""); + const keyCards = keyEntities(pkg, g).map((k) => ({ name: k.name, href: g.relHref(pageHref, k.href), inbound: k.inbound })); + + // package ERD — collect internal objects + external neighbor objects + edges + const extNodes = new Map(); + const erdEdges: ErEdge[] = []; + for (const m of objs) { + for (const r of g.refsFrom(fqnOf(m.node))) { + if (r.kind === "extends") continue; + const t = g.byFqn(r.to); if (!t || t.kind !== "object") continue; + erdEdges.push({ parent: t.name, child: m.name, label: r.via }); + if (t.pkg !== pkg) extNodes.set(r.to, t); + } + for (const r of g.refsTo(fqnOf(m.node))) { + if (r.kind === "extends") continue; + const s = g.byFqn(r.from); if (!s || s.kind !== "object" || s.pkg === pkg) continue; + erdEdges.push({ parent: m.name, child: s.name, label: r.via }); + extNodes.set(r.from, s); + } + } + // deduplicate edges + const deduped = erdEdges.filter((e, i) => erdEdges.findIndex((x) => x.parent === e.parent && x.child === e.child && x.label === e.label) === i); + + // mode switch: rich (≤RICH_MAX) vs domain flowchart (large) + const totalNodes = objs.length + extNodes.size; + let erdMermaid: string; + let erdLegend: { pkg: string; fill: string; stroke: string }[] = []; + if (totalNodes <= RICH_MAX) { + const erNodes: ErNode[] = [ + ...objs.map((m) => { const { attrs, more } = pkgAttrs(m.node); return { name: m.name, pkg: m.pkg, role: "normal" as ErNode["role"], kind: m.node.subType, attrs, more }; }), + ...[...extNodes.values()].map((m) => { const { attrs, more } = pkgAttrs(m.node); return { name: m.name, pkg: m.pkg, role: "external" as ErNode["role"], kind: m.node.subType, attrs, more }; }), + ]; + erdMermaid = erDiagramRich(erNodes, deduped); + // build legend from unique packages + const pkgsSeen = new Map(); + for (const n of erNodes) { + if (!pkgsSeen.has(n.pkg)) { + const dc = domainColor(n.pkg); + pkgsSeen.set(n.pkg, { pkg: n.pkg, fill: dc.fill, stroke: dc.stroke }); + } + } + erdLegend = [...pkgsSeen.values()].sort((a, b) => a.pkg.localeCompare(b.pkg)); + } else { + const flowNodes = [ + ...objs.map((m) => ({ name: m.name, pkg: m.pkg, kind: m.node.subType })), + ...[...extNodes.values()].map((m) => ({ name: m.name, pkg: m.pkg, kind: m.node.subType })), + ]; + const r = flowchartDomain(flowNodes, deduped.map((e) => ({ from: e.parent, to: e.child, label: e.label }))); + erdMermaid = r.mermaid; + erdLegend = r.legend; + } + + // inbound package backlinks + const inbound = new Map(); + for (const m of objs) for (const r of g.refsTo(fqnOf(m.node))) { + const s = g.byFqn(r.from); + if (s && s.pkg !== pkg && r.kind !== "extends") inbound.set(s.pkg, (inbound.get(s.pkg) ?? 0) + 1); + } + const referencedBy = [...inbound.entries()].sort().map(([p, n]) => ({ pkg: p, href: g.relHref(pageHref, `${p.split("::").join("/")}/index.html`), n })); + + return { + pkg, pkgPath: members[0].pkgPath, tree: members[0].tree, + breadcrumbHtml: `index / ${esc(pkg)}`, + title, descHtml, keyCards, + erdMermaid, erdLegend, + abstracts, objects, prompts, outputs, referencedBy, + }; +} diff --git a/server/typescript/packages/docs-site/src/builders/prompt-data.ts b/server/typescript/packages/docs-site/src/builders/prompt-data.ts new file mode 100644 index 000000000..615c6240e --- /dev/null +++ b/server/typescript/packages/docs-site/src/builders/prompt-data.ts @@ -0,0 +1,61 @@ +import { existsSync, readFileSync, readdirSync } from "node:fs"; +import { join, dirname } from "node:path"; +import { LinkGraph } from "../link-graph"; +import type { CoverageTracker } from "../coverage"; +import { highlightMustache } from "../mustache-highlight"; +import { esc } from "../badges"; + +export interface PayloadTreeRow { indent: number; name: string; type: string; isArray: boolean; anchor: string; desc: string; refHtml: string; } +export interface PromptPageData { name: string; pkg: string; href: string; breadcrumbHtml: string; attrsHtml: string; desc: string; payloadName: string; payloadHref: string; payloadTree: PayloadTreeRow[]; sourceHtml?: string; sourceMissingNote?: string; tocHtml?: string; packageFiles: { file: string; html: string }[]; } + +export function buildPromptPage(fqn: string, g: LinkGraph, cov: CoverageTracker, sourceDirs: string[]): PromptPageData { + const dn = g.byFqn(fqn)!; + const t = dn.node; + cov.consumeNode(t); + const attrs: string[] = []; + for (const a of ["format", "maxTokens", "requiredSlots", "model", "responseRef", "maxChars", "promptStyle"]) { + const v = t.attr(a); if (v !== undefined) { cov.consumeAttr(t, a); attrs.push(`@${esc(a)} ${esc(v)}`); } + } + cov.consumeAttr(t, "payloadRef"); cov.consumeAttr(t, "textRef"); + // payload tree (root + one nested level) + const pRef = g.refsFrom(fqn).find((r) => r.kind === "payload"); + const payload = pRef ? g.byFqn(pRef.to) : undefined; + const tree: PayloadTreeRow[] = []; + const fieldsOf = (o: NonNullable, indent: number, prefix: string) => { + for (const f of o.node.childrenOfType("field")) { + const ref = f.attr("objectRef"); + const target = typeof ref === "string" ? (g.byFqn(ref.includes("::") ? ref : `${o.pkg}::${ref}`) ?? g.byFqn(ref)) : undefined; + tree.push({ indent, name: prefix + f.name, type: f.subType, isArray: f.resolvedIsArray(), anchor: `f-${prefix}${f.name}`, + desc: esc(f.attr("description") ?? ""), + refHtml: target ? `${esc(target.name)}` : "" }); + if (target && indent === 0) fieldsOf(target, 1, `${f.name}.`); + } + }; + if (payload) fieldsOf(payload, 0, ""); + const anchors = new Map(tree.map((r) => [r.name, `#${r.anchor}`])); + // source resolution + const textRef = String(t.attr("textRef") ?? ""); + let sourceHtml: string | undefined, tocHtml: string | undefined, sourceMissingNote: string | undefined; + let pkgDir: string | undefined; + for (const d of sourceDirs) { + const p = join(d, ...textRef.split("/")) + ".mustache"; + if (existsSync(p)) { pkgDir = dirname(p); + const r = highlightMustache(readFileSync(p, "utf8"), (path) => anchors.get(path)); + sourceHtml = r.html; + tocHtml = r.toc.map((s) => `${esc(s.name)}`).join(" · "); + break; + } + } + if (!sourceHtml) sourceMissingNote = `text ref ${esc(textRef)} does not resolve under the metadata roots (forward-pointing ref).`; + // other mustache files in the template's package dir + const packageFiles: { file: string; html: string }[] = []; + if (pkgDir) for (const f of readdirSync(pkgDir).filter((f) => f.endsWith(".mustache")).sort()) { + if (join(pkgDir, f) === join(pkgDir, `${textRef.split("/").pop()}.mustache`)) continue; + packageFiles.push({ file: f, html: highlightMustache(readFileSync(join(pkgDir, f), "utf8"), (p) => anchors.get(p)).html }); + } + return { name: dn.name, pkg: dn.pkg, href: dn.href, + breadcrumbHtml: `index / ${esc(dn.pkg)} / ${esc(dn.name)}`, + attrsHtml: attrs.join(" "), desc: esc(t.attr("description") ?? ""), + payloadName: payload?.name ?? "", payloadHref: payload ? esc(g.relHref(dn.href, payload.href)) : "", + payloadTree: tree, sourceHtml, sourceMissingNote, tocHtml, packageFiles }; +} diff --git a/server/typescript/packages/docs-site/src/coverage.ts b/server/typescript/packages/docs-site/src/coverage.ts new file mode 100644 index 000000000..b8f523098 --- /dev/null +++ b/server/typescript/packages/docs-site/src/coverage.ts @@ -0,0 +1,29 @@ +import type { MetaData, MetaRoot } from "@metaobjectsdev/metadata"; + +export interface CoverageRow { key: string; count: number; consumed: boolean; } +export interface CoverageReport { kinds: CoverageRow[]; attrs: CoverageRow[]; warnings: string[]; } + +export class CoverageTracker { + private kinds = new Set(); + private attrs = new Set(); + consumeNode(n: MetaData): void { this.kinds.add(`${n.type}.${n.subType}`); } + consumeAttr(n: MetaData, a: string): void { this.attrs.add(`${n.type}:@${a}`); } + report(root: MetaRoot): CoverageReport { + const kindCount = new Map(); + const attrCount = new Map(); + const walk = (n: MetaData) => { + kindCount.set(`${n.type}.${n.subType}`, (kindCount.get(`${n.type}.${n.subType}`) ?? 0) + 1); + for (const [name] of n.ownAttrs()) { + attrCount.set(`${n.type}:@${name}`, (attrCount.get(`${n.type}:@${name}`) ?? 0) + 1); + } + for (const c of n.ownChildren()) walk(c); + }; + for (const c of root.ownChildren()) walk(c); + const rows = (m: Map, seen: Set) => + [...m.entries()].sort(([a], [b]) => (a < b ? -1 : a > b ? 1 : 0)).map(([key, count]) => ({ key, count, consumed: seen.has(key) })); + const kinds = rows(kindCount, this.kinds); + const attrs = rows(attrCount, this.attrs); + const warnings = [...kinds, ...attrs].filter((r) => !r.consumed).map((r) => `coverage: ${r.key} (${r.count}) not rendered by any page`); + return { kinds, attrs, warnings }; + } +} diff --git a/server/typescript/packages/docs-site/src/index.ts b/server/typescript/packages/docs-site/src/index.ts new file mode 100644 index 000000000..9e1c73c50 --- /dev/null +++ b/server/typescript/packages/docs-site/src/index.ts @@ -0,0 +1,2 @@ +export { generateSite } from "./site"; +export type { SiteOptions, SiteResult } from "./site"; diff --git a/server/typescript/packages/docs-site/src/link-check.ts b/server/typescript/packages/docs-site/src/link-check.ts new file mode 100644 index 000000000..5fff98800 --- /dev/null +++ b/server/typescript/packages/docs-site/src/link-check.ts @@ -0,0 +1,24 @@ +import { existsSync, readFileSync } from "node:fs"; +import { dirname, join, normalize } from "node:path"; + +export function checkLinks(outDir: string, pages: string[]): string[] { + const errs: string[] = []; + const idCache = new Map>(); + const idsOf = (p: string) => { + if (!idCache.has(p)) idCache.set(p, new Set([...readFileSync(p, "utf8").matchAll(/id="([^"]+)"/g)].map((m) => m[1]))); + return idCache.get(p)!; + }; + for (const page of pages) { + const html = readFileSync(join(outDir, page), "utf8"); + for (const m of html.matchAll(/href="([^"]+)"/g)) { + const href = m[1]; + if (/^(https?:|mailto:|#$)/.test(href)) continue; + const [file, anchor] = href.split("#"); + const target = file === "" ? page : normalize(join(dirname(page), file)); + const abs = join(outDir, target); + if (!existsSync(abs)) { errs.push(`${page} -> ${href}`); continue; } + if (anchor && !idsOf(abs).has(anchor)) errs.push(`${page} -> ${href}`); + } + } + return errs; +} diff --git a/server/typescript/packages/docs-site/src/link-graph.ts b/server/typescript/packages/docs-site/src/link-graph.ts new file mode 100644 index 000000000..c8805f694 --- /dev/null +++ b/server/typescript/packages/docs-site/src/link-graph.ts @@ -0,0 +1,153 @@ +import type { MetaData } from "@metaobjectsdev/metadata"; +import { type LoadedModel, treeOf } from "./load"; + +export interface DocNode { kind: "object" | "prompt" | "output"; name: string; pkg: string; pkgPath: string; href: string; node: MetaData; tree: string; } +export interface Ref { from: string; to: string; via: string; kind: "field" | "fk" | "extends" | "payload" | "relationship" | "origin"; cardinality?: string; } +export interface OriginRef { field: string; from: string; via: string; } + +export function pkgOf(node: MetaData): string { + return (node.package ?? (node as { fileDefaultPackage?: string }).fileDefaultPackage ?? ""); +} +export function fqnOf(node: MetaData): string { + const p = pkgOf(node); + return p ? `${p}::${node.name}` : node.name; +} + +export class LinkGraph { + private _nodes = new Map(); + private _from = new Map(); + private _to = new Map(); + private _extBy = new Map(); + private _origins = new Map(); + + constructor(model: LoadedModel) { + for (const o of model.root.ownChildren()) { + let kind: DocNode["kind"] | undefined; + if (o.type === "object") kind = "object"; + else if (o.type === "template") kind = o.subType === "prompt" ? "prompt" : "output"; + if (!kind) continue; + const pkg = pkgOf(o); + const pkgPath = pkg.split("::").join("/"); + this._nodes.set(fqnOf(o), { + kind, name: o.name, pkg, pkgPath, + href: `${pkgPath}/${o.name}.html`, node: o, tree: treeOf(o, model), + }); + } + const addRef = (r: Ref) => { + (this._from.get(r.from) ?? this._from.set(r.from, []).get(r.from)!).push(r); + (this._to.get(r.to) ?? this._to.set(r.to, []).get(r.to)!).push(r); + }; + const resolveRef = (raw: string, ctxPkg: string): string | undefined => { + const cand = raw.includes("::") ? raw : `${ctxPkg}::${raw}`; + return this._nodes.has(cand) ? cand : (this._nodes.has(raw) ? raw : undefined); + }; + for (const dn of this._nodes.values()) { + const fqn = fqnOf(dn.node); + if (dn.kind === "object") { + for (const f of dn.node.childrenOfType("field")) { + const ref = f.attr("objectRef"); + if (typeof ref === "string") { + const to = resolveRef(ref, dn.pkg); + if (to) addRef({ from: fqn, to, via: f.name, kind: "field" }); + } + } + for (const id of dn.node.childrenOfType("identity")) { + if (id.subType !== "reference") continue; + const ref = id.attr("references"); + if (typeof ref === "string") { + const to = resolveRef(ref, dn.pkg); + if (to) { + const fieldsValue = id.attr("fields") ?? id.name; + const via = Array.isArray(fieldsValue) ? fieldsValue.join(", ") : String(fieldsValue); + addRef({ from: fqn, to, via, kind: "fk" }); + } + } + } + for (const rel of dn.node.childrenOfType("relationship")) { + const ref = rel.attr("objectRef"); + if (typeof ref === "string") { + const to = resolveRef(ref, dn.pkg); + if (to) addRef({ from: fqn, to, via: rel.name, kind: "relationship", cardinality: String(rel.attr("cardinality") ?? "") }); + } + } + for (const f of dn.node.childrenOfType("field")) { + for (const org of f.childrenOfType("origin")) { + const from = String(org.attr("from") ?? ""), via = String(org.attr("via") ?? ""); + (this._origins.get(fqn) ?? this._origins.set(fqn, []).get(fqn)!).push({ field: f.name, from, via }); + // connect a projection to its source object so it is not orphaned on diagrams: + // `from` is "pkg::Entity.field" — strip the trailing ".field" to get the source object ref. + const dot = from.lastIndexOf("."); + const srcRef = dot > 0 ? from.slice(0, dot) : ""; + const to = srcRef ? resolveRef(srcRef, dn.pkg) : undefined; + if (to && to !== fqn) addRef({ from: fqn, to, via: `${f.name} (origin)`, kind: "origin" }); + } + } + const sup = dn.node.superResolved; + if (sup) { + const supFqn = fqnOf(sup); + addRef({ from: fqn, to: supFqn, via: "extends", kind: "extends" }); + (this._extBy.get(supFqn) ?? this._extBy.set(supFqn, []).get(supFqn)!).push(dn); + } + } else { + const p = dn.node.attr("payloadRef"); + if (typeof p === "string") { + const to = resolveRef(p, dn.pkg); + if (to) addRef({ from: fqn, to, via: "payloadRef", kind: "payload" }); + } + } + } + } + nodes(): DocNode[] { return [...this._nodes.values()]; } + byFqn(fqn: string): DocNode | undefined { return this._nodes.get(fqn); } + refsFrom(fqn: string): Ref[] { return this._from.get(fqn) ?? []; } + refsTo(fqn: string): Ref[] { return this._to.get(fqn) ?? []; } + degree(fqn: string): number { + const n = (rs: Ref[]) => rs.filter((r) => r.kind !== "extends").length; + return n(this.refsFrom(fqn)) + n(this.refsTo(fqn)); + } + extendedBy(fqn: string): DocNode[] { return this._extBy.get(fqn) ?? []; } + originsOf(fqn: string): OriginRef[] { return this._origins.get(fqn) ?? []; } + ancestors(fqn: string): DocNode[] { + const out: DocNode[] = []; + const start = this._nodes.get(fqn); + if (!start) return out; + for (let s = start.node.superResolved; s; s = s.superResolved) { + const t = this._nodes.get(fqnOf(s)); + if (!t) break; + out.push(t); + } + return out; + } + relationshipsOf(fqn: string): { name: string; toFqn: string; cardinality: string }[] { + return this.refsFrom(fqn).filter((r) => r.kind === "relationship") + .map((r) => ({ name: r.via, toFqn: r.to, cardinality: r.cardinality ?? "" })) + .sort((a, b) => a.name.localeCompare(b.name)); + } + relHref(fromHref: string, toHref: string): string { + const fromParts = fromHref.split("/"); + const toParts = toHref.split("/"); + + // Remove the filename from fromHref to get the directory + fromParts.pop(); + + // Find the common prefix length + // Note: toParts.length - 1 excludes the filename segment, comparing only directories + let commonLen = 0; + for (let i = 0; i < Math.min(fromParts.length, toParts.length - 1); i++) { + if (fromParts[i] === toParts[i]) { + commonLen++; + } else { + break; + } + } + + // Calculate levels to go up + const up = fromParts.length - commonLen; + + // Get the remaining path from the common ancestor + const remaining = toParts.slice(commonLen).join("/"); + + // Construct the relative path + return up > 0 ? "../".repeat(up) + remaining : remaining; + } +} diff --git a/server/typescript/packages/docs-site/src/load.ts b/server/typescript/packages/docs-site/src/load.ts new file mode 100644 index 000000000..4d4a077c5 --- /dev/null +++ b/server/typescript/packages/docs-site/src/load.ts @@ -0,0 +1,47 @@ +import { mkdtempSync, rmSync, symlinkSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { basename, join, resolve } from "node:path"; +import { MetaDataLoader, composeRegistry, coreTypesProvider, dbProvider, docProvider, promptProvider, uiProvider } from "@metaobjectsdev/metadata"; +import type { MetaData, MetaRoot } from "@metaobjectsdev/metadata"; + +export interface LoadedModel { + root: MetaRoot; + warnings: string[]; + sourceDirs: string[]; +} + +/** Load N metadata source dirs into ONE root via a staging dir of symlinks. */ +export async function loadModel(sourceDirs: string[]): Promise { + const staging = mkdtempSync(join(tmpdir(), "metadocs-")); + try { + const usedBasenames = new Set(); + for (const dir of sourceDirs) { + const baseName = basename(dir); + if (usedBasenames.has(baseName)) { + throw new Error(`duplicate source dir basename: ${baseName}`); + } + usedBasenames.add(baseName); + symlinkSync(resolve(dir), join(staging, baseName)); + } + const registry = composeRegistry([coreTypesProvider, dbProvider, docProvider, promptProvider, uiProvider]); + const result = await MetaDataLoader.fromDirectory(staging, { registry, strict: false }); + if (result.errors.length > 0) { + throw new Error(`metadata load failed:\n${result.errors.map((e) => String(e)).join("\n")}`); + } + return { + root: result.root, + warnings: result.warnings.map((w) => w.message), + sourceDirs: sourceDirs.map((d) => basename(resolve(d))), + }; + } finally { + rmSync(staging, { recursive: true, force: true }); + } +} + +/** Which top-level source dir a node came from (first file path segment of its source envelope). */ +export function treeOf(node: MetaData, model: LoadedModel): string { + const src = node.source as { files?: string[] }; + const f = src.files?.[0] ?? ""; + const seg = f.replace(/\\/g, "/").split("/")[0] ?? ""; + return model.sourceDirs.includes(seg) ? seg : (model.sourceDirs[0] ?? ""); +} diff --git a/server/typescript/packages/docs-site/src/mermaid.ts b/server/typescript/packages/docs-site/src/mermaid.ts new file mode 100644 index 000000000..215fdafb7 --- /dev/null +++ b/server/typescript/packages/docs-site/src/mermaid.ts @@ -0,0 +1,137 @@ +export interface ErEdge { parent: string; child: string; label: string; } + +// base theme is the only theme that honors custom themeVariables; the built-in `dark` +// theme renders attribute-bearing ERDs unreadably. Surfaces match the site's dark palette. +export const THEME_INIT = + `%%{init: {'theme':'base','themeVariables':{'darkMode':true,'background':'#0b1220',` + + `'primaryColor':'#1e2a3a','primaryTextColor':'#cbd5e1','primaryBorderColor':'#4a7fa5',` + + `'lineColor':'#64748b','secondaryColor':'#1a2535','tertiaryColor':'#0f1826',` + + `'fontSize':'13px'}}}%%\n`; + +const safe = (s: string) => s.replace(/"/g, "'"); +const nodeId = (s: string) => s.replace(/[^a-zA-Z0-9_]/g, "_"); +const edgeSort = (a: ErEdge, b: ErEdge) => + a.parent.localeCompare(b.parent) || a.child.localeCompare(b.child) || a.label.localeCompare(b.label); + +export function packageFlowchart(edges: { from: string; to: string; n: number }[], counts: Map): string { + const lines = [THEME_INIT + "flowchart LR"]; + const used = new Set(edges.flatMap((e) => [e.from, e.to])); + for (const p of [...used].sort()) lines.push(` ${p}["${safe(p)} · ${counts.get(p) ?? 0}"]`); + for (const e of [...edges].sort((a, b) => a.from.localeCompare(b.from) || a.to.localeCompare(b.to))) + lines.push(` ${e.from} -->|${e.n}| ${e.to}`); + return lines.join("\n"); +} + +export function inheritanceTree(rows: { name: string; level: number; self?: boolean }[]): string { + const lines = [THEME_INIT + "flowchart TD"]; + const ordered = [...rows].sort((a, b) => a.level - b.level || a.name.localeCompare(b.name)); + for (const r of ordered) lines.push(` ${nodeId(r.name)}["${safe(r.name)}"]${r.self ? ":::self" : ""}`); + // connect each node to the alphabetically-first node one level below it whose ancestor it is: + // for a simple chain+children, link consecutive levels deterministically. + const byLevel = new Map(); + for (const r of ordered) (byLevel.get(r.level) ?? byLevel.set(r.level, []).get(r.level)!).push(nodeId(r.name)); + const levels = [...byLevel.keys()].sort((a, b) => a - b); + for (let i = 1; i < levels.length; i++) { + const parents = byLevel.get(levels[i - 1])!; + const parent = parents[parents.length - 1]; // the chain node at the shallower level + for (const child of byLevel.get(levels[i])!.sort()) lines.push(` ${parent} --> ${child}`); + } + lines.push(" classDef self fill:#1e3a5f,stroke:#60a5fa,color:#e2e8f0,font-weight:bold"); + return lines.join("\n"); +} + +// ---- v2 diagram system: domain palette + rich/simple emitters ---- +export const RICH_MAX = 8; +const ROLE_STROKE: Record = { focal: "#60a5fa", view: "#2dd4bf", external: "#334155", normal: "#3b5170" }; +const CURATED: Record = { + session: { fill: "#1e3a5f", stroke: "#60a5fa", text: "#93c5fd" }, + npc: { fill: "#3b2f1e", stroke: "#fbbf24", text: "#fde68a" }, + player: { fill: "#3f2d5c", stroke: "#a78bfa", text: "#ede9fe" }, + arc: { fill: "#3f1f2e", stroke: "#fb7185", text: "#fecdd3" }, + world: { fill: "#14342b", stroke: "#34d399", text: "#a7f3d0" }, + engine_misc: { fill: "#1f2937", stroke: "#94a3b8", text: "#e2e8f0" }, + memory: { fill: "#2a2440", stroke: "#818cf8", text: "#e0e7ff" }, + turn: { fill: "#1a2e35", stroke: "#22d3ee", text: "#cffafe" }, + realm: { fill: "#332018", stroke: "#fb923c", text: "#fed7aa" }, + common: { fill: "#1c2431", stroke: "#64748b", text: "#cbd5e1" }, +}; +const PALETTE: { fill: string; stroke: string; text: string }[] = Object.values(CURATED); +export function domainColor(pkg: string): { fill: string; stroke: string; text: string } { + const leaf = pkg.split("::").pop() ?? pkg; + if (CURATED[leaf]) return CURATED[leaf]; + // stable slot for unmapped packages: hash the leaf name deterministically into the palette + let h = 0; for (let i = 0; i < leaf.length; i++) h = (h * 31 + leaf.charCodeAt(i)) >>> 0; + return PALETTE[h % PALETTE.length]; +} +const cls = (pkg: string) => `d_${(pkg.split("::").pop() ?? pkg).replace(/[^a-zA-Z0-9]/g, "_")}`; + +export interface ErAttr { type: string; name: string; key: "PK" | "FK" | "UK" | ""; note: string; } +export interface ErNode { name: string; pkg: string; role: "focal" | "view" | "external" | "normal"; kind?: string; attrs: ErAttr[]; more: number; } +// erDiagram can't vary box shape, so KIND is encoded as a border dash + a glyph prefix in the box title. +const KIND_DASH: Record = { value: ",stroke-dasharray:5 3", projection: ",stroke-dasharray:2 2" }; +const KIND_GLYPH: Record = { entity: "▭", value: "⬭", projection: "▱" }; +const kindGlyph = (kind?: string) => KIND_GLYPH[kind ?? ""] ?? "▭"; + +export function erDiagramRich(nodes: ErNode[], edges: ErEdge[]): string { + const lines = [THEME_INIT + "erDiagram"]; + for (const n of [...nodes].sort((a, b) => a.name.localeCompare(b.name))) { + lines.push(` ${nodeId(n.name)}["${kindGlyph(n.kind)} ${safe(n.name)}"] {`); + for (const a of n.attrs) { + const keyPart = a.key ? ` ${a.key}` : ""; + lines.push(` ${a.type} ${a.name}${keyPart} "${safe(a.note)}"`); + } + if (n.more > 0) lines.push(` _ plus "+${n.more} more"`); + lines.push(" }"); + } + for (const e of [...edges].sort(edgeSort)) lines.push(` ${nodeId(e.parent)} ||--o{ ${nodeId(e.child)} : "${safe(e.label)}"`); + // one classDef per entity: fill = domain, stroke = role + for (const n of [...nodes].sort((a, b) => a.name.localeCompare(b.name))) { + const dc = domainColor(n.pkg); + lines.push(` ${nodeId(n.name)}:::c_${nodeId(n.name)}`); + lines.push(` classDef c_${nodeId(n.name)} fill:${dc.fill},stroke:${ROLE_STROKE[n.role]},color:#cbd5e1${KIND_DASH[n.kind ?? ""] ?? ""}`); + } + return lines.join("\n"); +} + +// node shape encodes object KIND: entity = rectangle, value object = stadium (rounded ends), +// projection/view = parallelogram. Domain is the fill color; kind is the silhouette. +function shapeDecl(id: string, label: string, kind?: string): string { + const l = `"${label}"`; + if (kind === "value") return `${id}([${l}])`; + if (kind === "projection") return `${id}[/${l}/]`; + return `${id}[${l}]`; // entity / default +} + +export function flowchartDomain( + nodes: { name: string; pkg: string; kind?: string }[], + edges: { from: string; to: string; label?: string }[], +): { mermaid: string; legend: { pkg: string; fill: string; stroke: string }[] } { + const lines = [THEME_INIT + "flowchart LR"]; + const kindByName = new Map(nodes.map((n) => [n.name, n.kind])); + // collect all node names (from node list + edge endpoints), deduplicate, declare with explicit labels + const allNodeNames = new Set([ + ...nodes.map((n) => n.name), + ...edges.flatMap((e) => [e.from, e.to]), + ]); + for (const name of [...allNodeNames].sort()) lines.push(` ${shapeDecl(nodeId(name), safe(name), kindByName.get(name))}`); + // edge labels must not contain flowchart-breaking chars (parens, pipes, brackets, braces) + const edgeLabel = (s: string) => s.replace(/["|(){}\[\]]/g, "").replace(/\s+/g, " ").trim(); + for (const e of [...edges].sort((a, b) => a.from.localeCompare(b.from) || a.to.localeCompare(b.to))) { + const lbl = e.label ? edgeLabel(e.label) : ""; + lines.push(lbl ? ` ${nodeId(e.from)} -->|${lbl}| ${nodeId(e.to)}` : ` ${nodeId(e.from)} --> ${nodeId(e.to)}`); + } + // group node names by domain class, assign, and emit one classDef per used domain + const byCls = new Map(); + for (const n of [...nodes].sort((a, b) => a.name.localeCompare(b.name))) { + const c = cls(n.pkg); + (byCls.get(c) ?? byCls.set(c, { pkg: n.pkg, ids: [] }).get(c)!).ids.push(nodeId(n.name)); + } + for (const [c, g] of [...byCls.entries()].sort(([a], [b]) => a.localeCompare(b))) { + const dc = domainColor(g.pkg); + lines.push(` class ${g.ids.sort().join(",")} ${c}`); + lines.push(` classDef ${c} fill:${dc.fill},stroke:${dc.stroke},color:${dc.text}`); + } + const legend = [...new Map(nodes.map((n) => [n.pkg, n.pkg])).keys()].sort() + .map((pkg) => ({ pkg, fill: domainColor(pkg).fill, stroke: domainColor(pkg).stroke })); + return { mermaid: lines.join("\n"), legend }; +} diff --git a/server/typescript/packages/docs-site/src/mustache-highlight.ts b/server/typescript/packages/docs-site/src/mustache-highlight.ts new file mode 100644 index 000000000..ffd8ddfd7 --- /dev/null +++ b/server/typescript/packages/docs-site/src/mustache-highlight.ts @@ -0,0 +1,43 @@ +export interface HighlightResult { html: string; toc: { name: string; anchor: string }[]; refs: string[]; } + +const esc = (s: string) => s.replace(/&/g, "&").replace(//g, ">").replace(/"/g, """); + +export function highlightMustache(src: string, resolveHref: (p: string) => string | undefined): HighlightResult { + let out = ""; + const toc: { name: string; anchor: string }[] = []; + const refs: string[] = []; + let depth = 0; + let i = 0; + while (i < src.length) { + const open = src.indexOf("{{", i); + if (open === -1) { out += esc(src.slice(i)); break; } + out += esc(src.slice(i, open)); + const triple = src.startsWith("{{{", open); + const close = src.indexOf(triple ? "}}}" : "}}", open); + if (close === -1) { out += esc(src.slice(open)); break; } + const end = close + (triple ? 3 : 2); + const rawTok = src.slice(open, end); + const inner = src.slice(open + (triple ? 3 : 2), close).trim(); + const sigil = triple ? "{" : inner[0] ?? ""; + const name = triple ? inner : inner.replace(/^[#^\/>!&]\s*/, ""); + const span = (cls: string, extra = "") => `${esc(rawTok)}`; + if (!triple && sigil === "!") out += span("mu-com"); + else if (!triple && sigil === ">") out += span("mu-par"); + else if (!triple && (sigil === "#" || sigil === "^")) { + refs.push(name); + const href = resolveHref(name); + if (depth === 0 && sigil === "#") { toc.push({ name, anchor: `sec-${name}` }); } + const idAttr = depth === 0 && sigil === "#" ? ` id="sec-${esc(name)}"` : ""; + out += href ? `${esc(rawTok)}` : span("mu-sec mu-unresolved", idAttr); + depth++; + } else if (!triple && sigil === "/") { depth = Math.max(0, depth - 1); out += span("mu-sec"); } + else { + refs.push(name); + const cls = triple || sigil === "&" ? "mu-raw" : "mu-var"; + const href = resolveHref(name); + out += href ? `${esc(rawTok)}` : span(`${cls} mu-unresolved`); + } + i = end; + } + return { html: out, toc, refs }; +} diff --git a/server/typescript/packages/docs-site/src/package-docs.ts b/server/typescript/packages/docs-site/src/package-docs.ts new file mode 100644 index 000000000..24b1be537 --- /dev/null +++ b/server/typescript/packages/docs-site/src/package-docs.ts @@ -0,0 +1,33 @@ +import { readdirSync, readFileSync, statSync } from "node:fs"; +import { join } from "node:path"; +import { parse } from "yaml"; +import { LinkGraph, fqnOf } from "./link-graph"; + +export interface PackageDoc { title: string; description: string; } + +export function harvestPackageDocs(sourceDirs: string[]): Map { + const out = new Map(); + const walk = (d: string) => { + for (const e of readdirSync(d)) { + const p = join(d, e); + if (statSync(p).isDirectory()) walk(p); + else if (e === "_package.yaml") { + try { + const y = parse(readFileSync(p, "utf8")) as { metadata?: { package?: string; title?: string; description?: string } }; + const m = y?.metadata; + if (m?.package) out.set(m.package, { title: String(m.title ?? ""), description: String(m.description ?? "") }); + } catch { /* malformed _package.yaml is skipped; coverage/anomalies surface it elsewhere */ } + } + } + }; + for (const d of sourceDirs) walk(d); + return out; +} + +export function keyEntities(pkg: string, g: LinkGraph, n = 4): { name: string; href: string; inbound: number }[] { + return g.nodes() + .filter((dn) => dn.kind === "object" && dn.pkg === pkg && !dn.node.isAbstract) + .map((dn) => ({ name: dn.name, href: dn.href, inbound: g.refsTo(fqnOf(dn.node)).filter((r) => r.kind !== "extends").length })) + .sort((a, b) => b.inbound - a.inbound || a.name.localeCompare(b.name)) + .slice(0, n); +} diff --git a/server/typescript/packages/docs-site/src/site.ts b/server/typescript/packages/docs-site/src/site.ts new file mode 100644 index 000000000..366c6effa --- /dev/null +++ b/server/typescript/packages/docs-site/src/site.ts @@ -0,0 +1,275 @@ +import { existsSync, mkdirSync, readFileSync, writeFileSync } from "node:fs"; +import { dirname, join, resolve } from "node:path"; +import { render, InMemoryProvider } from "@metaobjectsdev/render"; +import { loadModel } from "./load"; +import { LinkGraph, fqnOf } from "./link-graph"; +import { CoverageTracker } from "./coverage"; +import type { CoverageReport } from "./coverage"; +import { harvestComments } from "./yaml-comments"; +import { buildIndexPage } from "./builders/index-data"; +import type { CoreConfig } from "./builders/index-data"; +import { buildPackagePage } from "./builders/package-data"; +import { buildObjectPage } from "./builders/object-data"; +import { buildPromptPage } from "./builders/prompt-data"; +import { buildOutputPage } from "./builders/output-data"; +import { buildEnumsPage, findAnomalies, buildSearchIndex } from "./builders/extras"; +import type { Anomaly } from "./builders/extras"; +import { checkLinks } from "./link-check"; +import { legendHtml, esc as escBadge } from "./badges"; +import type { ObjectPageData } from "./builders/object-data"; +import type { PromptPageData } from "./builders/prompt-data"; +import type { OutputPageData } from "./builders/output-data"; + +// ─── Public API ──────────────────────────────────────────────────────────────── + +export interface SiteOptions { + sourceDirs: string[]; + outDir: string; + title: string; + stamp: string; + commit: string; + core?: CoreConfig; + /** Override dir; if a file of the same basename exists here, it wins over the bundled templates/ dir. */ + templatesDir?: string; +} + +export interface SiteResult { + pages: string[]; + coverage: CoverageReport; + anomalies: Anomaly[]; + dangling: string[]; +} + +// ─── Helpers ─────────────────────────────────────────────────────────────────── + +/** Relative-root prefix: "" for root pages, "../../" for depth-2 pages. */ +function relRootFor(href: string): string { + const depth = href.split("/").length - 1; + return "../".repeat(depth); +} + +/** Write a file, creating parent dirs as needed. */ +function writeOut(outDir: string, relPath: string, content: string | Uint8Array): string { + const abs = join(outDir, relPath); + mkdirSync(dirname(abs), { recursive: true }); + writeFileSync(abs, content); + return relPath; +} + +// ─── Template loading ────────────────────────────────────────────────────────── + +const BUNDLED_TEMPLATES = resolve(import.meta.dir, "../templates"); + +function loadTemplate(name: string, overrideDir?: string): string { + if (overrideDir) { + const candidate = join(overrideDir, name); + if (existsSync(candidate)) return readFileSync(candidate, "utf8"); + } + return readFileSync(join(BUNDLED_TEMPLATES, name), "utf8"); +} + +// ─── Nav HTML ───────────────────────────────────────────────────────────────── + +/** + * Build the sidebar nav HTML for a specific page as a collapsible tree. + * Each package is a
element (open if it's the current page's package). + * Members are listed as links within each package. Deterministic: sorted packages, sorted members. + */ +function buildNavHtml( + g: LinkGraph, + relRoot: string, + currentPkgPath: string, + dataPackages: { pkg: string; pkgPath: string }[], + promptPackages: { pkg: string; pkgPath: string }[], +): string { + const nodes = g.nodes(); + + const renderGroup = (label: string, pkgList: { pkg: string; pkgPath: string }[]): string => { + if (pkgList.length === 0) return ""; + const parts: string[] = [`
${label}
`]; + for (const p of pkgList) { + const isOpen = p.pkgPath === currentPkgPath; + const pkgHref = escBadge(relRoot + p.pkgPath + "/index.html"); + const pkgLabel = escBadge(p.pkg); + const members = nodes + .filter((n) => n.pkg === p.pkg) + .sort((a, b) => a.name.localeCompare(b.name)); + const memberLinks = members.map((m) => + `${escBadge(m.name)}` + ).join("\n"); + parts.push( + `\n` + + `${pkgLabel}\n` + + memberLinks + `\n
` + ); + } + return parts.join("\n"); + }; + + return [ + renderGroup("Data", dataPackages), + renderGroup("Prompts", promptPackages), + ].filter(Boolean).join("\n"); +} + +// ─── Orchestrator ───────────────────────────────────────────────────────────── + +export async function generateSite(opts: SiteOptions): Promise { + // 1. Load + graph + comments + const loaded = await loadModel(opts.sourceDirs); + const g = new LinkGraph(loaded); + const docs = harvestComments(opts.sourceDirs); + const cov = new CoverageTracker(); + + // 2. Derive package lists (deterministic sort) + const nodes = g.nodes(); + const allPkgs = [...new Set(nodes.map((n) => n.pkg))].sort(); + const promptPkgSet = new Set(nodes.filter((n) => n.kind !== "object").map((n) => n.pkg)); + const dataPkgList = allPkgs + .filter((p) => !promptPkgSet.has(p)) + .map((p) => ({ pkg: p, pkgPath: p.split("::").join("/") })); + const promptPkgList = allPkgs + .filter((p) => promptPkgSet.has(p)) + .map((p) => ({ pkg: p, pkgPath: p.split("::").join("/") })); + + // 3. Load chrome templates (fixed) + const chromeHead = loadTemplate("chrome-head.mustache", opts.templatesDir); + const chromeFoot = loadTemplate("chrome-foot.mustache", opts.templatesDir); + + // 4. Rendering helper: concat chrome + page template, render with payload + shared fields + const provider = new InMemoryProvider({}); + const pages: string[] = []; + + const legend = legendHtml(); + + // TOC builders — only list sections with non-empty data; ids must match id="s-..." in templates + const objectTocHtml = (d: ObjectPageData): string => { + const sections: { id: string; label: string; present: boolean }[] = [ + { id: "s-overview", label: "Overview", present: !!d.desc }, + { id: "s-fields", label: "Fields", present: d.ownFields.length > 0 }, + { id: "s-indexes", label: "Indexes & keys", present: d.indexes.length > 0 }, + { id: "s-validators", label: "Validators", present: d.validators.length > 0 }, + { id: "s-relationships", label: "Relationships", present: d.relations.length > 0 }, + { id: "s-provenance", label: "Field provenance", present: d.origins.length > 0 }, + { id: "s-inheritance", label: "Inheritance", present: !!d.inheritanceMermaid }, + { id: "s-neighborhood", label: "Neighborhood", present: !!d.neighborhoodMermaid }, + { id: "s-referenced-by", label: "Referenced by", present: d.referencedBy.length > 0 }, + ]; + return sections + .filter((s) => s.present) + .map((s) => `${s.label}`) + .join("\n"); + }; + + const promptTocHtml = (d: PromptPageData): string => { + const sections: { id: string; label: string; present: boolean }[] = [ + { id: "s-payload", label: "Payload tree", present: d.payloadTree.length > 0 }, + { id: "s-source", label: "Source", present: !!d.sourceHtml }, + ]; + return sections + .filter((s) => s.present) + .map((s) => `${s.label}`) + .join("\n"); + }; + + const outputTocHtml = (d: OutputPageData): string => { + const sections: { id: string; label: string; present: boolean }[] = [ + { id: "s-contract", label: "Parse contract", present: d.fields.length > 0 }, + ]; + return sections + .filter((s) => s.present) + .map((s) => `${s.label}`) + .join("\n"); + }; + + const renderPage = ( + pageTemplateName: string, + href: string, + pageData: Record, + tocHtml: string = "", + currentPkgPath: string = "", + ): void => { + const relRoot = relRootFor(href); + const navHtml = buildNavHtml(g, relRoot, currentPkgPath, dataPkgList, promptPkgList); + const pageTpl = loadTemplate(pageTemplateName, opts.templatesDir); + const template = chromeHead + pageTpl + chromeFoot; + const payload = { + ...pageData, + title: opts.title, + stamp: opts.stamp, + commit: opts.commit, + relRoot, + navHtml, + tocHtml, + legendHtml: legend, + }; + const html = render({ template, payload, provider, format: "text" }); + writeOut(opts.outDir, href, html); + pages.push(href); + }; + + // 5. Index page + const indexData = buildIndexPage(g, cov, { + title: opts.title, + stamp: opts.stamp, + commit: opts.commit, + core: opts.core, + sourceDirs: opts.sourceDirs, + }); + renderPage("index.html.mustache", "index.html", indexData as unknown as Record); + + // 6. Enums page + const enumRows = buildEnumsPage(g); + renderPage("enums.html.mustache", "enums.html", { rows: enumRows } as Record); + + // 7. Anomalies + coverage (deferred until all pages built — build now, write after member pages) + const anomalies = findAnomalies(g, opts.sourceDirs); + + // 8. Package + member pages (deterministic: pkgs sorted, then members sorted) + for (const pkg of allPkgs) { + const pkgData = buildPackagePage(pkg, g, cov, opts.sourceDirs); + const pkgHref = `${pkgData.pkgPath}/index.html`; + renderPage("package.html.mustache", pkgHref, pkgData as unknown as Record, "", pkgData.pkgPath); + + // Member pages sorted by name + const pkgNodes = nodes.filter((n) => n.pkg === pkg).sort((a, b) => a.name.localeCompare(b.name)); + for (const n of pkgNodes) { + const fqn = fqnOf(n.node); + const pkgPath = n.pkgPath; + if (n.kind === "object") { + const data = buildObjectPage(fqn, g, cov); + renderPage("object.html.mustache", n.href, data as unknown as Record, objectTocHtml(data), pkgPath); + } else if (n.kind === "prompt") { + const data = buildPromptPage(fqn, g, cov, opts.sourceDirs); + renderPage("prompt.html.mustache", n.href, data as unknown as Record, promptTocHtml(data), pkgPath); + } else if (n.kind === "output") { + const data = buildOutputPage(fqn, g, cov, docs, opts.sourceDirs); + renderPage("output.html.mustache", n.href, data as unknown as Record, outputTocHtml(data), pkgPath); + } + } + } + + // 9. Coverage page (after all member pages so cov is fully populated) + const coverage = cov.report(loaded.root); + const coverageData = { + kinds: coverage.kinds, + attrs: coverage.attrs, + anomalies, + }; + renderPage("coverage.html.mustache", "coverage.html", coverageData as Record); + + // 10. Write assets + const assetsDir = resolve(import.meta.dir, "../assets"); + writeOut(opts.outDir, "assets/site.css", readFileSync(join(assetsDir, "site.css"), "utf8")); + writeOut(opts.outDir, "assets/site.js", readFileSync(join(assetsDir, "site.js"), "utf8")); + + // 11. Search index + const searchIndex = buildSearchIndex(g); + writeOut(opts.outDir, "assets/search-index.json", JSON.stringify(searchIndex)); + + // 12. Link check (throws if any dangling links found) + const dangling = checkLinks(opts.outDir, pages); + if (dangling.length) throw new Error("link check failed:\n" + dangling.join("\n")); + + return { pages, coverage, anomalies, dangling }; +} diff --git a/server/typescript/packages/docs-site/src/yaml-comments.ts b/server/typescript/packages/docs-site/src/yaml-comments.ts new file mode 100644 index 000000000..10d17c485 --- /dev/null +++ b/server/typescript/packages/docs-site/src/yaml-comments.ts @@ -0,0 +1,33 @@ +import { readdirSync, readFileSync, statSync } from "node:fs"; +import { join } from "node:path"; + +export interface CommentDocs { objectDesc: Map; fieldNote: Map; } +const clean = (c: string) => c.replace(/^[#\s─═-]+/, "").replace(/[─═]+\s*$/, "").trim(); + +export function harvestComments(sourceDirs: string[]): CommentDocs { + const objectDesc = new Map(); const fieldNote = new Map(); + const files: string[] = []; + const walk = (d: string) => { for (const e of readdirSync(d)) { const p = join(d, e); if (statSync(p).isDirectory()) walk(p); else if (/\.ya?ml$/.test(e)) files.push(p); } }; + for (const d of sourceDirs) walk(d); + for (const f of files) { + const lines = readFileSync(f, "utf8").split("\n"); + let current: string | undefined; + for (let i = 0; i < lines.length; i++) { + const m = lines[i].match(/^\s*-\s*object\.\w+:/); + if (m) { + current = undefined; + for (let j = i + 1; j < Math.min(i + 4, lines.length); j++) { + const nm = lines[j].match(/^\s*name:\s*(\S+)/); if (nm) { current = nm[1]; break; } + } + if (current) { + const desc: string[] = []; + for (let k = i - 1; k >= 0 && lines[k].trim().startsWith("#"); k--) { const c = clean(lines[k].trim()); if (c) desc.unshift(c); } + if (desc.length) objectDesc.set(current, desc.join(" ").replace(/\s+/g, " ").slice(0, 400)); + } + } + const fm = lines[i].match(/^\s*-\s*field\.\w+:\s*\{\s*name:\s*(\w+)[^}]*\}\s*#\s*(.+)$/); + if (fm && current) fieldNote.set(`${current}.${fm[1]}`, clean(fm[2]).slice(0, 160)); + } + } + return { objectDesc, fieldNote }; +} diff --git a/server/typescript/packages/docs-site/templates/chrome-foot.mustache b/server/typescript/packages/docs-site/templates/chrome-foot.mustache new file mode 100644 index 000000000..3938c27e3 --- /dev/null +++ b/server/typescript/packages/docs-site/templates/chrome-foot.mustache @@ -0,0 +1,17 @@ + + + + + + + +
generated {{stamp}} · {{commit}} · metadata-docs
+ + + diff --git a/server/typescript/packages/docs-site/templates/chrome-head.mustache b/server/typescript/packages/docs-site/templates/chrome-head.mustache new file mode 100644 index 000000000..1bdf82752 --- /dev/null +++ b/server/typescript/packages/docs-site/templates/chrome-head.mustache @@ -0,0 +1,21 @@ + + + + +{{title}} + + + + + + + + +
+ +
diff --git a/server/typescript/packages/docs-site/templates/coverage.html.mustache b/server/typescript/packages/docs-site/templates/coverage.html.mustache new file mode 100644 index 000000000..365c6f478 --- /dev/null +++ b/server/typescript/packages/docs-site/templates/coverage.html.mustache @@ -0,0 +1,24 @@ +

Coverage

+

Which metadata node kinds and attributes are rendered by the doc pages.

+{{#kinds.length}} +

Node kinds

+
+ + {{#kinds}}{{/kinds}} +
KindCountRendered
{{key}}{{count}}{{#consumed}}{{/consumed}}{{^consumed}}{{/consumed}}
+{{/kinds.length}} +{{#attrs.length}} +

Attributes

+
+ + {{#attrs}}{{/attrs}} +
AttributeCountRendered
{{key}}{{count}}{{#consumed}}{{/consumed}}{{^consumed}}{{/consumed}}
+{{/attrs.length}} +{{#anomalies.length}} +

Anomalies

+
+ + {{#anomalies}}{{/anomalies}} +
KindSubjectDetail
{{kind}}{{subject}}{{detail}}
+{{/anomalies.length}} +{{^anomalies.length}}

No anomalies detected.

{{/anomalies.length}} diff --git a/server/typescript/packages/docs-site/templates/enums.html.mustache b/server/typescript/packages/docs-site/templates/enums.html.mustache new file mode 100644 index 000000000..faf9e3bf8 --- /dev/null +++ b/server/typescript/packages/docs-site/templates/enums.html.mustache @@ -0,0 +1,14 @@ +

Enums

+

All enum-valued fields across the metadata.

+{{#rows.length}} +
+ + {{#rows}} + + + + + {{/rows}} +
OwnerFieldValuesDefault
{{owner}}{{field}}{{#values}}{{.}}{{/values}}{{deflt}}
+{{/rows.length}} +{{^rows.length}}

No enum fields found.

{{/rows.length}} diff --git a/server/typescript/packages/docs-site/templates/index.html.mustache b/server/typescript/packages/docs-site/templates/index.html.mustache new file mode 100644 index 000000000..0d2895130 --- /dev/null +++ b/server/typescript/packages/docs-site/templates/index.html.mustache @@ -0,0 +1,54 @@ +

{{title}}

+
+
objects
{{stats.objects}}
+
tables
{{stats.tables}}
+
packages
{{stats.packages}}
+
prompt VOs
{{stats.promptVos}}
+
prompts
{{stats.prompts}}
+
contracts
{{stats.contracts}}
+
enums
{{stats.enums}}
+
+{{#coreMermaid}} +
+
Core ERD
+
{{coreMermaid}}
+
{{coreCaption}}
+ {{#coreLegend.length}}
{{#coreLegend}}{{pkg}}{{/coreLegend}}
+
entity · value object · view
{{/coreLegend.length}} +
+{{/coreMermaid}} +{{#packageMermaid}} +
+
Package dependencies
+
{{packageMermaid}}
+
+{{/packageMermaid}} +{{#fullEdges.length}} +
+ All cross-package edges +
+ + {{#fullEdges}}{{/fullEdges}} +
FromTon
{{from}}{{to}}{{n}}
+
+{{/fullEdges.length}} +{{#dataPackages.length}} +

Data packages

+ +{{/dataPackages.length}} +{{#promptPackages.length}} +

Prompt packages

+ +{{/promptPackages.length}} diff --git a/server/typescript/packages/docs-site/templates/object.html.mustache b/server/typescript/packages/docs-site/templates/object.html.mustache new file mode 100644 index 000000000..14612b5ab --- /dev/null +++ b/server/typescript/packages/docs-site/templates/object.html.mustache @@ -0,0 +1,46 @@ + +

{{name}} + {{kindBadge}} + {{#isView}}view{{/isView}} + {{#isAbstract}}abstract{{/isAbstract}}

+
+ {{#tableName}}{{#isView}}view{{/isView}}{{^isView}}table{{/isView}} {{tableName}}{{/tableName}} + {{#pkHtml}} · pk {{{pkHtml}}}{{/pkHtml}}{{#generation}} · gen {{generation}}{{/generation}}
+{{#desc}}

{{desc}}

{{/desc}} + +

Fields

+
+{{#ownFields}} +{{#enumValues.length}}{{/enumValues.length}} +{{/ownFields}} +
FieldTypeDescription
{{name}}{{#isArray}}[]{{/isArray}}{{type}}{{{badgesHtml}}}{{desc}}
{{#enumValues}}{{value}}{{#deflt}} ★{{/deflt}}{{/enumValues}}
+{{#inheritedFields.length}}
Inherited fields ({{inheritedFields.length}})
+{{#inheritedFields}}{{/inheritedFields}} +
{{name}}{{#isArray}}[]{{/isArray}} {{inheritedFrom.name}}{{type}}{{{badgesHtml}}}
{{/inheritedFields.length}}
+ +{{#indexes.length}}

Indexes & keys

+ +{{#indexes}}{{/indexes}} +
NameKindFieldsDetail
{{name}}{{kind}}{{fields}}{{extra}}
{{/indexes.length}} + +{{#validators.length}}

Validators

+{{#validators}}{{/validators}}
{{subject}}{{rule}}
{{/validators.length}} + +{{#relations.length}}

Relationships

+{{#relations}}{{/relations}}
{{name}}{{cardinality}}{{toName}}
{{/relations.length}} + +{{#origins.length}}

Field provenance

+{{#origins}}{{/origins}}
FieldFromVia
{{field}}{{from}}{{via}}
{{/origins.length}} + +{{#inheritanceMermaid}}

Inheritance

+
{{inheritanceMermaid}}
{{/inheritanceMermaid}} + +{{#neighborhoodMermaid}}

Neighborhood

+
{{neighborhoodMermaid}}
+{{#neighborhoodLegend.length}}
{{#neighborhoodLegend}}{{pkg}}{{/neighborhoodLegend}}
{{/neighborhoodLegend.length}} +
entity · value object · view (ER boxes: dashed = value, dotted = view)
+{{#neighborhoodMore}}
+{{neighborhoodMore}} more neighbor(s) — see Referenced by.
{{/neighborhoodMore}}
{{/neighborhoodMermaid}} + +{{#referencedBy.length}}
referenced by {{#referencedBy}}{{name}} {{/referencedBy}}
{{/referencedBy.length}} +{{#usedByTemplates.length}}
used by templates {{#usedByTemplates}}{{name}} {{/usedByTemplates}}
{{/usedByTemplates.length}} +
{{sourceFile}}
diff --git a/server/typescript/packages/docs-site/templates/output.html.mustache b/server/typescript/packages/docs-site/templates/output.html.mustache new file mode 100644 index 000000000..801653b4c --- /dev/null +++ b/server/typescript/packages/docs-site/templates/output.html.mustache @@ -0,0 +1,16 @@ + +

{{name}} {{format}} output

+{{#payloadName}}{{/payloadName}} +
+ textRef: {{textRef}} + {{^textRefResolves}}forward-pointing{{/textRefResolves}} +
+{{#desc}}

{{desc}}

{{/desc}} +{{#fields.length}} +

Parse contract

+
+ + {{#fields}}{{/fields}} +
FieldTypeWireMeaning
{{name}}{{#isArray}}[]{{/isArray}}{{type}}{{wire}}{{{refHtml}}}{{#note}} {{note}}{{/note}}
+
+{{/fields.length}} diff --git a/server/typescript/packages/docs-site/templates/package.html.mustache b/server/typescript/packages/docs-site/templates/package.html.mustache new file mode 100644 index 000000000..943ad1474 --- /dev/null +++ b/server/typescript/packages/docs-site/templates/package.html.mustache @@ -0,0 +1,43 @@ + +

{{title}}

+{{#descHtml}}

{{descHtml}}

{{/descHtml}} +{{#keyCards.length}} +
+{{#keyCards}}
{{name}}
{{inbound}} refs
{{/keyCards}} +
+{{/keyCards.length}} +{{#erdMermaid}}
{{erdMermaid}}
{{#erdLegend.length}}
{{#erdLegend}}{{pkg}}{{/erdLegend}}
{{/erdLegend.length}}
entity · value object · view
{{/erdMermaid}} +{{#abstracts.length}} +

Abstracts

+
+ + {{#abstracts}}{{/abstracts}} +
NameKindFields
{{name}}{{kind}}{{fieldCount}}
+{{/abstracts.length}} +{{#objects.length}} +

Objects

+
+ + {{#objects}}{{/objects}} +
NameKindTableFieldsExtends
{{name}}{{kind}}{{table}}{{fieldCount}}{{extendsName}}
+{{/objects.length}} +{{#prompts.length}} +

Prompts

+
+ + {{#prompts}}{{/prompts}} +
NamePayloadFormattextRef
{{name}}{{payloadName}}{{format}}{{textRef}}
+{{/prompts.length}} +{{#outputs.length}} +

Output contracts

+
+ + {{#outputs}}{{/outputs}} +
NamePayloadFormattextRef
{{name}}{{payloadName}}{{format}}{{textRef}}
+{{/outputs.length}} +{{#referencedBy.length}} +

Referenced by

+
+ {{#referencedBy}}{{pkg}} ×{{n}}{{/referencedBy}} +
+{{/referencedBy.length}} diff --git a/server/typescript/packages/docs-site/templates/prompt.html.mustache b/server/typescript/packages/docs-site/templates/prompt.html.mustache new file mode 100644 index 000000000..b5d47f91a --- /dev/null +++ b/server/typescript/packages/docs-site/templates/prompt.html.mustache @@ -0,0 +1,28 @@ + +

{{name}} prompt

+
{{{attrsHtml}}}
+{{#payloadName}}{{/payloadName}} +{{#desc}}

{{desc}}

{{/desc}} +{{#payloadTree.length}} +

Payload tree

+
+ + {{#payloadTree}}{{/payloadTree}} +
FieldTypeRefDescription
{{name}}{{#isArray}}[]{{/isArray}}{{type}}{{{refHtml}}}{{desc}}
+
+{{/payloadTree.length}} +{{#sourceHtml}} +

Source

+
{{{sourceHtml}}}
+
+{{/sourceHtml}} +{{#sourceMissingNote}}
{{sourceMissingNote}}
{{/sourceMissingNote}} +{{#packageFiles.length}} +

Other files in package

+{{#packageFiles}} +
+ {{file}} +
{{{html}}}
+
+{{/packageFiles}} +{{/packageFiles.length}} diff --git a/server/typescript/packages/docs-site/test/badges.test.ts b/server/typescript/packages/docs-site/test/badges.test.ts new file mode 100644 index 000000000..9eec56766 --- /dev/null +++ b/server/typescript/packages/docs-site/test/badges.test.ts @@ -0,0 +1,14 @@ +import { expect, test } from "bun:test"; +import { badge, legendHtml, esc, LEGEND } from "../src/badges"; + +test("badges escape and carry semantic classes; legend covers the 6 roles", () => { + expect(esc(`"&`)).toBe("<a>"&"); + expect(badge({ text: "required", cls: "badge-soft badge-error" })).toContain("badge-error"); + const fk = badge({ text: "→ User", cls: "badge-soft badge-info", href: "x.html", title: "fk" }); + expect(fk).toContain(`href="x.html"`); + expect(fk).toContain("badge-info"); + expect(LEGEND.map((l) => l.label)).toEqual( + ["reference (fk)", "contains (nested)", "indexed / pk", "required", "deprecated", "enum", "optional"], + ); + expect(legendHtml()).toContain("badge-info"); +}); diff --git a/server/typescript/packages/docs-site/test/coverage.test.ts b/server/typescript/packages/docs-site/test/coverage.test.ts new file mode 100644 index 000000000..3c781719c --- /dev/null +++ b/server/typescript/packages/docs-site/test/coverage.test.ts @@ -0,0 +1,28 @@ +import { expect, test } from "bun:test"; +import { join } from "node:path"; +import { loadModel } from "../src/load"; +import { CoverageTracker } from "../src/coverage"; + +test("unconsumed kinds and attrs are reported", async () => { + const model = await loadModel([join(import.meta.dir, "fixture/input/acme")]); + const cov = new CoverageTracker(); + for (const o of model.root.objects()) cov.consumeNode(o); // consume objects only + const rep = cov.report(model.root); + const kind = (k: string) => rep.kinds.find((r) => r.key === k); + expect(kind("object.entity")?.consumed).toBe(true); + expect(kind("template.prompt")?.consumed).toBe(false); // never consumed + expect(rep.warnings.some((w) => w.includes("template.prompt"))).toBe(true); +}); + +test("attr consumption is tracked accurately", async () => { + const model = await loadModel([join(import.meta.dir, "fixture/input/acme")]); + const cov2 = new CoverageTracker(); + const first = model.root.objects()[0]; + const field = first.childrenOfType("field")[0]; + cov2.consumeAttr(field, "maxLength"); + const rep2 = cov2.report(model.root); + expect(rep2.attrs.length).toBeGreaterThan(0); + expect(rep2.attrs.every((r) => typeof r.key === "string")).toBe(true); + const consumedAttr = rep2.attrs.find((r) => r.key === "field:@maxLength"); + expect(consumedAttr?.consumed).toBe(true); +}); diff --git a/server/typescript/packages/docs-site/test/fixture/golden/acme/ai/ItemView.html b/server/typescript/packages/docs-site/test/fixture/golden/acme/ai/ItemView.html new file mode 100644 index 000000000..0a6f69690 --- /dev/null +++ b/server/typescript/packages/docs-site/test/fixture/golden/acme/ai/ItemView.html @@ -0,0 +1,109 @@ + + + + +Fixture + + + + + + + + +
+ +
+ +

ItemView + value + +

+
+ +
+ + +

Fields

+
+ + +
FieldTypeDescription
labelstring
+
+ + + + + + + + + + + +

Neighborhood

+
%%{init: {'theme':'base','themeVariables':{'darkMode':true,'background':'#0b1220','primaryColor':'#1e2a3a','primaryTextColor':'#cbd5e1','primaryBorderColor':'#4a7fa5','lineColor':'#64748b','secondaryColor':'#1a2535','tertiaryColor':'#0f1826','fontSize':'13px'}}}%%
+erDiagram
+  ItemView["⬭ ItemView"] {
+  }
+  NpcPayload["⬭ NpcPayload"] {
+    string npcName "req"
+  }
+  ItemView ||--o{ NpcPayload : "items"
+  ItemView:::c_ItemView
+  classDef c_ItemView fill:#3f2d5c,stroke:#60a5fa,color:#cbd5e1,stroke-dasharray:5 3
+  NpcPayload:::c_NpcPayload
+  classDef c_NpcPayload fill:#3f2d5c,stroke:#3b5170,color:#cbd5e1,stroke-dasharray:5 3
+ +
entity · value object · view (ER boxes: dashed = value, dotted = view)
+
+ +
referenced by NpcPayload
+
used by templates npcReview
+
meta.ai.yaml
+
+ +
+ + + + +
generated 2026-01-01 · abc1234 · metadata-docs
+ + + diff --git a/server/typescript/packages/docs-site/test/fixture/golden/acme/ai/NpcPayload.html b/server/typescript/packages/docs-site/test/fixture/golden/acme/ai/NpcPayload.html new file mode 100644 index 000000000..d2d338d11 --- /dev/null +++ b/server/typescript/packages/docs-site/test/fixture/golden/acme/ai/NpcPayload.html @@ -0,0 +1,111 @@ + + + + +Fixture + + + + + + + + +
+ +
+ +

NpcPayload + value + +

+
+ +
+ + +

Fields

+
+ + + + +
FieldTypeDescription
npcNamestringrequired
items[]object⊃ ItemView
+
+ + + + + + + + + + + +

Neighborhood

+
%%{init: {'theme':'base','themeVariables':{'darkMode':true,'background':'#0b1220','primaryColor':'#1e2a3a','primaryTextColor':'#cbd5e1','primaryBorderColor':'#4a7fa5','lineColor':'#64748b','secondaryColor':'#1a2535','tertiaryColor':'#0f1826','fontSize':'13px'}}}%%
+erDiagram
+  ItemView["⬭ ItemView"] {
+  }
+  NpcPayload["⬭ NpcPayload"] {
+    string npcName "req"
+  }
+  ItemView ||--o{ NpcPayload : "items"
+  ItemView:::c_ItemView
+  classDef c_ItemView fill:#3f2d5c,stroke:#3b5170,color:#cbd5e1,stroke-dasharray:5 3
+  NpcPayload:::c_NpcPayload
+  classDef c_NpcPayload fill:#3f2d5c,stroke:#60a5fa,color:#cbd5e1,stroke-dasharray:5 3
+ +
entity · value object · view (ER boxes: dashed = value, dotted = view)
+
+ +
referenced by npcReview
+
used by templates npcReview
+
meta.ai.yaml
+
+ +
+ + + + +
generated 2026-01-01 · abc1234 · metadata-docs
+ + + diff --git a/server/typescript/packages/docs-site/test/fixture/golden/acme/ai/NpcResponse.html b/server/typescript/packages/docs-site/test/fixture/golden/acme/ai/NpcResponse.html new file mode 100644 index 000000000..ac16ef82f --- /dev/null +++ b/server/typescript/packages/docs-site/test/fixture/golden/acme/ai/NpcResponse.html @@ -0,0 +1,95 @@ + + + + +Fixture + + + + + + + + +
+ +
+ +

NpcResponse + value + +

+
+ +
+ + +

Fields

+
+ + + + +
FieldTypeDescription
verdictstringverdict <b>tag</b> & "quote"
reasonstring@xmlText
+
+ + + + + + + + + + + + + +
referenced by npcReviewOutput
+
used by templates npcReviewOutput
+
meta.ai.yaml
+
+ +
+ + + + +
generated 2026-01-01 · abc1234 · metadata-docs
+ + + diff --git a/server/typescript/packages/docs-site/test/fixture/golden/acme/ai/OrderLine.html b/server/typescript/packages/docs-site/test/fixture/golden/acme/ai/OrderLine.html new file mode 100644 index 000000000..ef2ff8287 --- /dev/null +++ b/server/typescript/packages/docs-site/test/fixture/golden/acme/ai/OrderLine.html @@ -0,0 +1,118 @@ + + + + +Fixture + + + + + + + + +
+ +
+ +

OrderLine + entity + +

+
+ table order_lines +
+ + +

Fields

+
+ + + + +
FieldTypeDescription
orderIduuid
lineNoint
+
+ +

Indexes & keys

+ + +
NameKindFieldsDetail
fkOrderLinefkorderId, lineNo→ acme::shop::Order
+ + + + + + + + + +

Neighborhood

+
%%{init: {'theme':'base','themeVariables':{'darkMode':true,'background':'#0b1220','primaryColor':'#1e2a3a','primaryTextColor':'#cbd5e1','primaryBorderColor':'#4a7fa5','lineColor':'#64748b','secondaryColor':'#1a2535','tertiaryColor':'#0f1826','fontSize':'13px'}}}%%
+erDiagram
+  Order["▭ Order"] {
+    string id PK ""
+    string customerId FK "Customer"
+    enum status "enum"
+  }
+  OrderLine["▭ OrderLine"] {
+    uuid orderId FK "Order"
+    int lineNo FK "Order"
+  }
+  Order ||--o{ OrderLine : "orderId, lineNo"
+  Order:::c_Order
+  classDef c_Order fill:#3f2d5c,stroke:#334155,color:#cbd5e1
+  OrderLine:::c_OrderLine
+  classDef c_OrderLine fill:#3f2d5c,stroke:#60a5fa,color:#cbd5e1
+ +
entity · value object · view (ER boxes: dashed = value, dotted = view)
+
+ + + +
meta.ai.yaml
+
+ +
+ + + + +
generated 2026-01-01 · abc1234 · metadata-docs
+ + + diff --git a/server/typescript/packages/docs-site/test/fixture/golden/acme/ai/OrphanVO.html b/server/typescript/packages/docs-site/test/fixture/golden/acme/ai/OrphanVO.html new file mode 100644 index 000000000..24491fd96 --- /dev/null +++ b/server/typescript/packages/docs-site/test/fixture/golden/acme/ai/OrphanVO.html @@ -0,0 +1,90 @@ + + + + +Fixture + + + + + + + + +
+ +
+ +

OrphanVO + value + +

+
+ +
+ + +

Fields

+
+
FieldTypeDescription
+
+ + + + + + + + + + + + + + + +
meta.ai.yaml
+
+ +
+ + + + +
generated 2026-01-01 · abc1234 · metadata-docs
+ + + diff --git a/server/typescript/packages/docs-site/test/fixture/golden/acme/ai/index.html b/server/typescript/packages/docs-site/test/fixture/golden/acme/ai/index.html new file mode 100644 index 000000000..70fadfe64 --- /dev/null +++ b/server/typescript/packages/docs-site/test/fixture/golden/acme/ai/index.html @@ -0,0 +1,115 @@ + + + + +Fixture + + + + + + + + +
+ +
+ +

Fixture

+ + +
%%{init: {'theme':'base','themeVariables':{'darkMode':true,'background':'#0b1220','primaryColor':'#1e2a3a','primaryTextColor':'#cbd5e1','primaryBorderColor':'#4a7fa5','lineColor':'#64748b','secondaryColor':'#1a2535','tertiaryColor':'#0f1826','fontSize':'13px'}}}%%
+erDiagram
+  ItemView["⬭ ItemView"] {
+  }
+  NpcPayload["⬭ NpcPayload"] {
+    string npcName "req"
+  }
+  NpcResponse["⬭ NpcResponse"] {
+  }
+  Order["▭ Order"] {
+    string id PK ""
+    string customerId FK "Customer"
+    enum status "enum"
+  }
+  OrderLine["▭ OrderLine"] {
+    uuid orderId FK "Order"
+    int lineNo FK "Order"
+  }
+  OrphanVO["⬭ OrphanVO"] {
+  }
+  ItemView ||--o{ NpcPayload : "items"
+  Order ||--o{ OrderLine : "orderId, lineNo"
+  ItemView:::c_ItemView
+  classDef c_ItemView fill:#3f2d5c,stroke:#3b5170,color:#cbd5e1,stroke-dasharray:5 3
+  NpcPayload:::c_NpcPayload
+  classDef c_NpcPayload fill:#3f2d5c,stroke:#3b5170,color:#cbd5e1,stroke-dasharray:5 3
+  NpcResponse:::c_NpcResponse
+  classDef c_NpcResponse fill:#3f2d5c,stroke:#3b5170,color:#cbd5e1,stroke-dasharray:5 3
+  Order:::c_Order
+  classDef c_Order fill:#3f2d5c,stroke:#334155,color:#cbd5e1
+  OrderLine:::c_OrderLine
+  classDef c_OrderLine fill:#3f2d5c,stroke:#3b5170,color:#cbd5e1
+  OrphanVO:::c_OrphanVO
+  classDef c_OrphanVO fill:#3f2d5c,stroke:#3b5170,color:#cbd5e1,stroke-dasharray:5 3
acme::aiacme::shop
entity · value object · view
+

Objects

+
+ + +
NameKindTableFieldsExtends
ItemViewvalue1
NpcPayloadvalue2
NpcResponsevalue2
OrderLineentityorder_lines2
OrphanVOvalue0
+

Prompts

+
+ + +
NamePayloadFormattextRef
npcReviewNpcPayloadtextai/npc-review
+

Output contracts

+
+ + +
NamePayloadFormattextRef
npcReviewOutputNpcResponsexmlai/npc-response-format
+
+ +
+ + + + +
generated 2026-01-01 · abc1234 · metadata-docs
+ + + diff --git a/server/typescript/packages/docs-site/test/fixture/golden/acme/ai/npcReview.html b/server/typescript/packages/docs-site/test/fixture/golden/acme/ai/npcReview.html new file mode 100644 index 000000000..f1e2b1c71 --- /dev/null +++ b/server/typescript/packages/docs-site/test/fixture/golden/acme/ai/npcReview.html @@ -0,0 +1,83 @@ + + + + +Fixture + + + + + + + + +
+ +
+ +

npcReview prompt

+
@format text
+
payload: NpcPayload
+ +

Payload tree

+
+ + +
FieldTypeRefDescription
npcNamestring
items[]objectItemView
items.labelstring
+
+

Source

+
{{! reviewer instructions }}
+Review {{npcName}}.
+{{#items}}
+- {{label}}{{^last}},{{/last}}
+{{/items}}
+{{{rawNotes}}}
+{{> shared/footer}}
+
+
+ +
+ +
+ + + + +
generated 2026-01-01 · abc1234 · metadata-docs
+ + + diff --git a/server/typescript/packages/docs-site/test/fixture/golden/acme/ai/npcReviewOutput.html b/server/typescript/packages/docs-site/test/fixture/golden/acme/ai/npcReviewOutput.html new file mode 100644 index 000000000..2da368dc2 --- /dev/null +++ b/server/typescript/packages/docs-site/test/fixture/golden/acme/ai/npcReviewOutput.html @@ -0,0 +1,74 @@ + + + + +Fixture + + + + + + + + +
+ +
+ +

npcReviewOutput xml output

+
payload: NpcResponse
+
+ textRef: ai/npc-response-format + forward-pointing +
+ +

Parse contract

+
+ + +
FieldTypeWireMeaning
verdictstringattr verdict <b>tag</b> & "quote"
reasonstring@xmlText body
+
+
+ +
+ + + + +
generated 2026-01-01 · abc1234 · metadata-docs
+ + + diff --git a/server/typescript/packages/docs-site/test/fixture/golden/acme/common/BaseEntity.html b/server/typescript/packages/docs-site/test/fixture/golden/acme/common/BaseEntity.html new file mode 100644 index 000000000..7f53e8ae0 --- /dev/null +++ b/server/typescript/packages/docs-site/test/fixture/golden/acme/common/BaseEntity.html @@ -0,0 +1,133 @@ + + + + +Fixture + + + + + + + + +
+ +
+ +

BaseEntity + entity + + abstract

+
+ + · pk id · gen uuid
+ + +

Fields

+
+ + + + +
FieldTypeDescription
idstringrequired ≤36 uuid
createdAttimestamp
+
+ +

Indexes & keys

+ + +
NameKindFieldsDetail
idprimaryid
+ + + + + + + +

Inheritance

+
%%{init: {'theme':'base','themeVariables':{'darkMode':true,'background':'#0b1220','primaryColor':'#1e2a3a','primaryTextColor':'#cbd5e1','primaryBorderColor':'#4a7fa5','lineColor':'#64748b','secondaryColor':'#1a2535','tertiaryColor':'#0f1826','fontSize':'13px'}}}%%
+flowchart TD
+  BaseEntity["BaseEntity"]:::self
+  Customer["Customer"]
+  Order["Order"]
+  BaseEntity --> Customer
+  BaseEntity --> Order
+  classDef self fill:#1e3a5f,stroke:#60a5fa,color:#e2e8f0,font-weight:bold
+ +

Neighborhood

+
%%{init: {'theme':'base','themeVariables':{'darkMode':true,'background':'#0b1220','primaryColor':'#1e2a3a','primaryTextColor':'#cbd5e1','primaryBorderColor':'#4a7fa5','lineColor':'#64748b','secondaryColor':'#1a2535','tertiaryColor':'#0f1826','fontSize':'13px'}}}%%
+erDiagram
+  BaseEntity["▭ BaseEntity"] {
+    string id PK ""
+  }
+  Customer["▭ Customer"] {
+    string id PK ""
+    string email "req"
+  }
+  Order["▭ Order"] {
+    string id PK ""
+    string customerId FK "Customer"
+    enum status "enum"
+  }
+  BaseEntity ||--o{ Customer : "extends"
+  BaseEntity ||--o{ Order : "extends"
+  BaseEntity:::c_BaseEntity
+  classDef c_BaseEntity fill:#1c2431,stroke:#60a5fa,color:#cbd5e1
+  Customer:::c_Customer
+  classDef c_Customer fill:#3f2d5c,stroke:#334155,color:#cbd5e1
+  Order:::c_Order
+  classDef c_Order fill:#3f2d5c,stroke:#334155,color:#cbd5e1
+ +
entity · value object · view (ER boxes: dashed = value, dotted = view)
+
+ + + +
meta.common.yaml
+
+ +
+ + + + +
generated 2026-01-01 · abc1234 · metadata-docs
+ + + diff --git a/server/typescript/packages/docs-site/test/fixture/golden/acme/common/index.html b/server/typescript/packages/docs-site/test/fixture/golden/acme/common/index.html new file mode 100644 index 000000000..9cbb24024 --- /dev/null +++ b/server/typescript/packages/docs-site/test/fixture/golden/acme/common/index.html @@ -0,0 +1,75 @@ + + + + +Fixture + + + + + + + + +
+ +
+ +

Fixture

+ +
%%{init: {'theme':'base','themeVariables':{'darkMode':true,'background':'#0b1220','primaryColor':'#1e2a3a','primaryTextColor':'#cbd5e1','primaryBorderColor':'#4a7fa5','lineColor':'#64748b','secondaryColor':'#1a2535','tertiaryColor':'#0f1826','fontSize':'13px'}}}%%
+erDiagram
+  BaseEntity["▭ BaseEntity"] {
+    string id PK ""
+  }
+  BaseEntity:::c_BaseEntity
+  classDef c_BaseEntity fill:#1c2431,stroke:#3b5170,color:#cbd5e1
acme::common
entity · value object · view
+

Abstracts

+
+ + +
NameKindFields
BaseEntityentity2
+
+ +
+ + + + +
generated 2026-01-01 · abc1234 · metadata-docs
+ + + diff --git a/server/typescript/packages/docs-site/test/fixture/golden/acme/shop/Customer.html b/server/typescript/packages/docs-site/test/fixture/golden/acme/shop/Customer.html new file mode 100644 index 000000000..b50f7c912 --- /dev/null +++ b/server/typescript/packages/docs-site/test/fixture/golden/acme/shop/Customer.html @@ -0,0 +1,137 @@ + + + + +Fixture + + + + + + + + +
+ +
+ +

Customer + entity + +

+
+ table customers + · pk id · gen uuid
+ + +

Fields

+
+ + +
FieldTypeDescription
emailstringrequired ≤120
+
Inherited fields (2)
+ +
id BaseEntitystringrequired ≤36 uuid
createdAt BaseEntitytimestamp
+ +

Indexes & keys

+ + +
NameKindFieldsDetail
idprimaryid
+ + + + + + + +

Inheritance

+
%%{init: {'theme':'base','themeVariables':{'darkMode':true,'background':'#0b1220','primaryColor':'#1e2a3a','primaryTextColor':'#cbd5e1','primaryBorderColor':'#4a7fa5','lineColor':'#64748b','secondaryColor':'#1a2535','tertiaryColor':'#0f1826','fontSize':'13px'}}}%%
+flowchart TD
+  BaseEntity["BaseEntity"]
+  Customer["Customer"]:::self
+  BaseEntity --> Customer
+  classDef self fill:#1e3a5f,stroke:#60a5fa,color:#e2e8f0,font-weight:bold
+ +

Neighborhood

+
%%{init: {'theme':'base','themeVariables':{'darkMode':true,'background':'#0b1220','primaryColor':'#1e2a3a','primaryTextColor':'#cbd5e1','primaryBorderColor':'#4a7fa5','lineColor':'#64748b','secondaryColor':'#1a2535','tertiaryColor':'#0f1826','fontSize':'13px'}}}%%
+erDiagram
+  BaseEntity["▭ BaseEntity"] {
+    string id PK ""
+  }
+  Customer["▭ Customer"] {
+    string id PK ""
+    string email "req"
+  }
+  LineItemView["▱ LineItemView"] {
+  }
+  Order["▭ Order"] {
+    string id PK ""
+    string customerId FK "Customer"
+    enum status "enum"
+  }
+  BaseEntity ||--o{ Customer : "extends"
+  Customer ||--o{ LineItemView : "customerName (origin)"
+  Customer ||--o{ Order : "customerId"
+  BaseEntity:::c_BaseEntity
+  classDef c_BaseEntity fill:#1c2431,stroke:#334155,color:#cbd5e1
+  Customer:::c_Customer
+  classDef c_Customer fill:#3f2d5c,stroke:#60a5fa,color:#cbd5e1
+  LineItemView:::c_LineItemView
+  classDef c_LineItemView fill:#3f2d5c,stroke:#2dd4bf,color:#cbd5e1,stroke-dasharray:2 2
+  Order:::c_Order
+  classDef c_Order fill:#3f2d5c,stroke:#3b5170,color:#cbd5e1
+ +
entity · value object · view (ER boxes: dashed = value, dotted = view)
+
+ +
referenced by LineItemView Order Order
+ +
meta.shop.yaml
+
+ +
+ + + + +
generated 2026-01-01 · abc1234 · metadata-docs
+ + + diff --git a/server/typescript/packages/docs-site/test/fixture/golden/acme/shop/LineItemView.html b/server/typescript/packages/docs-site/test/fixture/golden/acme/shop/LineItemView.html new file mode 100644 index 000000000..0a753cca3 --- /dev/null +++ b/server/typescript/packages/docs-site/test/fixture/golden/acme/shop/LineItemView.html @@ -0,0 +1,121 @@ + + + + +Fixture + + + + + + + + +
+ +
+ +

LineItemView + projection + view +

+
+ view v_line_item +
+ + +

Fields

+
+ + + + +
FieldTypeDescription
orderIdstring→ Order
customerNamestring
+
+ + + + + + + +

Field provenance

+
FieldFromVia
customerNameacme::shop::Customer.emailacme::shop::Order.customer
+ + + +

Neighborhood

+
%%{init: {'theme':'base','themeVariables':{'darkMode':true,'background':'#0b1220','primaryColor':'#1e2a3a','primaryTextColor':'#cbd5e1','primaryBorderColor':'#4a7fa5','lineColor':'#64748b','secondaryColor':'#1a2535','tertiaryColor':'#0f1826','fontSize':'13px'}}}%%
+erDiagram
+  Customer["▭ Customer"] {
+    string id PK ""
+    string email "req"
+  }
+  LineItemView["▱ LineItemView"] {
+  }
+  Order["▭ Order"] {
+    string id PK ""
+    string customerId FK "Customer"
+    enum status "enum"
+  }
+  Customer ||--o{ LineItemView : "customerName (origin)"
+  Order ||--o{ LineItemView : "orderId"
+  Customer:::c_Customer
+  classDef c_Customer fill:#3f2d5c,stroke:#3b5170,color:#cbd5e1
+  LineItemView:::c_LineItemView
+  classDef c_LineItemView fill:#3f2d5c,stroke:#60a5fa,color:#cbd5e1,stroke-dasharray:2 2
+  Order:::c_Order
+  classDef c_Order fill:#3f2d5c,stroke:#3b5170,color:#cbd5e1
+ +
entity · value object · view (ER boxes: dashed = value, dotted = view)
+
+ + + +
meta.shop.yaml
+
+ +
+ + + + +
generated 2026-01-01 · abc1234 · metadata-docs
+ + + diff --git a/server/typescript/packages/docs-site/test/fixture/golden/acme/shop/Order.html b/server/typescript/packages/docs-site/test/fixture/golden/acme/shop/Order.html new file mode 100644 index 000000000..7be8a5adb --- /dev/null +++ b/server/typescript/packages/docs-site/test/fixture/golden/acme/shop/Order.html @@ -0,0 +1,153 @@ + + + + +Fixture + + + + + + + + +
+ +
+ +

Order + entity + +

+
+ table orders + · pk id · gen uuid
+

A customer <order> with "items" & totals.

+ +

Fields

+
+ + + + + + +
FieldTypeDescription
statusenumdefault OPEN enum
OPEN ★CLOSED
qtyintdefault 1 min=1 max=99
customerIdstring≤36
+
Inherited fields (2)
+ +
id BaseEntitystringrequired ≤36 uuid
createdAt BaseEntitytimestamp
+ +

Indexes & keys

+ + +
NameKindFieldsDetail
fkCustomerfkcustomerId→ acme::shop::Customer
idx_order_qtyindexqtyorders desc · where (qty > 0)
idprimaryid
+ +

Validators

+
comparisonqty lte 99
+ +

Relationships

+
customeroneCustomer
+ + + +

Inheritance

+
%%{init: {'theme':'base','themeVariables':{'darkMode':true,'background':'#0b1220','primaryColor':'#1e2a3a','primaryTextColor':'#cbd5e1','primaryBorderColor':'#4a7fa5','lineColor':'#64748b','secondaryColor':'#1a2535','tertiaryColor':'#0f1826','fontSize':'13px'}}}%%
+flowchart TD
+  BaseEntity["BaseEntity"]
+  Order["Order"]:::self
+  BaseEntity --> Order
+  classDef self fill:#1e3a5f,stroke:#60a5fa,color:#e2e8f0,font-weight:bold
+ +

Neighborhood

+
%%{init: {'theme':'base','themeVariables':{'darkMode':true,'background':'#0b1220','primaryColor':'#1e2a3a','primaryTextColor':'#cbd5e1','primaryBorderColor':'#4a7fa5','lineColor':'#64748b','secondaryColor':'#1a2535','tertiaryColor':'#0f1826','fontSize':'13px'}}}%%
+erDiagram
+  BaseEntity["▭ BaseEntity"] {
+    string id PK ""
+  }
+  Customer["▭ Customer"] {
+    string id PK ""
+    string email "req"
+  }
+  LineItemView["▱ LineItemView"] {
+  }
+  Order["▭ Order"] {
+    string id PK ""
+    string customerId FK "Customer"
+    enum status "enum"
+  }
+  OrderLine["▭ OrderLine"] {
+    uuid orderId FK "Order"
+    int lineNo FK "Order"
+  }
+  BaseEntity ||--o{ Order : "extends"
+  Customer ||--o{ Order : "customerId"
+  Order ||--o{ LineItemView : "orderId"
+  Order ||--o{ OrderLine : "orderId, lineNo"
+  BaseEntity:::c_BaseEntity
+  classDef c_BaseEntity fill:#1c2431,stroke:#334155,color:#cbd5e1
+  Customer:::c_Customer
+  classDef c_Customer fill:#3f2d5c,stroke:#3b5170,color:#cbd5e1
+  LineItemView:::c_LineItemView
+  classDef c_LineItemView fill:#3f2d5c,stroke:#2dd4bf,color:#cbd5e1,stroke-dasharray:2 2
+  Order:::c_Order
+  classDef c_Order fill:#3f2d5c,stroke:#60a5fa,color:#cbd5e1
+  OrderLine:::c_OrderLine
+  classDef c_OrderLine fill:#3f2d5c,stroke:#334155,color:#cbd5e1
+ +
entity · value object · view (ER boxes: dashed = value, dotted = view)
+
+ +
referenced by LineItemView OrderLine
+ +
meta.shop.yaml
+
+ +
+ + + + +
generated 2026-01-01 · abc1234 · metadata-docs
+ + + diff --git a/server/typescript/packages/docs-site/test/fixture/golden/acme/shop/OrphanLog.html b/server/typescript/packages/docs-site/test/fixture/golden/acme/shop/OrphanLog.html new file mode 100644 index 000000000..29a0320f3 --- /dev/null +++ b/server/typescript/packages/docs-site/test/fixture/golden/acme/shop/OrphanLog.html @@ -0,0 +1,92 @@ + + + + +Fixture + + + + + + + + +
+ +
+ +

OrphanLog + entity + +

+
+ +
+ + +

Fields

+
+ + +
FieldTypeDescription
linestring
+
+ + + + + + + + + + + + + + + +
meta.shop.yaml
+
+ +
+ + + + +
generated 2026-01-01 · abc1234 · metadata-docs
+ + + diff --git a/server/typescript/packages/docs-site/test/fixture/golden/acme/shop/index.html b/server/typescript/packages/docs-site/test/fixture/golden/acme/shop/index.html new file mode 100644 index 000000000..177adc45b --- /dev/null +++ b/server/typescript/packages/docs-site/test/fixture/golden/acme/shop/index.html @@ -0,0 +1,109 @@ + + + + +Fixture + + + + + + + + +
+ +
+ +

Fixture

+

The shop package models customers and their orders.

+ +
%%{init: {'theme':'base','themeVariables':{'darkMode':true,'background':'#0b1220','primaryColor':'#1e2a3a','primaryTextColor':'#cbd5e1','primaryBorderColor':'#4a7fa5','lineColor':'#64748b','secondaryColor':'#1a2535','tertiaryColor':'#0f1826','fontSize':'13px'}}}%%
+erDiagram
+  Customer["▭ Customer"] {
+    string id PK ""
+    string email "req"
+  }
+  LineItemView["▱ LineItemView"] {
+  }
+  Order["▭ Order"] {
+    string id PK ""
+    string customerId FK "Customer"
+    enum status "enum"
+  }
+  OrderLine["▭ OrderLine"] {
+    uuid orderId FK "Order"
+    int lineNo FK "Order"
+  }
+  OrphanLog["▭ OrphanLog"] {
+  }
+  Customer ||--o{ LineItemView : "customerName (origin)"
+  Customer ||--o{ Order : "customer"
+  Customer ||--o{ Order : "customerId"
+  Order ||--o{ LineItemView : "orderId"
+  Order ||--o{ OrderLine : "orderId, lineNo"
+  Customer:::c_Customer
+  classDef c_Customer fill:#3f2d5c,stroke:#3b5170,color:#cbd5e1
+  LineItemView:::c_LineItemView
+  classDef c_LineItemView fill:#3f2d5c,stroke:#3b5170,color:#cbd5e1,stroke-dasharray:2 2
+  Order:::c_Order
+  classDef c_Order fill:#3f2d5c,stroke:#3b5170,color:#cbd5e1
+  OrderLine:::c_OrderLine
+  classDef c_OrderLine fill:#3f2d5c,stroke:#334155,color:#cbd5e1
+  OrphanLog:::c_OrphanLog
+  classDef c_OrphanLog fill:#3f2d5c,stroke:#3b5170,color:#cbd5e1
acme::aiacme::shop
entity · value object · view
+

Objects

+
+ + +
NameKindTableFieldsExtends
Customerentitycustomers3BaseEntity
LineItemViewprojectionv_line_item2
Orderentityorders5BaseEntity
OrphanLogentity1
+

Referenced by

+ +
+ +
+ + + + +
generated 2026-01-01 · abc1234 · metadata-docs
+ + + diff --git a/server/typescript/packages/docs-site/test/fixture/golden/assets/search-index.json b/server/typescript/packages/docs-site/test/fixture/golden/assets/search-index.json new file mode 100644 index 000000000..927f73c8c --- /dev/null +++ b/server/typescript/packages/docs-site/test/fixture/golden/assets/search-index.json @@ -0,0 +1 @@ +[{"t":"acme::ai::ItemView","h":"acme/ai/ItemView.html","k":"object"},{"t":"acme::ai::NpcPayload","h":"acme/ai/NpcPayload.html","k":"object"},{"t":"acme::ai::NpcResponse","h":"acme/ai/NpcResponse.html","k":"object"},{"t":"acme::ai::npcReview","h":"acme/ai/npcReview.html","k":"prompt"},{"t":"acme::ai::npcReviewOutput","h":"acme/ai/npcReviewOutput.html","k":"output"},{"t":"acme::ai::OrderLine","h":"acme/ai/OrderLine.html","k":"object"},{"t":"acme::ai::OrphanVO","h":"acme/ai/OrphanVO.html","k":"object"},{"t":"acme::common::BaseEntity","h":"acme/common/BaseEntity.html","k":"object"},{"t":"acme::shop::Customer","h":"acme/shop/Customer.html","k":"object"},{"t":"acme::shop::LineItemView","h":"acme/shop/LineItemView.html","k":"object"},{"t":"acme::shop::Order","h":"acme/shop/Order.html","k":"object"},{"t":"acme::shop::OrphanLog","h":"acme/shop/OrphanLog.html","k":"object"},{"t":"BaseEntity.createdAt","h":"acme/common/BaseEntity.html#f-createdAt","k":"field"},{"t":"BaseEntity.id","h":"acme/common/BaseEntity.html#f-id","k":"field"},{"t":"Customer.createdAt","h":"acme/shop/Customer.html#f-createdAt","k":"field"},{"t":"Customer.email","h":"acme/shop/Customer.html#f-email","k":"field"},{"t":"Customer.id","h":"acme/shop/Customer.html#f-id","k":"field"},{"t":"ItemView.label","h":"acme/ai/ItemView.html#f-label","k":"field"},{"t":"LineItemView.customerName","h":"acme/shop/LineItemView.html#f-customerName","k":"field"},{"t":"LineItemView.orderId","h":"acme/shop/LineItemView.html#f-orderId","k":"field"},{"t":"NpcPayload.items","h":"acme/ai/NpcPayload.html#f-items","k":"field"},{"t":"NpcPayload.npcName","h":"acme/ai/NpcPayload.html#f-npcName","k":"field"},{"t":"NpcResponse.reason","h":"acme/ai/NpcResponse.html#f-reason","k":"field"},{"t":"NpcResponse.verdict","h":"acme/ai/NpcResponse.html#f-verdict","k":"field"},{"t":"Order.createdAt","h":"acme/shop/Order.html#f-createdAt","k":"field"},{"t":"Order.customerId","h":"acme/shop/Order.html#f-customerId","k":"field"},{"t":"Order.id","h":"acme/shop/Order.html#f-id","k":"field"},{"t":"Order.qty","h":"acme/shop/Order.html#f-qty","k":"field"},{"t":"Order.status","h":"acme/shop/Order.html#f-status","k":"field"},{"t":"OrderLine.lineNo","h":"acme/ai/OrderLine.html#f-lineNo","k":"field"},{"t":"OrderLine.orderId","h":"acme/ai/OrderLine.html#f-orderId","k":"field"},{"t":"OrphanLog.line","h":"acme/shop/OrphanLog.html#f-line","k":"field"}] \ No newline at end of file diff --git a/server/typescript/packages/docs-site/test/fixture/golden/assets/site.css b/server/typescript/packages/docs-site/test/fixture/golden/assets/site.css new file mode 100644 index 000000000..56686b419 --- /dev/null +++ b/server/typescript/packages/docs-site/test/fixture/golden/assets/site.css @@ -0,0 +1,13 @@ +.pl-mu { background:#0b1220; color:#cbd5e1; font-family:ui-monospace,monospace; font-size:.78rem; line-height:1.55; padding:1rem; border-radius:.5rem; overflow-x:auto; white-space:pre; } +.mu-sec { color:#f472b6; font-weight:600; } .mu-var { color:#7dd3fc; } .mu-raw { color:#fbbf24; } +.mu-par { color:#4ade80; } .mu-com { color:#64748b; font-style:italic; } +.mu-unresolved { text-decoration: underline dotted #ef4444; } +/* diagrams fit the container WIDTH (mermaid useMaxWidth), and the box scrolls if a diagram is tall. + Native scroll = mobile-friendly; caps runaway height so a diagram never dominates the page. */ +.pl-diagram { max-height: min(72vh, 560px); overflow: auto; border-radius: .5rem; } +.pl-diagram pre.mermaid { margin: 0; } +.pl-diagram svg { width: 100%; height: auto; max-width: none; display: block; } +.pl-toc a { display:block; opacity:.6; border-left:2px solid transparent; padding-left:.5rem; } +.pl-toc a:hover, .pl-toc a.active { opacity:1; border-left-color:#60a5fa; } +section[id] { scroll-margin-top:1rem; } +@media print { .pl-sidebar, .pl-toc { display:none } } diff --git a/server/typescript/packages/docs-site/test/fixture/golden/assets/site.js b/server/typescript/packages/docs-site/test/fixture/golden/assets/site.js new file mode 100644 index 000000000..b8e629e19 --- /dev/null +++ b/server/typescript/packages/docs-site/test/fixture/golden/assets/site.js @@ -0,0 +1,25 @@ +// mermaid: theme comes from each diagram's %%{init}%% prelude; useMaxWidth fits diagrams to the +// container width. The .pl-diagram box scrolls (native, mobile-friendly) if a diagram is tall. +mermaid.initialize({ startOnLoad: false, securityLevel: "loose", er: { useMaxWidth: true }, flowchart: { useMaxWidth: true } }); +mermaid.run({ querySelector: ".mermaid" }); +// scroll-spy TOC +const secs = [...document.querySelectorAll("section[id]")]; +const tocLinks = new Map([...document.querySelectorAll(".pl-toc a")].map((a) => [a.getAttribute("href").replace(/^#/, ""), a])); +if (secs.length && tocLinks.size) { + const io = new IntersectionObserver((es) => es.forEach((e) => { const a = tocLinks.get(e.target.id); if (a) a.classList.toggle("active", e.isIntersecting); }), { rootMargin: "0px 0px -70% 0px" }); + secs.forEach((s) => io.observe(s)); +} +// Cmd+K search +const modal = document.getElementById("search-modal"), box = document.getElementById("search"), results = document.getElementById("search-results"); +const openBtn = document.getElementById("search-open"); +const open = () => { modal?.showModal(); box.value = ""; results.innerHTML = ""; box.focus(); }; +openBtn?.addEventListener("click", open); +document.addEventListener("keydown", (e) => { if ((e.key === "k" && (e.metaKey || e.ctrlKey)) || (e.key === "/" && document.activeElement !== box)) { e.preventDefault(); open(); } }); +let INDEX = null; +box?.addEventListener("input", async () => { + INDEX ??= await (await fetch(window.__REL_ROOT__ + "assets/search-index.json")).json(); + const q = box.value.trim().toLowerCase(); + if (!q) { results.innerHTML = ""; return; } + const scored = INDEX.map((e) => { const t = e.t.toLowerCase(); const i = t.indexOf(q); return i < 0 ? null : { e, s: (i === 0 ? 0 : 1) + t.length / 500 }; }).filter(Boolean).sort((a, b) => a.s - b.s).slice(0, 40); + results.innerHTML = scored.map(({ e }) => `${e.t} ${e.k}`).join(""); +}); diff --git a/server/typescript/packages/docs-site/test/fixture/golden/coverage.html b/server/typescript/packages/docs-site/test/fixture/golden/coverage.html new file mode 100644 index 000000000..551be281b --- /dev/null +++ b/server/typescript/packages/docs-site/test/fixture/golden/coverage.html @@ -0,0 +1,78 @@ + + + + +Fixture + + + + + + + + +
+ +
+

Coverage

+

Which metadata node kinds and attributes are rendered by the doc pages.

+

Node kinds

+
+ + +
KindCountRendered
field.enum2
field.int2
field.object1
field.string10
field.timestamp1
field.uuid1
identity.primary1
identity.reference2
index.lookup1
object.entity5
object.projection1
object.value4
origin.passthrough1
relationship.association1
source.rdb4
template.output1
template.prompt1
validator.comparison1
validator.numeric1
+

Attributes

+
+ + +
AttributeCountRendered
field:@dbColumnType1
field:@default2
field:@description1
field:@maxLength3
field:@objectRef2
field:@required3
field:@storage1
field:@values1
field:@xmlText1
identity:@fields3
identity:@generation1
identity:@references2
index:@fields1
index:@orders1
index:@where1
object:@description1
origin:@from1
origin:@via1
relationship:@cardinality1
relationship:@objectRef1
source:@kind1
source:@table4
template:@format2
template:@payloadRef2
template:@textRef2
validator:@left1
validator:@max1
validator:@min1
validator:@op1
validator:@right1
+

Anomalies

+
+ + +
KindSubjectDetail
orphanOrphanLogno inbound or outbound references
orphanOrphanVOno inbound or outbound references
unreachable-voOrphanVOvalue object not reachable from any template payload
unresolved-textrefnpcReviewOutput@textRef ai/npc-response-format does not resolve (forward-pointing)
+ +
+ +
+ + + + +
generated 2026-01-01 · abc1234 · metadata-docs
+ + + diff --git a/server/typescript/packages/docs-site/test/fixture/golden/enums.html b/server/typescript/packages/docs-site/test/fixture/golden/enums.html new file mode 100644 index 000000000..2453c1861 --- /dev/null +++ b/server/typescript/packages/docs-site/test/fixture/golden/enums.html @@ -0,0 +1,72 @@ + + + + +Fixture + + + + + + + + +
+ +
+

Enums

+

All enum-valued fields across the metadata.

+
+ + + + + + + +
OwnerFieldValuesDefault
OrderstatusOPENCLOSEDOPEN
+ +
+ +
+ + + + +
generated 2026-01-01 · abc1234 · metadata-docs
+ + + diff --git a/server/typescript/packages/docs-site/test/fixture/golden/index.html b/server/typescript/packages/docs-site/test/fixture/golden/index.html new file mode 100644 index 000000000..ea18bbd66 --- /dev/null +++ b/server/typescript/packages/docs-site/test/fixture/golden/index.html @@ -0,0 +1,130 @@ + + + + +Fixture + + + + + + + + +
+ +
+

Fixture

+
+
objects
10
+
tables
4
+
packages
3
+
prompt VOs
5
+
prompts
1
+
contracts
1
+
enums
1
+
+
+
Core ERD
+
%%{init: {'theme':'base','themeVariables':{'darkMode':true,'background':'#0b1220','primaryColor':'#1e2a3a','primaryTextColor':'#cbd5e1','primaryBorderColor':'#4a7fa5','lineColor':'#64748b','secondaryColor':'#1a2535','tertiaryColor':'#0f1826','fontSize':'13px'}}}%%
+flowchart LR
+  BaseEntity["BaseEntity"]
+  Customer["Customer"]
+  ItemView(["ItemView"])
+  LineItemView[/"LineItemView"/]
+  NpcPayload(["NpcPayload"])
+  Order["Order"]
+  OrderLine["OrderLine"]
+  Customer --> BaseEntity
+  LineItemView --> Customer
+  LineItemView --> Order
+  NpcPayload --> ItemView
+  Order --> BaseEntity
+  Order --> Customer
+  OrderLine --> Order
+  class ItemView,NpcPayload,OrderLine d_ai
+  classDef d_ai fill:#3f2d5c,stroke:#a78bfa,color:#ede9fe
+  class BaseEntity d_common
+  classDef d_common fill:#1c2431,stroke:#64748b,color:#cbd5e1
+  class Customer,LineItemView,Order d_shop
+  classDef d_shop fill:#3f2d5c,stroke:#a78bfa,color:#ede9fe
+
7 of the most-connected objects (entities, views, and payloads), colored by domain.
+
acme::aiacme::commonacme::shop
+
entity · value object · view
+
+
+
Package dependencies
+
%%{init: {'theme':'base','themeVariables':{'darkMode':true,'background':'#0b1220','primaryColor':'#1e2a3a','primaryTextColor':'#cbd5e1','primaryBorderColor':'#4a7fa5','lineColor':'#64748b','secondaryColor':'#1a2535','tertiaryColor':'#0f1826','fontSize':'13px'}}}%%
+flowchart LR
+
+
+ All cross-package edges +
+ + +
FromTon
aishop1
+
+

Data packages

+ +

Prompt packages

+ +
+ +
+ + + + +
generated 2026-01-01 · abc1234 · metadata-docs
+ + + diff --git a/server/typescript/packages/docs-site/test/fixture/input/acme/ai/meta.ai.yaml b/server/typescript/packages/docs-site/test/fixture/input/acme/ai/meta.ai.yaml new file mode 100644 index 000000000..7a8531fe8 --- /dev/null +++ b/server/typescript/packages/docs-site/test/fixture/input/acme/ai/meta.ai.yaml @@ -0,0 +1,39 @@ +metadata: + package: acme::ai + children: + - object.value: + name: ItemView + children: + - field.string: { name: label } + - object.value: + name: NpcPayload + children: + - field.string: { name: npcName, required: true } + - field.object: { name: items, isArray: true, "@objectRef": "acme::ai::ItemView", "@storage": jsonb } + - object.value: + name: NpcResponse + children: + - field.string: { name: verdict, "@description": "verdict tag & \"quote\"" } # attr on the wire; use <tag> to embed & "quotes" + - field.string: { name: reason, "@xmlText": true } + - template.prompt: + name: npcReview + "@payloadRef": NpcPayload + "@textRef": ai/npc-review + "@format": text + - template.output: + name: npcReviewOutput + "@payloadRef": NpcResponse + "@textRef": ai/npc-response-format + "@format": xml + - object.entity: + name: OrderLine + children: + - field.uuid: { name: orderId } + - field.int: { name: lineNo } + - identity.reference: + name: fkOrderLine + fields: [orderId, lineNo] + references: acme::shop::Order + - source.rdb: { table: order_lines } + - object.value: + name: OrphanVO diff --git a/server/typescript/packages/docs-site/test/fixture/input/acme/ai/npc-review.mustache b/server/typescript/packages/docs-site/test/fixture/input/acme/ai/npc-review.mustache new file mode 100644 index 000000000..4774d359b --- /dev/null +++ b/server/typescript/packages/docs-site/test/fixture/input/acme/ai/npc-review.mustache @@ -0,0 +1,7 @@ +{{! reviewer instructions }} +Review {{npcName}}. +{{#items}} +- {{label}}{{^last}},{{/last}} +{{/items}} +{{{rawNotes}}} +{{> shared/footer}} diff --git a/server/typescript/packages/docs-site/test/fixture/input/acme/common/meta.common.yaml b/server/typescript/packages/docs-site/test/fixture/input/acme/common/meta.common.yaml new file mode 100644 index 000000000..ced96fb04 --- /dev/null +++ b/server/typescript/packages/docs-site/test/fixture/input/acme/common/meta.common.yaml @@ -0,0 +1,14 @@ +metadata: + package: acme::common + children: + - object.entity: + name: BaseEntity + abstract: true + children: + - field.string: { name: id, required: true, maxLength: 36, dbColumnType: uuid } + - field.timestamp: { name: createdAt } + - identity.primary: { name: id, fields: id, generation: uuid } + - field.enum: + name: statusEnum + abstract: true + values: [OPEN, CLOSED] diff --git a/server/typescript/packages/docs-site/test/fixture/input/acme/shop/_package.yaml b/server/typescript/packages/docs-site/test/fixture/input/acme/shop/_package.yaml new file mode 100644 index 000000000..70db313a7 --- /dev/null +++ b/server/typescript/packages/docs-site/test/fixture/input/acme/shop/_package.yaml @@ -0,0 +1,4 @@ +metadata: + package: acme::shop + title: Shop + description: The shop package models customers and their orders. diff --git a/server/typescript/packages/docs-site/test/fixture/input/acme/shop/meta.shop.yaml b/server/typescript/packages/docs-site/test/fixture/input/acme/shop/meta.shop.yaml new file mode 100644 index 000000000..72bcab68a --- /dev/null +++ b/server/typescript/packages/docs-site/test/fixture/input/acme/shop/meta.shop.yaml @@ -0,0 +1,39 @@ +metadata: + package: acme::shop + children: + - object.entity: + name: Order + "@description": "A customer with \"items\" & totals." + extends: acme::common::BaseEntity + children: + - field.enum: { name: status, extends: acme::common::statusEnum, default: OPEN } + - field.int: + name: qty + default: "1" + children: + - validator.numeric: { min: 1, max: 99 } + - field.string: { name: customerId, maxLength: 36 } + - identity.reference: { name: fkCustomer, fields: customerId, references: acme::shop::Customer } + - relationship.association: { name: customer, "@objectRef": "acme::shop::Customer", cardinality: one } + - index.lookup: { name: idx_order_qty, fields: qty, orders: [desc], where: "(qty > 0)" } + - validator.comparison: { left: qty, op: lte, right: "99" } + - source.rdb: { table: orders } + - object.entity: + name: Customer + extends: acme::common::BaseEntity + children: + - field.string: { name: email, required: true, maxLength: 120 } + - source.rdb: { table: customers } + - object.entity: + name: OrphanLog + children: + - field.string: { name: line } + - object.projection: + name: LineItemView + children: + - field.string: { name: orderId, "@objectRef": "acme::shop::Order" } + - field.string: + name: customerName + children: + - origin.passthrough: { "@from": "acme::shop::Customer.email", "@via": "Order.customer" } + - source.rdb: { kind: view, table: v_line_item } diff --git a/server/typescript/packages/docs-site/test/golden.test.ts b/server/typescript/packages/docs-site/test/golden.test.ts new file mode 100644 index 000000000..d3ed36fbd --- /dev/null +++ b/server/typescript/packages/docs-site/test/golden.test.ts @@ -0,0 +1,18 @@ +import { expect, test } from "bun:test"; +import { mkdtempSync, readdirSync, readFileSync, statSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { generateSite } from "../src/site"; + +const GOLDEN = join(import.meta.dir, "fixture/golden"); +const walk = (d: string, base = d): string[] => readdirSync(d).flatMap((e) => { + const p = join(d, e); return statSync(p).isDirectory() ? walk(p, base) : [p.slice(base.length + 1)]; +}); + +test("fixture site is byte-identical to the committed golden", async () => { + const out = mkdtempSync(join(tmpdir(), "golden-")); + await generateSite({ sourceDirs: [join(import.meta.dir, "fixture/input/acme")], outDir: out, title: "Fixture", stamp: "2026-01-01", commit: "abc1234" }); + const got = walk(out).sort(); const want = walk(GOLDEN).sort(); + expect(got).toEqual(want); + for (const f of want) expect(readFileSync(join(out, f), "utf8")).toBe(readFileSync(join(GOLDEN, f), "utf8")); +}); diff --git a/server/typescript/packages/docs-site/test/graph-v2.test.ts b/server/typescript/packages/docs-site/test/graph-v2.test.ts new file mode 100644 index 000000000..e9fccb8bd --- /dev/null +++ b/server/typescript/packages/docs-site/test/graph-v2.test.ts @@ -0,0 +1,21 @@ +import { expect, test } from "bun:test"; +import { join } from "node:path"; +import { loadModel } from "../src/load"; +import { LinkGraph } from "../src/link-graph"; + +const DIRS = [join(import.meta.dir, "fixture/input/acme")]; + +test("graph exposes relationship edges, origin provenance, and transitive ancestors", async () => { + const g = new LinkGraph(await loadModel(DIRS)); + // relationship.association on Order → Customer (added to fixture in Task 12) + const rels = g.relationshipsOf("acme::shop::Order"); + expect(rels.some((r) => r.cardinality === "one" && r.toFqn === "acme::shop::Customer")).toBe(true); + // relationship edge is queryable as a ref kind + expect(g.refsFrom("acme::shop::Order").some((r) => r.kind === "relationship")).toBe(true); + // origin.passthrough on a projection object (fixture LineItemView) + const origins = g.originsOf("acme::shop::LineItemView"); + expect(origins.some((o) => o.field === "customerName" && o.via.length > 0)).toBe(true); + // transitive ancestors: Order extends BaseEntity (fixture) — chain non-empty and resolvable + const anc = g.ancestors("acme::shop::Order"); + expect(anc.map((n) => n.name)).toContain("BaseEntity"); +}); diff --git a/server/typescript/packages/docs-site/test/index-package-v2.test.ts b/server/typescript/packages/docs-site/test/index-package-v2.test.ts new file mode 100644 index 000000000..f8ddf0777 --- /dev/null +++ b/server/typescript/packages/docs-site/test/index-package-v2.test.ts @@ -0,0 +1,25 @@ +import { expect, test } from "bun:test"; +import { join } from "node:path"; +import { loadModel } from "../src/load"; +import { LinkGraph } from "../src/link-graph"; +import { CoverageTracker } from "../src/coverage"; +import { buildIndexPage } from "../src/builders/index-data"; +import { buildPackagePage } from "../src/builders/package-data"; + +const DIRS = [join(import.meta.dir, "fixture/input/acme")]; + +test("index shows hero flowchart + package cards carrying authored purpose", async () => { + const g = new LinkGraph(await loadModel(DIRS)); + const d = buildIndexPage(g, new CoverageTracker(), { title: "Fixture", stamp: "2026-01-01", commit: "abc1234", sourceDirs: DIRS, core: { n: 15 } }); + // NOTE: brief Step-1 test said erDiagram but hero is now a flowchartDomain → corrected to "flowchart" + expect(d.coreMermaid).toContain("flowchart"); + expect(d.dataPackages.find((c) => c.pkg === "acme::shop")?.purpose).toContain("orders"); +}); + +test("package page carries authored prose + key-entity cards", async () => { + const g = new LinkGraph(await loadModel(DIRS)); + const d = buildPackagePage("acme::shop", g, new CoverageTracker(), DIRS); + expect(d.title).toBe("Shop"); + expect(d.descHtml).toContain("orders"); + expect(d.keyCards[0]?.name).toBe("Customer"); +}); diff --git a/server/typescript/packages/docs-site/test/link-check.test.ts b/server/typescript/packages/docs-site/test/link-check.test.ts new file mode 100644 index 000000000..a28d69a36 --- /dev/null +++ b/server/typescript/packages/docs-site/test/link-check.test.ts @@ -0,0 +1,14 @@ +import { expect, test } from "bun:test"; +import { mkdtempSync, writeFileSync, mkdirSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { checkLinks } from "../src/link-check"; + +test("detects dangling files and anchors", () => { + const d = mkdtempSync(join(tmpdir(), "lc-")); + mkdirSync(join(d, "a"), { recursive: true }); + writeFileSync(join(d, "index.html"), `okbadokbad`); + writeFileSync(join(d, "a/one.html"), `
`); + const errs = checkLinks(d, ["index.html", "a/one.html"]); + expect(errs).toEqual(["index.html -> a/two.html", "index.html -> a/one.html#f-y"]); +}); diff --git a/server/typescript/packages/docs-site/test/link-graph.test.ts b/server/typescript/packages/docs-site/test/link-graph.test.ts new file mode 100644 index 000000000..234d763e7 --- /dev/null +++ b/server/typescript/packages/docs-site/test/link-graph.test.ts @@ -0,0 +1,26 @@ +import { expect, test } from "bun:test"; +import { join } from "node:path"; +import { loadModel } from "../src/load"; +import { LinkGraph } from "../src/link-graph"; + +const FIX = join(import.meta.dir, "fixture/input"); + +test("builds nodes, refs, backlinks, degree, hrefs", async () => { + const model = await loadModel([join(FIX, "acme")]); + const g = new LinkGraph(model); + const order = g.byFqn("acme::shop::Order")!; + expect(order.href).toBe("acme/shop/Order.html"); + // FK ref Order -> Customer via customerId + expect(g.refsFrom("acme::shop::Order").some((r) => r.to === "acme::shop::Customer" && r.kind === "fk")).toBe(true); + // payload ref: prompt npcReview -> NpcPayload; nested objectRef NpcPayload -> ItemView + expect(g.refsFrom("acme::ai::npcReview").some((r) => r.to === "acme::ai::NpcPayload" && r.kind === "payload")).toBe(true); + expect(g.refsFrom("acme::ai::NpcPayload").some((r) => r.to === "acme::ai::ItemView" && r.kind === "field")).toBe(true); + // backlink + degree: 3 refs (Order fk + Order relationship + LineItemView origin-passthrough) + expect(g.refsTo("acme::shop::Customer").length).toBe(3); + expect(g.degree("acme::shop::Customer")).toBe(3); + // extends + expect(g.extendedBy("acme::common::BaseEntity").map((n) => n.name).sort()).toEqual(["Customer", "Order"]); + // relative href + expect(g.relHref("acme/shop/Order.html", "acme/ai/NpcPayload.html")).toBe("../ai/NpcPayload.html"); + expect(g.relHref("index.html", "acme/shop/Order.html")).toBe("acme/shop/Order.html"); +}); diff --git a/server/typescript/packages/docs-site/test/load.test.ts b/server/typescript/packages/docs-site/test/load.test.ts new file mode 100644 index 000000000..8e3981f40 --- /dev/null +++ b/server/typescript/packages/docs-site/test/load.test.ts @@ -0,0 +1,13 @@ +import { expect, test } from "bun:test"; +import { join } from "node:path"; +import { loadModel, treeOf } from "../src/load"; + +const FIX = join(import.meta.dir, "fixture/input"); + +test("loads all fixture source dirs into one root", async () => { + const model = await loadModel([join(FIX, "acme")]); + const names = model.root.objects().map((o) => o.name).sort(); + expect(names).toEqual(["BaseEntity", "Customer", "ItemView", "LineItemView", "NpcPayload", "NpcResponse", "Order", "OrderLine", "OrphanLog", "OrphanVO"]); + const order = model.root.objects().find((o) => o.name === "Order")!; + expect(treeOf(order, model)).toBe("acme"); +}); diff --git a/server/typescript/packages/docs-site/test/mermaid.test.ts b/server/typescript/packages/docs-site/test/mermaid.test.ts new file mode 100644 index 000000000..ac278c1fa --- /dev/null +++ b/server/typescript/packages/docs-site/test/mermaid.test.ts @@ -0,0 +1,60 @@ +import { expect, test } from "bun:test"; +import { packageFlowchart, inheritanceTree, THEME_INIT } from "../src/mermaid"; + +test("packageFlowchart labels nodes with counts", () => { + const d = packageFlowchart([{ from: "shop", to: "common", n: 2 }], new Map([["shop", 3], ["common", 1]])); + expect(d).toBe( +THEME_INIT + +`flowchart LR + common["common · 1"] + shop["shop · 3"] + shop -->|2| common`); +}); + +test("inheritanceTree renders a deterministic top-down chain with the self node marked", () => { + const t = inheritanceTree([ + { name: "BaseEntity", level: 0 }, + { name: "BaseTenantEntity", level: 1 }, + { name: "GameSession", level: 2, self: true }, + { name: "ChildA", level: 3 }, + { name: "ChildB", level: 3 }, + ]); + expect(t.startsWith(THEME_INIT)).toBe(true); + expect(t).toContain("flowchart TD"); + expect(t.indexOf("ChildA")).toBeLessThan(t.indexOf("ChildB")); // deterministic child order +}); + +test("erDiagramRich puts attributes in boxes + per-entity fill(domain)/stroke(role)", () => { + const { erDiagramRich, domainColor } = require("../src/mermaid"); + const out = erDiagramRich( + [ + { name: "GameSession", pkg: "acme::session", role: "focal", + attrs: [{ type: "string", name: "id", key: "PK", note: "uuid" }, { type: "enum", name: "status", key: "", note: "lifecycle" }], more: 3 }, + { name: "WorldLocation", pkg: "acme::world", role: "external", + attrs: [{ type: "string", name: "id", key: "PK", note: "" }], more: 0 }, + ], + [{ parent: "WorldLocation", child: "GameSession", label: "loc" }], + ); + expect(out).toContain("erDiagram"); + expect(out).toContain("string id PK"); + expect(out).toContain('enum status "lifecycle"'); // empty-key: no extra space before quote + expect(out).toContain('_ plus "+3 more"'); // truncation sentinel with opening quote + expect(out).toContain(`stroke:${"#60a5fa"}`); // focal role stroke + expect(out).toContain(domainColor("acme::session").fill); // domain fill applied +}); + +test("flowchartDomain fills nodes by domain and returns a sorted legend", () => { + const { flowchartDomain } = require("../src/mermaid"); + const r = flowchartDomain( + [{ name: "GameSession", pkg: "acme::session" }, { name: "ActiveNpc", pkg: "acme::npc" }], + [{ from: "GameSession", to: "ActiveNpc", label: "session" }], + ); + expect(r.mermaid).toContain("flowchart"); + expect(r.legend.map((l: { pkg: string }) => l.pkg)).toEqual(["acme::npc", "acme::session"]); // sorted, deduped +}); + +test("domainColor is deterministic and stable for unmapped packages", () => { + const { domainColor } = require("../src/mermaid"); + expect(domainColor("acme::session")).toEqual(domainColor("acme::session")); + expect(domainColor("acme::zzz_unmapped").fill).toMatch(/^#/); // assigned a stable slot, not random +}); diff --git a/server/typescript/packages/docs-site/test/mustache-highlight.test.ts b/server/typescript/packages/docs-site/test/mustache-highlight.test.ts new file mode 100644 index 000000000..e1d8e723c --- /dev/null +++ b/server/typescript/packages/docs-site/test/mustache-highlight.test.ts @@ -0,0 +1,30 @@ +import { expect, test } from "bun:test"; +import { highlightMustache } from "../src/mustache-highlight"; + +const SRC = `{{! note }} +Hello {{name}} & {{{raw}}} +{{#items}}{{label}}{{/items}} +{{> shared/footer}}`; + +test("tokenizes all forms, escapes text, links resolved refs", () => { + const r = highlightMustache(SRC, (p) => (p === "name" || p === "items" ? `#f-${p}` : undefined)); + expect(r.html).toContain(`{{! note }}`); + expect(r.html).toContain(`&`); // escaped text + expect(r.html).toContain(`{{name}}`); + expect(r.html).toContain(`{{{raw}}}`); + expect(r.html).toContain(`id="sec-items"`); + expect(r.html).toContain(`{{label}}`); + expect(r.html).toContain(`{{> shared/footer}}`); + expect(r.toc).toEqual([{ name: "items", anchor: "sec-items" }]); + expect(r.refs).toContain("name"); +}); + +test("escapes attribute-context injection attempts", () => { + const r = highlightMustache(`{{#x" onmouseover="alert(1)}}{{/x" onmouseover="alert(1)}}`, () => undefined); + expect(r.html).not.toContain(`onmouseover="alert`); + expect(r.html).toContain("""); + const r2 = highlightMustache("{{unclosed", () => undefined); + expect(r2.html).toBe("{{unclosed"); + const r3 = highlightMustache("", () => undefined); + expect(r3.html).toBe(""); +}); diff --git a/server/typescript/packages/docs-site/test/object-data-v2.test.ts b/server/typescript/packages/docs-site/test/object-data-v2.test.ts new file mode 100644 index 000000000..ab8c653ac --- /dev/null +++ b/server/typescript/packages/docs-site/test/object-data-v2.test.ts @@ -0,0 +1,28 @@ +import { expect, test } from "bun:test"; +import { join } from "node:path"; +import { loadModel } from "../src/load"; +import { LinkGraph } from "../src/link-graph"; +import { CoverageTracker } from "../src/coverage"; +import { buildObjectPage } from "../src/builders/object-data"; + +const DIRS = [join(import.meta.dir, "fixture/input/acme")]; + +test("object page surfaces validators, indexes detail, relations, generation, description, inheritance", async () => { + const g = new LinkGraph(await loadModel(DIRS)); + const d = buildObjectPage("acme::shop::Order", g, new CoverageTracker()); + expect(d.desc).toContain("<"); // fixture desc with < is escaped + expect(d.generation).toBe("uuid"); // identity.primary generation + expect(d.validators.some((v) => v.scope === "object" && v.rule.includes("qty"))).toBe(true); // comparison qty<=99 + expect(d.indexes.some((i) => i.extra.includes("where") || i.extra.includes("orders"))).toBe(true); + expect(d.relations.some((r) => r.toName === "Customer" && r.cardinality === "one")).toBe(true); + expect(d.ownFields.find((f) => f.name === "status")?.enumValues.length).toBeGreaterThan(0); + expect(d.hierarchy.some((h) => h.self) && d.hierarchy.some((h) => h.name === "BaseEntity")).toBe(true); + expect(d.inheritanceMermaid).toContain("flowchart TD"); +}); + +test("projection object marks view + origin provenance", async () => { + const g = new LinkGraph(await loadModel(DIRS)); + const d = buildObjectPage("acme::shop::LineItemView", g, new CoverageTracker()); + expect(d.isView).toBe(true); + expect(d.origins.some((o) => o.field === "customerName")).toBe(true); +}); diff --git a/server/typescript/packages/docs-site/test/object-data.test.ts b/server/typescript/packages/docs-site/test/object-data.test.ts new file mode 100644 index 000000000..9f75ce332 --- /dev/null +++ b/server/typescript/packages/docs-site/test/object-data.test.ts @@ -0,0 +1,33 @@ +import { expect, test } from "bun:test"; +import { join } from "node:path"; +import { loadModel } from "../src/load"; +import { LinkGraph } from "../src/link-graph"; +import { CoverageTracker } from "../src/coverage"; +import { buildObjectPage } from "../src/builders/object-data"; + +test("Order page: extends chain, own vs inherited, constraints, backlinks", async () => { + const model = await loadModel([join(import.meta.dir, "fixture/input/acme")]); + const g = new LinkGraph(model); + const d = buildObjectPage("acme::shop::Order", g, new CoverageTracker()); + // extendsChain → hierarchy: ancestors appear as non-self rows above the self row + const selfLevel = d.hierarchy.find((h) => h.self)!.level; + expect(d.hierarchy.filter((h) => !h.self && h.level < selfLevel).map((h) => h.name)).toEqual(["BaseEntity"]); + expect(d.ownFields.map((f) => f.name)).toEqual(["status", "qty", "customerId"]); + expect(d.inheritedFields.map((f) => f.name)).toEqual(["id", "createdAt"]); + expect(d.inheritedFields[0].inheritedFrom?.name).toBe("BaseEntity"); + const status = d.ownFields[0]; + // constraintsHtml → badgesHtml; enum values are now in enumValues array not injected into badges + expect(status.enumValues.map((e) => e.value)).toContain("OPEN"); + expect(status.badgesHtml).toContain("default OPEN"); + const qty = d.ownFields[1]; + expect(qty.badgesHtml).toContain("min=1"); + expect(d.references.some((r) => r.name === "Customer")).toBe(true); + expect(d.tableName).toBe("orders"); +}); + +test("ItemView page: used-by-templates backlink through the payload tree", async () => { + const model = await loadModel([join(import.meta.dir, "fixture/input/acme")]); + const g = new LinkGraph(model); + const d = buildObjectPage("acme::ai::ItemView", g, new CoverageTracker()); + expect(d.usedByTemplates.map((t) => t.name)).toContain("npcReview"); +}); diff --git a/server/typescript/packages/docs-site/test/package-docs.test.ts b/server/typescript/packages/docs-site/test/package-docs.test.ts new file mode 100644 index 000000000..c47140c76 --- /dev/null +++ b/server/typescript/packages/docs-site/test/package-docs.test.ts @@ -0,0 +1,18 @@ +import { expect, test } from "bun:test"; +import { join } from "node:path"; +import { loadModel } from "../src/load"; +import { LinkGraph } from "../src/link-graph"; +import { harvestPackageDocs, keyEntities } from "../src/package-docs"; + +const DIRS = [join(import.meta.dir, "fixture/input/acme")]; + +test("package docs parsed from _package.yaml; key entities ranked by inbound refs", async () => { + const docs = harvestPackageDocs(DIRS); + const shop = docs.get("acme::shop"); + expect(shop?.title).toBe("Shop"); // fixture _package.yaml (Task 12) + expect(shop?.description).toContain("orders"); + const g = new LinkGraph(await loadModel(DIRS)); + const keys = keyEntities("acme::shop", g); + expect(keys[0]?.name).toBe("Customer"); // most-referenced in fixture + expect(keys.every((k) => k.inbound >= (keys[keys.length - 1]?.inbound ?? 0))).toBe(true); +}); diff --git a/server/typescript/packages/docs-site/test/package-index-data.test.ts b/server/typescript/packages/docs-site/test/package-index-data.test.ts new file mode 100644 index 000000000..bce924dab --- /dev/null +++ b/server/typescript/packages/docs-site/test/package-index-data.test.ts @@ -0,0 +1,34 @@ +import { expect, test } from "bun:test"; +import { join } from "node:path"; +import { loadModel } from "../src/load"; +import { LinkGraph } from "../src/link-graph"; +import { CoverageTracker } from "../src/coverage"; +import { buildPackagePage } from "../src/builders/package-data"; +import { buildIndexPage } from "../src/builders/index-data"; + +test("package page: abstracts grouped, ERD variants, prompt rows", async () => { + const model = await loadModel([join(import.meta.dir, "fixture/input/acme")]); + const g = new LinkGraph(model); + const shop = buildPackagePage("acme::shop", g, new CoverageTracker()); + expect(shop.objects.map((o) => o.name)).toEqual(["Customer", "LineItemView", "Order", "OrphanLog"]); + // v2: single erdMermaid replaces the erdInternal/erdExternals toggle + expect(shop.erdMermaid).toContain("Customer"); + expect(shop.erdMermaid).toContain("Order"); + expect(shop.erdLegend.some((l) => l.pkg === "acme::shop")).toBe(true); + const common = buildPackagePage("acme::common", g, new CoverageTracker()); + expect(common.abstracts.map((o) => o.name)).toEqual(["BaseEntity"]); + const ai = buildPackagePage("acme::ai", g, new CoverageTracker()); + expect(ai.prompts.map((t) => t.name)).toEqual(["npcReview"]); + expect(ai.outputs.map((t) => t.name)).toEqual(["npcReviewOutput"]); +}); + +test("index page: stats, hero flowchart, package cards", async () => { + const model = await loadModel([join(import.meta.dir, "fixture/input/acme")]); + const g = new LinkGraph(model); + const d = buildIndexPage(g, new CoverageTracker(), { title: "Fixture", stamp: "S", commit: "C", core: { n: 2 } }); + expect(d.stats.objects).toBe(10); + expect(d.stats.contracts).toBe(1); + expect(d.coreMermaid).toContain("flowchart"); // hero is now a domain-colored flowchart + expect(d.coreCaption).toContain("most-connected"); // connected-cluster core map (all object types) + expect(d.promptPackages.map((p) => p.pkg)).toEqual(["acme::ai"]); +}); diff --git a/server/typescript/packages/docs-site/test/prompt-output-extras.test.ts b/server/typescript/packages/docs-site/test/prompt-output-extras.test.ts new file mode 100644 index 000000000..0470ee10f --- /dev/null +++ b/server/typescript/packages/docs-site/test/prompt-output-extras.test.ts @@ -0,0 +1,62 @@ +import { expect, test } from "bun:test"; +import { join } from "node:path"; +import { loadModel } from "../src/load"; +import { LinkGraph } from "../src/link-graph"; +import { CoverageTracker } from "../src/coverage"; +import { harvestComments } from "../src/yaml-comments"; +import { buildPromptPage } from "../src/builders/prompt-data"; +import { buildOutputPage } from "../src/builders/output-data"; +import { buildEnumsPage, findAnomalies, buildSearchIndex } from "../src/builders/extras"; + +const DIRS = [join(import.meta.dir, "fixture/input/acme")]; + +test("prompt page: highlighted source, payload tree links, unresolved marks", async () => { + const model = await loadModel(DIRS); + const g = new LinkGraph(model); + const d = buildPromptPage("acme::ai::npcReview", g, new CoverageTracker(), DIRS); + expect(d.sourceHtml).toContain(`class="mu-com"`); + expect(d.sourceHtml).toContain(`href="#f-npcName"`); + expect(d.sourceHtml).toContain("mu-unresolved"); // {{{rawNotes}}} + expect(d.payloadTree.some((r) => r.name === "npcName")).toBe(true); + expect(d.tocHtml).toContain("items"); +}); + +test("output page: wire classification + harvested comment (escaped)", async () => { + const model = await loadModel(DIRS); + const g = new LinkGraph(model); + const docs = harvestComments(DIRS); + const d = buildOutputPage("acme::ai::npcReviewOutput", g, new CoverageTracker(), docs, DIRS); + expect(d.textRefResolves).toBe(false); + expect(d.fields.find((f) => f.name === "reason")?.wire).toBe("@xmlText body"); + // note now comes from description attr (preferred over comment); must be escaped (XSS/corruption fix C1) + const verdictNote = d.fields.find((f) => f.name === "verdict")?.note ?? ""; + // description attr: "verdict tag & \"quote\"" → all special chars escaped + expect(verdictNote).toContain("<b>"); + expect(verdictNote).not.toContain(""); + expect(verdictNote).toContain("&"); + expect(verdictNote).toContain("""); +}); + +test("output Meaning column reads authored description; prompt payload rows carry descriptions", async () => { + const g = new LinkGraph(await loadModel(DIRS)); + const docs = harvestComments(DIRS); + const out = buildOutputPage("acme::ai::npcReviewOutput", g, new CoverageTracker(), docs, DIRS); + // fixture: `verdict` field carries description: "......" → escaped, non-empty, in note + expect(out.fields.find((f) => f.name === "verdict")?.note).toContain("<"); + const pr = buildPromptPage("acme::ai::npcReview", g, new CoverageTracker(), DIRS); + expect(typeof pr.desc).toBe("string"); +}); + +test("enums, anomalies, search index", async () => { + const model = await loadModel(DIRS); + const g = new LinkGraph(model); + expect(buildEnumsPage(g).some((r) => r.owner === "Order" && r.values.includes("OPEN"))).toBe(true); + const an = findAnomalies(g, DIRS); + expect(an.some((a) => a.kind === "orphan" && a.subject === "OrphanLog")).toBe(true); + expect(an.some((a) => a.kind === "unresolved-textref" && a.subject === "npcReviewOutput")).toBe(true); + expect(buildSearchIndex(g).some((e) => e.k === "field" && e.t.includes("qty"))).toBe(true); + // I3: composite-FK suppression -- orderId is part of a multi-field identity.reference, so no implied-ref + expect(an.some((a) => a.kind === "implied-ref" && a.subject === "OrderLine.orderId")).toBe(false); + // I3: unreachable-VO -- OrphanVO is in acme::ai (has templates) but unreachable from any payload tree + expect(an.some((a) => a.kind === "unreachable-vo" && a.subject === "OrphanVO")).toBe(true); +}); diff --git a/server/typescript/packages/docs-site/test/site.test.ts b/server/typescript/packages/docs-site/test/site.test.ts new file mode 100644 index 000000000..58c3f56c3 --- /dev/null +++ b/server/typescript/packages/docs-site/test/site.test.ts @@ -0,0 +1,15 @@ +import { expect, test } from "bun:test"; +import { existsSync, readFileSync, mkdtempSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { generateSite } from "../src/site"; + +test("generates all pages for the fixture", async () => { + const out = mkdtempSync(join(tmpdir(), "site-")); + const r = await generateSite({ sourceDirs: [join(import.meta.dir, "fixture/input/acme")], outDir: out, title: "Fixture", stamp: "2026-01-01", commit: "abc1234" }); + for (const p of ["index.html", "coverage.html", "enums.html", "acme/shop/index.html", "acme/shop/Order.html", "acme/ai/npcReview.html", "acme/ai/npcReviewOutput.html", "assets/site.css", "assets/site.js", "assets/search-index.json"]) + expect(existsSync(join(out, p))).toBe(true); + expect(readFileSync(join(out, "acme/ai/npcReview.html"), "utf8")).toContain("mu-sec"); + expect(r.pages.length).toBeGreaterThan(10); + expect(r.dangling).toEqual([]); +}); diff --git a/server/typescript/packages/docs-site/tsconfig.json b/server/typescript/packages/docs-site/tsconfig.json new file mode 100644 index 000000000..a14362619 --- /dev/null +++ b/server/typescript/packages/docs-site/tsconfig.json @@ -0,0 +1 @@ +{ "extends": "../../tsconfig.base.json", "compilerOptions": { "outDir": "./dist", "rootDir": "./src" }, "include": ["src/**/*"], "exclude": ["dist", "test", "node_modules"] } diff --git a/server/typescript/packages/docs-site/tsconfig.typecheck.json b/server/typescript/packages/docs-site/tsconfig.typecheck.json new file mode 100644 index 000000000..4d5d9bd87 --- /dev/null +++ b/server/typescript/packages/docs-site/tsconfig.typecheck.json @@ -0,0 +1,9 @@ +{ + "extends": "./tsconfig.json", + "compilerOptions": { + "noEmit": true, + "rootDir": "." + }, + "include": ["src/**/*", "test/**/*"], + "exclude": ["dist", "node_modules"] +} From b9f3aa5a0d4023dc5076764be34dc16100e38026 Mon Sep 17 00:00:00 2001 From: Doug Mealing Date: Sat, 4 Jul 2026 12:02:35 -0400 Subject: [PATCH 04/22] build(docs-site): strict-mode type fixes + confirm dist build/template resolution MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The monorepo's tsconfig.base.json is stricter than the source bun project (noUncheckedIndexedAccess + exactOptionalPropertyTypes). Applied minimal type-only fixes so `tsc -p .` (build) and typecheck pass: non-null assertions on in-bounds array/match accesses, default-destructure on guaranteed splits, and `| undefined` on the optional interface props that receive explicit undefined. No generator behavior change — golden byte-identical, 29 tests pass. Verified the BUILT dist package resolves templates/assets via resolve(import.meta.dir, "../templates") from dist/ (18 pages, 0 dangling on the acme fixture). bun.lock registers the workspace package. Co-Authored-By: Claude Opus 4.8 (1M context) --- bun.lock | 15 +++++++++++++++ .../packages/docs-site/src/builders/index-data.ts | 4 ++-- .../docs-site/src/builders/object-data.ts | 8 ++++---- .../docs-site/src/builders/package-data.ts | 4 ++-- .../docs-site/src/builders/prompt-data.ts | 2 +- .../packages/docs-site/src/link-check.ts | 6 +++--- .../typescript/packages/docs-site/src/mermaid.ts | 6 +++--- .../packages/docs-site/src/yaml-comments.ts | 10 +++++----- .../packages/docs-site/test/coverage.test.ts | 4 ++-- .../packages/docs-site/test/object-data.test.ts | 6 +++--- 10 files changed, 40 insertions(+), 25 deletions(-) diff --git a/bun.lock b/bun.lock index d44d69c03..bb67c41ad 100644 --- a/bun.lock +++ b/bun.lock @@ -265,6 +265,19 @@ "typescript": "^5.6.0", }, }, + "server/typescript/packages/docs-site": { + "name": "@metaobjectsdev/docs-site", + "version": "0.15.6", + "dependencies": { + "@metaobjectsdev/metadata": "workspace:*", + "@metaobjectsdev/render": "workspace:*", + "yaml": "^2.9.0", + }, + "devDependencies": { + "bun-types": "latest", + "typescript": "^5.6.0", + }, + }, "server/typescript/packages/forge": { "name": "@metaobjectsdev/forge", "version": "0.1.0", @@ -635,6 +648,8 @@ "@metaobjectsdev/conformance": ["@metaobjectsdev/conformance@workspace:server/typescript/packages/conformance"], + "@metaobjectsdev/docs-site": ["@metaobjectsdev/docs-site@workspace:server/typescript/packages/docs-site"], + "@metaobjectsdev/forge": ["@metaobjectsdev/forge@workspace:server/typescript/packages/forge"], "@metaobjectsdev/integration-tests": ["@metaobjectsdev/integration-tests@workspace:server/typescript/packages/integration-tests"], diff --git a/server/typescript/packages/docs-site/src/builders/index-data.ts b/server/typescript/packages/docs-site/src/builders/index-data.ts index 49dab2238..cc95f53cb 100644 --- a/server/typescript/packages/docs-site/src/builders/index-data.ts +++ b/server/typescript/packages/docs-site/src/builders/index-data.ts @@ -8,7 +8,7 @@ export interface PkgCard { pkg: string; href: string; objectCount: number; promp export interface CoreConfig { pin?: string[]; exclude?: string[]; n?: number; } export interface IndexPageData { title: string; stamp: string; commit: string; stats: { objects: number; tables: number; packages: number; promptVos: number; prompts: number; contracts: number; enums: number }; coreMermaid: string; coreCaption: string; coreLegend: { pkg: string; fill: string; stroke: string }[]; packageMermaid: string; fullEdges: { from: string; to: string; n: number }[]; dataPackages: PkgCard[]; promptPackages: PkgCard[]; } -export function buildIndexPage(g: LinkGraph, cov: CoverageTracker, opts: { title: string; stamp: string; commit: string; core?: CoreConfig; sourceDirs?: string[] }): IndexPageData { +export function buildIndexPage(g: LinkGraph, cov: CoverageTracker, opts: { title: string; stamp: string; commit: string; core?: CoreConfig | undefined; sourceDirs?: string[] | undefined }): IndexPageData { const objs = g.nodes().filter((n) => n.kind === "object"); const tpls = g.nodes().filter((n) => n.kind !== "object"); const pkgs = [...new Set(g.nodes().map((n) => n.pkg))].sort(); @@ -60,7 +60,7 @@ export function buildIndexPage(g: LinkGraph, cov: CoverageTracker, opts: { title const t = g.byFqn(r.to); if (!t || t.pkg === o.pkg) continue; const k = `${shortPkg(o.pkg)}→${shortPkg(t.pkg)}`; pkgEdges.set(k, (pkgEdges.get(k) ?? 0) + 1); } - const fullEdges = [...pkgEdges.entries()].sort().map(([k, n]) => { const [from, to] = k.split("→"); return { from, to, n }; }); + const fullEdges = [...pkgEdges.entries()].sort().map(([k, n]) => { const [from = "", to = ""] = k.split("→"); return { from, to, n }; }); const counts = new Map(); for (const o of objs) counts.set(shortPkg(o.pkg), (counts.get(shortPkg(o.pkg)) ?? 0) + 1); const card = (p: string): PkgCard => { const doc = pdocs.get(p); diff --git a/server/typescript/packages/docs-site/src/builders/object-data.ts b/server/typescript/packages/docs-site/src/builders/object-data.ts index 2334a06a9..0cc9958f4 100644 --- a/server/typescript/packages/docs-site/src/builders/object-data.ts +++ b/server/typescript/packages/docs-site/src/builders/object-data.ts @@ -27,7 +27,7 @@ function neighborAttrs(o: MetaData): { attrs: ErAttr[]; more: number } { } export interface EnumValue { value: string; deflt: boolean; desc: string; } -export interface FieldRow { name: string; type: string; isArray: boolean; required: boolean; badgesHtml: string; desc: string; enumValues: EnumValue[]; refHref?: string; refName?: string; inheritedFrom?: { name: string; href: string }; anchor: string; } +export interface FieldRow { name: string; type: string; isArray: boolean; required: boolean; badgesHtml: string; desc: string; enumValues: EnumValue[]; refHref?: string | undefined; refName?: string | undefined; inheritedFrom?: { name: string; href: string } | undefined; anchor: string; } export interface IndexRow { name: string; kind: string; fields: string; extra: string; unique: boolean; } export interface ValidatorRow { scope: "field" | "object"; subject: string; rule: string; // human-readable, HTML-escaped } @@ -36,10 +36,10 @@ export interface OriginRow { field: string; from: string; via: string; } export interface HierRow { name: string; href: string; level: number; self: boolean; } export interface ObjectPageData { name: string; kindBadge: string; isAbstract: boolean; isView: boolean; generation: string; - pkg: string; href: string; breadcrumbHtml: string; desc: string; tableName?: string; pkHtml?: string; + pkg: string; href: string; breadcrumbHtml: string; desc: string; tableName?: string | undefined; pkHtml?: string | undefined; ownFields: FieldRow[]; inheritedFields: FieldRow[]; indexes: IndexRow[]; validators: ValidatorRow[]; - relations: RelationRow[]; origins: OriginRow[]; hierarchy: HierRow[]; inheritanceMermaid?: string; - neighborhoodMermaid?: string; neighborhoodLegend?: { pkg: string; fill: string; stroke: string }[]; neighborhoodMore?: number; + relations: RelationRow[]; origins: OriginRow[]; hierarchy: HierRow[]; inheritanceMermaid?: string | undefined; + neighborhoodMermaid?: string | undefined; neighborhoodLegend?: { pkg: string; fill: string; stroke: string }[] | undefined; neighborhoodMore?: number | undefined; referencedBy: { name: string; href: string; via: string }[]; references: { name: string; href: string; via: string }[]; usedByTemplates: { name: string; href: string }[]; sourceFile: string; diff --git a/server/typescript/packages/docs-site/src/builders/package-data.ts b/server/typescript/packages/docs-site/src/builders/package-data.ts index 689116202..a3df8b4dc 100644 --- a/server/typescript/packages/docs-site/src/builders/package-data.ts +++ b/server/typescript/packages/docs-site/src/builders/package-data.ts @@ -42,7 +42,7 @@ export interface PackagePageData { export function buildPackagePage(pkg: string, g: LinkGraph, cov: CoverageTracker, sourceDirs?: string[]): PackagePageData { const dirs = sourceDirs ?? []; const members = g.nodes().filter((n) => n.pkg === pkg); - const pageHref = `${members[0].pkgPath}/index.html`; + const pageHref = `${members[0]!.pkgPath}/index.html`; const objRow = (n: (typeof members)[0]): ObjRow => ({ name: n.name, href: `${n.name}.html`, kind: n.node.subType, table: String(n.node.childrenOfType("source").map((s) => s.attr("table")).find((t) => t !== undefined) ?? ""), @@ -125,7 +125,7 @@ export function buildPackagePage(pkg: string, g: LinkGraph, cov: CoverageTracker const referencedBy = [...inbound.entries()].sort().map(([p, n]) => ({ pkg: p, href: g.relHref(pageHref, `${p.split("::").join("/")}/index.html`), n })); return { - pkg, pkgPath: members[0].pkgPath, tree: members[0].tree, + pkg, pkgPath: members[0]!.pkgPath, tree: members[0]!.tree, breadcrumbHtml: `index / ${esc(pkg)}`, title, descHtml, keyCards, erdMermaid, erdLegend, diff --git a/server/typescript/packages/docs-site/src/builders/prompt-data.ts b/server/typescript/packages/docs-site/src/builders/prompt-data.ts index 615c6240e..f5e796dbf 100644 --- a/server/typescript/packages/docs-site/src/builders/prompt-data.ts +++ b/server/typescript/packages/docs-site/src/builders/prompt-data.ts @@ -6,7 +6,7 @@ import { highlightMustache } from "../mustache-highlight"; import { esc } from "../badges"; export interface PayloadTreeRow { indent: number; name: string; type: string; isArray: boolean; anchor: string; desc: string; refHtml: string; } -export interface PromptPageData { name: string; pkg: string; href: string; breadcrumbHtml: string; attrsHtml: string; desc: string; payloadName: string; payloadHref: string; payloadTree: PayloadTreeRow[]; sourceHtml?: string; sourceMissingNote?: string; tocHtml?: string; packageFiles: { file: string; html: string }[]; } +export interface PromptPageData { name: string; pkg: string; href: string; breadcrumbHtml: string; attrsHtml: string; desc: string; payloadName: string; payloadHref: string; payloadTree: PayloadTreeRow[]; sourceHtml?: string | undefined; sourceMissingNote?: string | undefined; tocHtml?: string | undefined; packageFiles: { file: string; html: string }[]; } export function buildPromptPage(fqn: string, g: LinkGraph, cov: CoverageTracker, sourceDirs: string[]): PromptPageData { const dn = g.byFqn(fqn)!; diff --git a/server/typescript/packages/docs-site/src/link-check.ts b/server/typescript/packages/docs-site/src/link-check.ts index 5fff98800..517b02f25 100644 --- a/server/typescript/packages/docs-site/src/link-check.ts +++ b/server/typescript/packages/docs-site/src/link-check.ts @@ -5,15 +5,15 @@ export function checkLinks(outDir: string, pages: string[]): string[] { const errs: string[] = []; const idCache = new Map>(); const idsOf = (p: string) => { - if (!idCache.has(p)) idCache.set(p, new Set([...readFileSync(p, "utf8").matchAll(/id="([^"]+)"/g)].map((m) => m[1]))); + if (!idCache.has(p)) idCache.set(p, new Set([...readFileSync(p, "utf8").matchAll(/id="([^"]+)"/g)].map((m) => m[1]!))); return idCache.get(p)!; }; for (const page of pages) { const html = readFileSync(join(outDir, page), "utf8"); for (const m of html.matchAll(/href="([^"]+)"/g)) { - const href = m[1]; + const href = m[1]!; if (/^(https?:|mailto:|#$)/.test(href)) continue; - const [file, anchor] = href.split("#"); + const [file = "", anchor] = href.split("#"); const target = file === "" ? page : normalize(join(dirname(page), file)); const abs = join(outDir, target); if (!existsSync(abs)) { errs.push(`${page} -> ${href}`); continue; } diff --git a/server/typescript/packages/docs-site/src/mermaid.ts b/server/typescript/packages/docs-site/src/mermaid.ts index 215fdafb7..5f898c4ff 100644 --- a/server/typescript/packages/docs-site/src/mermaid.ts +++ b/server/typescript/packages/docs-site/src/mermaid.ts @@ -32,9 +32,9 @@ export function inheritanceTree(rows: { name: string; level: number; self?: bool for (const r of ordered) (byLevel.get(r.level) ?? byLevel.set(r.level, []).get(r.level)!).push(nodeId(r.name)); const levels = [...byLevel.keys()].sort((a, b) => a - b); for (let i = 1; i < levels.length; i++) { - const parents = byLevel.get(levels[i - 1])!; + const parents = byLevel.get(levels[i - 1]!)!; const parent = parents[parents.length - 1]; // the chain node at the shallower level - for (const child of byLevel.get(levels[i])!.sort()) lines.push(` ${parent} --> ${child}`); + for (const child of byLevel.get(levels[i]!)!.sort()) lines.push(` ${parent} --> ${child}`); } lines.push(" classDef self fill:#1e3a5f,stroke:#60a5fa,color:#e2e8f0,font-weight:bold"); return lines.join("\n"); @@ -61,7 +61,7 @@ export function domainColor(pkg: string): { fill: string; stroke: string; text: if (CURATED[leaf]) return CURATED[leaf]; // stable slot for unmapped packages: hash the leaf name deterministically into the palette let h = 0; for (let i = 0; i < leaf.length; i++) h = (h * 31 + leaf.charCodeAt(i)) >>> 0; - return PALETTE[h % PALETTE.length]; + return PALETTE[h % PALETTE.length]!; } const cls = (pkg: string) => `d_${(pkg.split("::").pop() ?? pkg).replace(/[^a-zA-Z0-9]/g, "_")}`; diff --git a/server/typescript/packages/docs-site/src/yaml-comments.ts b/server/typescript/packages/docs-site/src/yaml-comments.ts index 10d17c485..7cad2813e 100644 --- a/server/typescript/packages/docs-site/src/yaml-comments.ts +++ b/server/typescript/packages/docs-site/src/yaml-comments.ts @@ -13,20 +13,20 @@ export function harvestComments(sourceDirs: string[]): CommentDocs { const lines = readFileSync(f, "utf8").split("\n"); let current: string | undefined; for (let i = 0; i < lines.length; i++) { - const m = lines[i].match(/^\s*-\s*object\.\w+:/); + const m = lines[i]!.match(/^\s*-\s*object\.\w+:/); if (m) { current = undefined; for (let j = i + 1; j < Math.min(i + 4, lines.length); j++) { - const nm = lines[j].match(/^\s*name:\s*(\S+)/); if (nm) { current = nm[1]; break; } + const nm = lines[j]!.match(/^\s*name:\s*(\S+)/); if (nm) { current = nm[1]; break; } } if (current) { const desc: string[] = []; - for (let k = i - 1; k >= 0 && lines[k].trim().startsWith("#"); k--) { const c = clean(lines[k].trim()); if (c) desc.unshift(c); } + for (let k = i - 1; k >= 0 && lines[k]!.trim().startsWith("#"); k--) { const c = clean(lines[k]!.trim()); if (c) desc.unshift(c); } if (desc.length) objectDesc.set(current, desc.join(" ").replace(/\s+/g, " ").slice(0, 400)); } } - const fm = lines[i].match(/^\s*-\s*field\.\w+:\s*\{\s*name:\s*(\w+)[^}]*\}\s*#\s*(.+)$/); - if (fm && current) fieldNote.set(`${current}.${fm[1]}`, clean(fm[2]).slice(0, 160)); + const fm = lines[i]!.match(/^\s*-\s*field\.\w+:\s*\{\s*name:\s*(\w+)[^}]*\}\s*#\s*(.+)$/); + if (fm && current) fieldNote.set(`${current}.${fm[1]}`, clean(fm[2]!).slice(0, 160)); } } return { objectDesc, fieldNote }; diff --git a/server/typescript/packages/docs-site/test/coverage.test.ts b/server/typescript/packages/docs-site/test/coverage.test.ts index 3c781719c..8eda1f9c2 100644 --- a/server/typescript/packages/docs-site/test/coverage.test.ts +++ b/server/typescript/packages/docs-site/test/coverage.test.ts @@ -17,8 +17,8 @@ test("unconsumed kinds and attrs are reported", async () => { test("attr consumption is tracked accurately", async () => { const model = await loadModel([join(import.meta.dir, "fixture/input/acme")]); const cov2 = new CoverageTracker(); - const first = model.root.objects()[0]; - const field = first.childrenOfType("field")[0]; + const first = model.root.objects()[0]!; + const field = first.childrenOfType("field")[0]!; cov2.consumeAttr(field, "maxLength"); const rep2 = cov2.report(model.root); expect(rep2.attrs.length).toBeGreaterThan(0); diff --git a/server/typescript/packages/docs-site/test/object-data.test.ts b/server/typescript/packages/docs-site/test/object-data.test.ts index 9f75ce332..6ac874ce9 100644 --- a/server/typescript/packages/docs-site/test/object-data.test.ts +++ b/server/typescript/packages/docs-site/test/object-data.test.ts @@ -14,12 +14,12 @@ test("Order page: extends chain, own vs inherited, constraints, backlinks", asyn expect(d.hierarchy.filter((h) => !h.self && h.level < selfLevel).map((h) => h.name)).toEqual(["BaseEntity"]); expect(d.ownFields.map((f) => f.name)).toEqual(["status", "qty", "customerId"]); expect(d.inheritedFields.map((f) => f.name)).toEqual(["id", "createdAt"]); - expect(d.inheritedFields[0].inheritedFrom?.name).toBe("BaseEntity"); - const status = d.ownFields[0]; + expect(d.inheritedFields[0]!.inheritedFrom?.name).toBe("BaseEntity"); + const status = d.ownFields[0]!; // constraintsHtml → badgesHtml; enum values are now in enumValues array not injected into badges expect(status.enumValues.map((e) => e.value)).toContain("OPEN"); expect(status.badgesHtml).toContain("default OPEN"); - const qty = d.ownFields[1]; + const qty = d.ownFields[1]!; expect(qty.badgesHtml).toContain("min=1"); expect(d.references.some((r) => r.name === "Customer")).toBe(true); expect(d.tableName).toBe("orders"); From a40725498a9d4f344c32ac8f3f233828a2f3e48c Mon Sep 17 00:00:00 2001 From: Doug Mealing Date: Sat, 4 Jul 2026 12:11:14 -0400 Subject: [PATCH 05/22] =?UTF-8?q?feat(cli):=20meta=20docs=20--site=20surfa?= =?UTF-8?q?ce=20=E2=80=94=20emit=20the=20HTML=20documentation=20site?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Adds a `--site` surface to `meta docs` that renders the browsable HTML documentation site via @metaobjectsdev/docs-site, under `/site`. - `--site` is additive: combined with --model/--api it emits both the markdown surfaces AND the site; when it is the ONLY surface flag, markdown emission is suppressed (parseDocsArgs passes an explicit empty markdown surface list, which resolveDocsConfig honors via `??`). - The site has its OWN model loader (docs-site loadModel), so a site-only run returns before the sdk loadMemory/GenContext path — decoupled, one fewer failure surface, and no gen config required. - `--templates ` is threaded as the docs-site templatesDir override (the scaffold-and-own seam; a consumer template of the same basename wins). - Help text (top-level + `docs --help` FLAGS) documents --site/--model/--api/ --metamodel; cli.test.ts help snapshot updated for the one changed line. - docs-command.test.ts covers site-only (markdown suppressed), --site --model additive, and site determinism. CLI typecheck green; 361 pass / 0 fail. Co-Authored-By: Claude Opus 4.8 (1M context) --- bun.lock | 1 + server/typescript/packages/cli/package.json | 1 + .../packages/cli/src/commands/docs.ts | 72 +++++++++++++++++-- server/typescript/packages/cli/src/index.ts | 6 +- .../cli/test/__snapshots__/cli.test.ts.snap | 2 +- .../packages/cli/test/docs-command.test.ts | 53 ++++++++++++++ 6 files changed, 129 insertions(+), 6 deletions(-) diff --git a/bun.lock b/bun.lock index bb67c41ad..c336dec0b 100644 --- a/bun.lock +++ b/bun.lock @@ -148,6 +148,7 @@ "@metaobjectsdev/codegen-ts": "workspace:*", "@metaobjectsdev/codegen-ts-react": "workspace:*", "@metaobjectsdev/codegen-ts-tanstack": "workspace:*", + "@metaobjectsdev/docs-site": "workspace:*", "@metaobjectsdev/metadata": "workspace:*", "@metaobjectsdev/migrate-ts": "workspace:*", "@metaobjectsdev/render": "workspace:*", diff --git a/server/typescript/packages/cli/package.json b/server/typescript/packages/cli/package.json index d72c837cc..3dce31b46 100644 --- a/server/typescript/packages/cli/package.json +++ b/server/typescript/packages/cli/package.json @@ -52,6 +52,7 @@ "@metaobjectsdev/codegen-ts": "workspace:*", "@metaobjectsdev/codegen-ts-react": "workspace:*", "@metaobjectsdev/codegen-ts-tanstack": "workspace:*", + "@metaobjectsdev/docs-site": "workspace:*", "@metaobjectsdev/metadata": "workspace:*", "@metaobjectsdev/migrate-ts": "workspace:*", "@metaobjectsdev/render": "workspace:*", diff --git a/server/typescript/packages/cli/src/commands/docs.ts b/server/typescript/packages/cli/src/commands/docs.ts index 97fbf88e2..4ab54e410 100644 --- a/server/typescript/packages/cli/src/commands/docs.ts +++ b/server/typescript/packages/cli/src/commands/docs.ts @@ -11,7 +11,7 @@ // output is therefore guaranteed — it is byte-for-byte the same generator the // `meta gen` pipeline runs (gated by the docs conformance fixture). -import { resolve as resolvePath } from "node:path"; +import { resolve as resolvePath, basename } from "node:path"; import { mkdir, writeFile } from "node:fs/promises"; import { log } from "../lib/log.js"; import { loadMetaobjectsConfig } from "../lib/load-metaobjects-config.js"; @@ -33,6 +33,7 @@ import type { } from "@metaobjectsdev/codegen-ts"; import { docsFile, apiDocsFile } from "@metaobjectsdev/codegen-ts/generators"; import { composeRegistry, coreProviders, renderCoreMetamodelDocs } from "@metaobjectsdev/metadata"; +import { generateSite } from "@metaobjectsdev/docs-site"; type DocsLayout = "flat" | "package"; @@ -62,6 +63,11 @@ interface DocsFlags { /** FR-033 S3 — document the METAMODEL ITSELF (the built-in type/subtype/attr * vocabulary) instead of a user's entities. Needs NO metadata + NO config. */ metamodel: boolean; + /** Emit the browsable HTML documentation site (a `@metaobjectsdev/docs-site` + * surface) under `/site`. Additive to the markdown surfaces: when `--site` + * is the ONLY surface flag, markdown emission is suppressed; combined with + * `--model`/`--api`, both are emitted. */ + site: boolean; } function parseLayout(v: string | undefined, flag: string): DocsLayout { @@ -81,6 +87,7 @@ function parseDocsArgs(argv: string[], cwd: string): DocsFlags { let wantModel = false; let wantApi = false; let wantMetamodel = false; + let wantSite = false; let outProvided = false; let layoutProvided = false; for (let i = 0; i < argv.length; i++) { @@ -105,6 +112,8 @@ function parseDocsArgs(argv: string[], cwd: string): DocsFlags { wantApi = true; } else if (a === "--metamodel") { wantMetamodel = true; + } else if (a === "--site") { + wantSite = true; } else if (a === "--base-url") { const v = argv[++i]; if (v === undefined) throw new Error(`${a} requires a URL argument`); @@ -125,8 +134,12 @@ function parseDocsArgs(argv: string[], cwd: string): DocsFlags { throw new Error(`unexpected argument: ${a}`); } } - // --model and/or --api narrow the surfaces; both flags (or neither) leave the - // surfaces unset so the resolved `docs:` config decides (default both). + // --model and/or --api narrow the MARKDOWN surfaces; both flags (or neither) + // leave the surfaces unset so the resolved `docs:` config decides (default + // both). `--site` is a separate (additive) surface: when it is the ONLY + // surface flag we pass an explicit EMPTY markdown surface list so markdown + // emission is suppressed (resolveDocsConfig honors `[]` via `??`); combined + // with --model/--api the requested markdown surfaces still emit alongside it. const surfaces: DocsSurface[] = []; if (wantModel) surfaces.push("model"); if (wantApi) surfaces.push("api"); @@ -140,9 +153,10 @@ function parseDocsArgs(argv: string[], cwd: string): DocsFlags { // Default flat preserves today's single-package output (+ existing goldens). layout: layout ?? "flat", metamodel: wantMetamodel, + site: wantSite, outProvided, layoutProvided, - ...(surfaces.length > 0 ? { surfaces } : {}), + ...(surfaces.length > 0 || wantSite ? { surfaces } : {}), ...(baseUrl !== undefined ? { baseUrl } : {}), ...(templates !== undefined ? { templates } : {}), }; @@ -226,6 +240,15 @@ export async function docsCommand(args: string[], cwd: string): Promise ); const outDir = resolvePath(metaRoot, docsCfg.outDir); + // SITE surface has its OWN model loader (docs-site's loadModel — NOT the sdk + // loadMemory below) and needs no gen config. When the site is the ONLY + // requested surface (no markdown surfaces resolved), emit it and return + // WITHOUT building the markdown GenContext — decoupled and one fewer failure + // surface. Combined with --model/--api it is emitted after them (below). + if (flags.site && docsCfg.surfaces.length === 0) { + return emitSite(metaRoot, outDir, projectRoot, flags); + } + // Load metadata standalone — same loader path as migrate/gen. Threads any // consumer providers from the config so custom types resolve. let root; @@ -388,6 +411,12 @@ export async function docsCommand(args: string[], cwd: string): Promise return 1; } + // SITE surface (additive) — emit after the markdown surfaces so both coexist. + if (flags.site) { + const siteRc = await emitSite(metaRoot, outDir, projectRoot, flags); + if (siteRc !== 0) return siteRc; + } + // Summary: docsFile() emits ONE overview/index page (README.md) plus one page // per entity and one per template.output. The entity count is the matched // object count; the remaining non-overview model pages are template pages. @@ -406,6 +435,41 @@ export async function docsCommand(args: string[], cwd: string): Promise return 0; } +/** + * Emit the browsable HTML documentation site via `@metaobjectsdev/docs-site`. + * The site loads the model with its OWN loader from the metadata source dir + * (`/metaobjects`), so this is independent of the sdk loadMemory path + * used for the markdown surfaces. Writes under `/site` so it can coexist + * with the markdown output. `--templates ` (if given) is threaded as the + * template override dir (a consumer template of the same basename wins over the + * bundled one) — the scaffold-and-own seam. + */ +async function emitSite( + metaRoot: string, + outDir: string, + projectRoot: string, + flags: DocsFlags, +): Promise { + const siteOutDir = resolvePath(outDir, "site"); + const sourceDirs = [join(metaRoot, DEFAULT_METADATA_DIR)]; + try { + const r = await generateSite({ + sourceDirs, + outDir: siteOutDir, + title: basename(metaRoot) || "Metadata", + stamp: new Date().toISOString().slice(0, 10), + commit: "", + core: { n: 15 }, + ...(flags.templates !== undefined ? { templatesDir: projectRoot } : {}), + }); + log.info(`meta docs --site — wrote ${r.pages.length} page(s) → ${siteOutDir}`); + return 0; + } catch (err) { + log.error(`docs: failed to generate site: ${(err as Error).message}`); + return 1; + } +} + /** * FR-033 S3 — emit the metamodel reference docs (INDEX.md + per-type pages + * providers.md) from the BUILT-IN strict registry. No metadata, no config: diff --git a/server/typescript/packages/cli/src/index.ts b/server/typescript/packages/cli/src/index.ts index 7ab2f21b6..291809a79 100644 --- a/server/typescript/packages/cli/src/index.ts +++ b/server/typescript/packages/cli/src/index.ts @@ -19,7 +19,7 @@ COMMANDS: gen [...] Codegen TS targets from metaobjects/ entities types [query] Search the metadata vocabulary (types, subtypes, @attrs) by name or description export Flatten loaded metadata to one canonical JSON artifact - docs --out Generate neutral metadata documentation (entity + template pages) + docs --out Generate neutral metadata documentation (entity + template pages; --site for HTML site) verify Drift gate — subverbs: --templates / --db / --codegen (bare = --templates) prompt-snapshot Snapshot rendered template.* output; --check gates drift migrate Diff metadata vs live DB; emit migration SQL files @@ -142,6 +142,10 @@ USAGE: FLAGS: Project root holding metaobjects/ (default: current directory) --out , -o Output directory for the pages (default: ./docs) + --model Emit the markdown model surface (entity + template pages) + --api Emit the markdown api surface (generated SDK reference) + --metamodel Document the built-in metamodel vocabulary (no metadata needed) + --site Generate the browsable HTML documentation site (/site/) --templates Project root to resolve adopter templates/ overrides (default: ) --help, -h Print this help `, diff --git a/server/typescript/packages/cli/test/__snapshots__/cli.test.ts.snap b/server/typescript/packages/cli/test/__snapshots__/cli.test.ts.snap index 469d4272f..02d2a3dce 100644 --- a/server/typescript/packages/cli/test/__snapshots__/cli.test.ts.snap +++ b/server/typescript/packages/cli/test/__snapshots__/cli.test.ts.snap @@ -13,7 +13,7 @@ COMMANDS: gen [...] Codegen TS targets from metaobjects/ entities types [query] Search the metadata vocabulary (types, subtypes, @attrs) by name or description export Flatten loaded metadata to one canonical JSON artifact - docs --out Generate neutral metadata documentation (entity + template pages) + docs --out Generate neutral metadata documentation (entity + template pages; --site for HTML site) verify Drift gate — subverbs: --templates / --db / --codegen (bare = --templates) prompt-snapshot Snapshot rendered template.* output; --check gates drift migrate Diff metadata vs live DB; emit migration SQL files diff --git a/server/typescript/packages/cli/test/docs-command.test.ts b/server/typescript/packages/cli/test/docs-command.test.ts index 5ada891bc..2aed1569d 100644 --- a/server/typescript/packages/cli/test/docs-command.test.ts +++ b/server/typescript/packages/cli/test/docs-command.test.ts @@ -385,3 +385,56 @@ describe("meta docs — standalone neutral metadata docs", () => { expect(code).toBe(2); // arg parse error }); }); + +describe("meta docs --site — HTML documentation site", () => { + test("emits a browsable site under /site and SUPPRESSES markdown when --site is the only surface", async () => { + const root = await project(); + const out = join(root, "out-site-only"); + + const code = await docsCommand([root, "--site", "--out", out], root); + expect(code).toBe(0); + + // The site lands under /site with its chrome + assets. + expect(existsSync(join(out, "site", "index.html"))).toBe(true); + expect(existsSync(join(out, "site", "assets", "site.css"))).toBe(true); + expect(existsSync(join(out, "site", "assets", "site.js"))).toBe(true); + + // --site alone is NOT additive-with-markdown: no markdown pages at the root. + const rootEntries = (await readdir(out)).sort(); + expect(rootEntries).toContain("site"); + expect(rootEntries).not.toContain("Welcome.md"); + expect(rootEntries).not.toContain("README.md"); + + // Summary line names the site surface + destination. + const summary = logged.join("\n"); + expect(summary).toMatch(/meta docs --site — wrote \d+ page/); + expect(summary).toContain(join(out, "site")); + }); + + test("--site is ADDITIVE with --model: emits BOTH the markdown pages and the site", async () => { + const root = await project(); + const out = join(root, "out-site-model"); + + const code = await docsCommand([root, "--site", "--model", "--out", out], root); + expect(code).toBe(0); + + // Markdown surface still emits at the root... + expect(existsSync(join(out, "Welcome.md"))).toBe(true); + expect(existsSync(join(out, "README.md"))).toBe(true); + // ...alongside the site under /site. + expect(existsSync(join(out, "site", "index.html"))).toBe(true); + }); + + test("site output is deterministic across regenerations (byte-identical index.html)", async () => { + const root = await project(); + const out1 = join(root, "out-site-det-1"); + const out2 = join(root, "out-site-det-2"); + + expect(await docsCommand([root, "--site", "--out", out1], root)).toBe(0); + expect(await docsCommand([root, "--site", "--out", out2], root)).toBe(0); + + const a = await readFile(join(out1, "site", "index.html"), "utf8"); + const b = await readFile(join(out2, "site", "index.html"), "utf8"); + expect(a).toBe(b); + }); +}); From 76a93f36fac309d1e5fc44aded6158a3b5d52770 Mon Sep 17 00:00:00 2001 From: Doug Mealing Date: Sat, 4 Jul 2026 13:05:33 -0400 Subject: [PATCH 06/22] =?UTF-8?q?docs(spec):=20docs-site=20Phase=202=20?= =?UTF-8?q?=E2=80=94=20consolidate=20the=20graph=20onto=20the=20shared=20r?= =?UTF-8?q?elationship=20IR?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Design for re-sourcing the site's relationship edges from the metadata relationship PRIMITIVES (deriveM2MFields / findReferenceBetween / MetaRelationship) rather than the lossy buildApiModel output or the Drizzle-shaped buildRelationMap. Keeps LinkGraph as the graph shell; enriches relationship edges (M:N-through-junction with the junction kept as a node, belongs-to direction, directed + symmetric self-joins, onDelete); makes structural edges inheritance-aware via resolved accessors; dedupes relationship-vs-bare-FK. Extends the acme fixture (golden regenerates); additive mermaid connector/dashed-M:N rendering; presentation + gates preserved. Co-Authored-By: Claude Opus 4.8 (1M context) --- .../2026-07-04-docs-site-phase2-design.md | 216 ++++++++++++++++++ 1 file changed, 216 insertions(+) create mode 100644 docs/superpowers/specs/2026-07-04-docs-site-phase2-design.md diff --git a/docs/superpowers/specs/2026-07-04-docs-site-phase2-design.md b/docs/superpowers/specs/2026-07-04-docs-site-phase2-design.md new file mode 100644 index 000000000..64c2d7eb9 --- /dev/null +++ b/docs/superpowers/specs/2026-07-04-docs-site-phase2-design.md @@ -0,0 +1,216 @@ +# Docs Site — Phase 2 Design: Consolidate the Graph onto the Shared Relationship IR + +**Status:** approved · **Date:** 2026-07-04 · **Branch:** `feat/docs-site` +**Predecessor:** [Phase 1 — port as `meta docs --site`](2026-07-04-docs-site-design.md) (shipped) + +## Problem + +The docs-site's diagram edges come from a hand-rolled graph, `LinkGraph` +(`docs-site/src/link-graph.ts`), whose relationship edges are a shallow +`relationship.attr("objectRef") + cardinality` walk. It is blind to what the +metamodel actually models: M:N-through-junction (`@through`), belongs-to vs +has-many direction, directed (`@sourceRefField`) and symmetric (`@symmetric`) +self-joins, `@onDelete`, and the relationship subtype. A junction table shows +only as a plain entity with two FK edges — the logical M:N between its two ends +is invisible. And where an entity declares both a belongs-to `relationship` and +its underlying `identity.reference` FK, the graph emits **two parallel edges** +for the same physical link. + +(Inheritance is **not** a gap: the structural walks already go through +`childrenOfType` → `children()`, which resolves the `extends` chain, so +inherited FKs/fields/relationships already appear. The gaps are relationship +*semantics* and edge *de-duplication*.) + +## Goal / Non-goals + +**Goal:** re-source the site's *relationship* edges from the shared derivation +SSOT so the diagrams cover every relationship the metamodel supports (M:N through +junction, belongs-to direction, directed + symmetric self-joins, `@onDelete`), +and de-duplicate the relationship-vs-bare-FK double edge — while keeping the +presentation layer (templates, assets, mermaid theming, kind-shape/domain-color +doctrine, page structure) and the neutral raw-metadata reads (fields, indexes, +validators, identities) intact. Preserve the determinism + link-check + golden +gates. + +**Non-goals:** no template/asset redesign; no change to the markdown +`--model`/`--api`/`--metamodel` surfaces or `mermaid-er`; no new page kinds; no +runtime component. `buildRelationMap`/`buildApiModel` in codegen-ts are left +as-is (they serve codegen); we reuse the primitives *under* them. + +## Key finding — consume the primitives, not the lossy output + +The Phase-2 framing ("source edges from the relations IR that `buildApiModel` +exposes") does not survive contact with the code: + +- **`buildApiModel`'s output is lossy** — `relationNavField` (api-model.ts) + flattens relationships into display strings and drops `@onDelete`, + `symmetric`, the junction FK fields, and the relationship subtype. +- **`buildRelationMap`'s output (`RelationEntry`) doesn't fit** — it is shaped + for Drizzle codegen: keyed by bare entity name, skips projections, and models + **only declared `relationship` children (+ junction sides)**. It emits nothing + for a bare `identity.reference` FK, and nothing for the structural + `field`/`extends`/`origin`/`payload` edges the site's diagrams need. A model + with FKs but no `relationship` children yields an **empty** `RelationMap`. + +The genuinely shared single-source-of-truth is one layer down — the derivation +**primitives** in `@metaobjectsdev/metadata/core/relationship/`, which +`buildRelationMap` itself consumes and which are barrel-exported from +`@metaobjectsdev/metadata` (a package docs-site **already depends on**): + +- `deriveM2MFields(rel, source, root): M2MFields` — junction-FK derivation + (hetero / directed `@sourceRefField` / `@symmetric`). +- `findReferenceBetween(a, b): ReferenceLookup | undefined` — which side + physically holds the FK (direction). +- `MetaRelationship` getters — `through`, `symmetric`, `onDelete`, + `cardinality`, subtype. + +So we consume those primitives directly. This satisfies "consolidate onto the +shared derivation / new relationship types flow into docs automatically" at the +correct (primitive) layer, with **no new package coupling** and **no lifting +code across packages**. + +## Architecture + +Keep `LinkGraph` as the graph *structure* — nodes, `from`/`to` indices, +`relHref`, `ancestors`, `extendedBy`, and the entire public query API the +builders depend on (`refsFrom`/`refsTo`/`relationshipsOf`/…). Replace only the +**relationship-edge derivation** inside its constructor, and add the +relationship-vs-FK dedupe. The structural walks (`field`/`fk`/`origin`/`extends`/ +`payload`) are left as-is — they already resolve the `extends` chain via +`childrenOfType` → `children()`. This is a hybrid: enriched relationship edges +from the shared primitives; structural edges unchanged. + +### Edge model (`Ref`) — additive + +```ts +interface Ref { + from, to, via, kind; // unchanged + cardinality?: "one" | "many"; // normalized (was a free string) + through?: string; // junction FQN ┐ + sourceJoinField?: string; // junction source FK │ M:N + targetJoinField?: string; // junction target FK │ + symmetric?: boolean; // ┘ + onDelete?: string; // referential action + subtype?: string; // association / aggregation / composition +} +``` + +### Edge-source table (the hybrid) + +| kind | Phase-2 source | new data | +|---|---|---| +| `relationship` belongs-to (1:N) | `relationships()` (card `one`) + `referenceIdentities()` to match the FK field (dedupe) | normalized cardinality, `onDelete`, subtype | +| `relationship` **M:N** (`many` + `through`) | `relationships()` + `deriveM2MFields` | `through`, `sourceJoinField`, `targetJoinField`, `symmetric`, `onDelete` | +| `fk` (bare `identity.reference`) | `childrenOfType("identity")` — unchanged | none (dropped when a relationship covers the same FK) | +| `field` (composition, `field.objectRef`) | `childrenOfType("field")` — unchanged | none | +| `extends` / `origin` / `payload` | `superResolved` / origin children / `payloadRef` — unchanged | none | + +### Dedupe: relationship wins over the bare FK + +A declared belongs-to `relationship` and its underlying `identity.reference` +describe the same physical link; today `LinkGraph` emits **two parallel edges** +for that pair. Phase 2 dedupes: when a relationship edge's `fkField` matches an +`identity.reference` field (same `from`→`to`), the richer relationship edge wins +and the bare `fk` edge is suppressed. The M:N junction's own two FK edges are +different node-pairs, so they remain (consistent with keeping the junction node). + +### Junction rendering — keep the node + add the M:N edge + +Decision: a junction entity stays as its own node with its two FK edges (it has +its own page and FK structure — hiding it on some diagrams would misrepresent +the schema), **and** a distinct logical M:N edge is added between the two ends. +This is purely additive — lowest risk to the "presentation unchanged except +newly-covered edges" constraint. (If overview/core diagrams later get noisy with +junction boxes, collapsing them *only on the core diagram* is a clean, small +follow-up — YAGNI until observed.) + +### Presentation (`mermaid.ts`) — confined + additive + +- `erDiagramRich` picks the mermaid relationship connector from the edge's + `cardinality`: M:N → `}o--o{`; belongs-to / everything else → the existing + `||--o{`. Non-M:N edges therefore render **byte-identically**. +- `flowchartDomain` draws the logical M:N edge **dashed** (`-.->|M:N via X|`) + against the solid physical FK edges — the "logical vs physical" cue in + mermaid's own vocabulary, no color hack. +- `onDelete` + junction fold into the edge **label** (`author · cascade`, + `M:N via OrderProduct`) — no new colors, no new glyphs. +- `ErEdge` and the flowchart edge input gain optional carry fields + (`cardinality`, `style`) — additive; existing call sites compile unchanged. +- Kind-shape (▭/⬭/▱ + border-dash) and domain-color doctrine untouched. + +Net: for a model with **no** relationship children, output is byte-identical to +Phase 1. The acme golden changes **only** because we deliberately add +relationships to it. + +## Testing & fixture + +**Extend the acme fixture** (its golden regenerates — the diff is the review +artifact showing exactly which new edges appeared). Add, minimally: + +- **belongs-to + `onDelete`** — a `relationship` child (cardinality `one`) on an + entity that already holds the matching FK (e.g. `Order`→`Customer`), with + `@onDelete: cascade`. +- **M:N through junction (hetero)** — a `Product` entity + an `OrderProduct` + junction (two `identity.reference` FKs) + a `many @through OrderProduct` + relationship `Order ⇄ Product`. +- **directed self-join** — `Customer.referredBy → Customer` via `@sourceRefField`. +- **symmetric self-join** — `Customer ⇄ Customer` through a `CustomerFriend` + junction with `@symmetric`. + +(The dedupe case is already present: `Order` declares both `identity.reference +fkCustomer` **and** `relationship.association customer` → the enriched +relationship edge now supersedes the bare FK edge, changing the neighborhood +label from `customerId` to the relationship.) + +**Gates:** double-generate byte-identical golden · link-check throws on dangling +· every new edge/attr list **sorted** (determinism) · escaping unchanged (new +labels are identifier/vocab-derived, still pass `safe()`/`edgeLabel()`). + +**Unit tests** (extend existing): relationship-vs-fk dedupe; M:N edge carries +`through`/`sourceJoinField`/`targetJoinField`; directed + symmetric self-join +edges; `onDelete`/subtype carried; `erDiagramRich` emits `}o--o{` for M:N; +`flowchartDomain` emits the dashed M:N link. + +## Hygiene fold-in + +We are already editing `mermaid.ts`: genericize the `CURATED` palette **keys** +(domain-specific vocabulary carried over from the source project — a soft leak +in a public repo) to neutral slot names. acme's packages (`ai`/`common`/`shop`) match +none of them, so they hash into the same palette **values** either way — **zero +golden-color impact**, and the leak is gone. + +## File-by-file + +| File | Change | +|---|---| +| `src/link-graph.ts` | Primitive-based relationship derivation (`deriveM2MFields` + `MetaRelationship` getters); relationship-vs-fk dedupe; extend `Ref` (structural walks unchanged) | +| `src/mermaid.ts` | Cardinality connector in `erDiagramRich`; dashed M:N in `flowchartDomain`; additive `ErEdge`/edge-input fields; genericize `CURATED` keys | +| `src/builders/object-data.ts`, `index-data.ts` | Thread `cardinality`/`through`/`onDelete` into edge labels + the relations table | +| `test/fixture/input/acme/**` | The additions above | +| `test/fixture/golden/**` | Regenerate | +| `test/{link-graph,mermaid,graph-v2,object-data*}.test.ts` | New assertions | + +## Success criteria + +- The site's neighborhood + core diagrams render M:N-through-junction (with the + junction kept as a node), belongs-to cardinality, and directed + symmetric + self-join relationships; the relationship-vs-FK double edge is de-duplicated. +- The extended acme golden is regenerated, double-generate byte-identical, + link-check green; unit tests above pass; `bun test docs-site` green. +- A model with no relationship children produces byte-identical output to Phase 1 + (the change is edge-coverage, not a presentation rewrite). +- No new package dependency; no private-name or absolute-local-path leak; + `CURATED` keys genericized. + +## Risks + +- **Dedupe correctness** — matching a relationship's `fkField` to the right + `identity.reference` must be exact (package-stripped compare, as + `relation-resolver.ts` does) or an edge is wrongly dropped/kept. Covered by a + dedicated unit test. +- **`deriveM2MFields` throws** on an ambiguous self-join (neither + `@sourceRefField` nor `@symmetric`). The site must **skip that edge and warn**, + never fail generation — mirror the resolver's silent-skip, but surface a + `LoadedModel.warnings` entry so the anomaly page can list it. +- **Golden churn** — the acme golden diff will be non-trivial; review it as the + artifact, and re-lock determinism (two regenerations identical) before commit. From 24d56e4403799090596011e8a991321cc52a7cbf Mon Sep 17 00:00:00 2001 From: Doug Mealing Date: Sat, 4 Jul 2026 13:19:16 -0400 Subject: [PATCH 07/22] docs(plan): docs-site Phase 2 implementation plan (6 tasks, TDD) Task-by-task plan: extend the acme fixture (M:N + directed/symmetric self-joins + onDelete); extend Ref + belongs-to enrichment + relationship-vs-FK dedupe; M:N-through-junction edges via deriveM2MFields; }o--o{ / dashed-flowchart rendering with junction kept as a node; genericize CURATED palette keys; full verification. Golden regenerated per output-affecting task; determinism + link-check + typecheck gates at each step. Co-Authored-By: Claude Opus 4.8 (1M context) --- .../plans/2026-07-04-docs-site-phase2.md | 638 ++++++++++++++++++ 1 file changed, 638 insertions(+) create mode 100644 docs/superpowers/plans/2026-07-04-docs-site-phase2.md diff --git a/docs/superpowers/plans/2026-07-04-docs-site-phase2.md b/docs/superpowers/plans/2026-07-04-docs-site-phase2.md new file mode 100644 index 000000000..b1f8956e2 --- /dev/null +++ b/docs/superpowers/plans/2026-07-04-docs-site-phase2.md @@ -0,0 +1,638 @@ +# Docs Site — Phase 2 (Shared Relationship IR) Implementation Plan + +> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. + +**Goal:** Re-source the docs-site's *relationship* diagram edges from the `@metaobjectsdev/metadata` relationship primitives so the site covers M:N-through-junction, directed/symmetric self-joins, belongs-to cardinality, and `@onDelete`, and de-duplicate the relationship-vs-bare-FK double edge — with the presentation layer and gates preserved. + +**Architecture:** Keep `LinkGraph` as the graph shell (nodes, from/to indices, query API). Replace only its relationship-edge derivation: read `MetaRelationship` getters (`cardinality`/`objectRef`/`through`/`symmetric`/`onDelete`) and derive M:N junction FK fields via `deriveM2MFields` (the SSOT). Suppress the bare `fk` edge when a belongs-to relationship covers the same reference. Structural edges (`fk`/`field`/`origin`/`extends`/`payload`) are unchanged — they already resolve the `extends` chain via `childrenOfType` → `children()`. Render the new M:N edge with mermaid's `}o--o{` (ER) / dashed link (flowchart). + +**Tech Stack:** bun, TypeScript (monorepo `tsconfig.base.json`), `@metaobjectsdev/metadata` (relationship primitives), `@metaobjectsdev/render`, mustache, mermaid. + +**Spec:** `docs/superpowers/specs/2026-07-04-docs-site-phase2-design.md` + +## Global Constraints + +- **Public repo hygiene:** no private/downstream project names, no `/home/…` absolute paths, in code, tests, fixtures, or commit messages. A commit guard enforces a private-name denylist on every commit; also `grep -rniE "/home/"` the touched files (and eyeball for stray project names) before committing. +- **No new package dependency:** all primitives (`deriveM2MFields`, `MetaRelationship`, `stripPackage`, `MetaObject`) are barrel-exported from `@metaobjectsdev/metadata`, which docs-site already depends on. Do **not** add `@metaobjectsdev/codegen-ts`. +- **Determinism:** every emitted edge/attr list sorts; no Map/Set insertion-order leakage. The golden must be byte-identical across two regenerations. +- **Link-check:** `generateSite` throws on any dangling link — regeneration failing IS a test failure. +- **Escaping invariant:** new edge-label text (relationship name, junction name, `onDelete` value) still passes through the existing `safe()` (mermaid) / `edgeLabel()` (flowchart) / `esc()` (HTML) — never inject raw text into a slot. +- **`erDiagramRich` connector rule:** M:N (`cardinality === "many"`) → `}o--o{`; everything else → the existing `||--o{`. This keeps non-M:N output byte-identical. + +### Regenerating the golden (used by several tasks) + +From `server/typescript/packages/docs-site/`: + +```bash +bun -e 'import {generateSite} from "./src/site.ts"; import {rmSync} from "node:fs"; rmSync("test/fixture/golden",{recursive:true,force:true}); await generateSite({sourceDirs:["test/fixture/input/acme"],outDir:"test/fixture/golden",title:"Fixture",stamp:"2026-01-01",commit:"abc1234"}); console.log("golden regenerated");' +``` + +This mirrors `test/golden.test.ts` exactly (same `title`/`stamp`/`commit`). After regenerating, `bun test test/golden.test.ts` must pass, and running it **twice** confirms determinism. If the regen command throws, a link is dangling — fix the derivation, do not hand-edit the golden. + +--- + +## File Structure + +- `src/link-graph.ts` — MODIFY: extend `Ref`; rewrite the relationship-edge derivation; add dedupe. (~30 lines changed in the constructor + the `Ref` interface.) +- `src/mermaid.ts` — MODIFY: `ErEdge` gains `cardinality?`; `erDiagramRich` connector by cardinality; `flowchartDomain` edge input gains `style?`, dashed link; genericize `CURATED` keys. +- `src/builders/object-data.ts` — MODIFY: thread `cardinality`/`through`/`onDelete` into the neighborhood `ErEdge`/flowchart edges + labels. +- `src/builders/index-data.ts` — MODIFY: thread `cardinality`/`style` into the hero/core edges. +- `test/fixture/input/acme/shop/meta.shop.yaml` — MODIFY: add M:N + self-join entities/relationships + `@onDelete`. +- `test/fixture/golden/**` — REGENERATE. +- `test/link-graph.test.ts`, `test/mermaid.test.ts` — MODIFY: new assertions. + +--- + +## Task 1: Extend the acme fixture (M:N, self-joins, onDelete) + +**Files:** +- Modify: `test/fixture/input/acme/shop/meta.shop.yaml` +- Modify: `test/fixture/golden/**` (regenerate) +- Test: `test/link-graph.test.ts` (add a load/presence assertion) + +**Interfaces:** +- Produces: fixture entities `Product`, `OrderProduct`, `CustomerReferral`, `CustomerFriend`; relationships `Order.customer` (now with `@onDelete: cascade`), `Order.products` (M:N via `OrderProduct`), `Customer.referrals` (directed self-join via `CustomerReferral`, `@sourceRefField: referrerId`), `Customer.friends` (symmetric self-join via `CustomerFriend`). + +- [ ] **Step 1: Add the M:N + self-join entities.** In `meta.shop.yaml`, after the `OrphanLog` entity (before the `LineItemView` projection), add: + +```yaml + - object.entity: + name: Product + extends: acme::common::BaseEntity + children: + - field.string: { name: sku, required: true, maxLength: 40 } + - source.rdb: { table: products } + - object.entity: + name: OrderProduct + extends: acme::common::BaseEntity + children: + - field.string: { name: orderId, maxLength: 36 } + - field.string: { name: productId, maxLength: 36 } + - identity.reference: { name: fkOrder, fields: orderId, references: acme::shop::Order } + - identity.reference: { name: fkProduct, fields: productId, references: acme::shop::Product } + - source.rdb: { table: order_products } + - object.entity: + name: CustomerReferral + extends: acme::common::BaseEntity + children: + - field.string: { name: referrerId, maxLength: 36 } + - field.string: { name: referredId, maxLength: 36 } + - identity.reference: { name: fkReferrer, fields: referrerId, references: acme::shop::Customer } + - identity.reference: { name: fkReferred, fields: referredId, references: acme::shop::Customer } + - source.rdb: { table: customer_referrals } + - object.entity: + name: CustomerFriend + extends: acme::common::BaseEntity + children: + - field.string: { name: friendAId, maxLength: 36 } + - field.string: { name: friendBId, maxLength: 36 } + - identity.reference: { name: fkFriendA, fields: friendAId, references: acme::shop::Customer } + - identity.reference: { name: fkFriendB, fields: friendBId, references: acme::shop::Customer } + - source.rdb: { table: customer_friends } +``` + +- [ ] **Step 2: Add `@onDelete` to the existing belongs-to + the new M:N relationships.** In `meta.shop.yaml`, replace the `Order` `customer` relationship line: + +```yaml + - relationship.association: { name: customer, "@objectRef": "acme::shop::Customer", cardinality: one } +``` +with (adds `@onDelete` and the M:N `products` relationship right after it): +```yaml + - relationship.association: { name: customer, "@objectRef": "acme::shop::Customer", cardinality: one, "@onDelete": cascade } + - relationship.association: { name: products, "@objectRef": "acme::shop::Product", cardinality: many, "@through": "acme::shop::OrderProduct" } +``` + +Then add the two self-join relationships to `Customer` — replace: +```yaml + name: Customer + extends: acme::common::BaseEntity + children: + - field.string: { name: email, required: true, maxLength: 120 } + - source.rdb: { table: customers } +``` +with: +```yaml + name: Customer + extends: acme::common::BaseEntity + children: + - field.string: { name: email, required: true, maxLength: 120 } + - relationship.association: { name: referrals, "@objectRef": "acme::shop::Customer", cardinality: many, "@through": "acme::shop::CustomerReferral", "@sourceRefField": referrerId } + - relationship.association: { name: friends, "@objectRef": "acme::shop::Customer", cardinality: many, "@through": "acme::shop::CustomerFriend", "@symmetric": true } + - source.rdb: { table: customers } +``` + +- [ ] **Step 3: Write the load/presence test.** In `test/link-graph.test.ts`, add: + +```ts +test("phase-2 fixture loads the M:N + self-join entities", async () => { + const model = await loadModel([join(FIX, "acme")]); + const g = new LinkGraph(model); + for (const fqn of ["acme::shop::Product", "acme::shop::OrderProduct", "acme::shop::CustomerReferral", "acme::shop::CustomerFriend"]) { + expect(g.byFqn(fqn), fqn).toBeDefined(); + } +}); +``` + +- [ ] **Step 4: Run the load test.** Run: `cd server/typescript/packages/docs-site && bun test test/link-graph.test.ts -t "phase-2 fixture loads"`. Expected: PASS. + +- [ ] **Step 5: Regenerate the golden + confirm determinism.** From `server/typescript/packages/docs-site/`, run the regen command (see "Regenerating the golden"). Then: + +```bash +bun test test/golden.test.ts +bun test test/golden.test.ts # run twice — both must pass (determinism) +``` +Expected: PASS both times. (The golden now has new pages `acme/shop/Product.html`, `OrderProduct.html`, `CustomerReferral.html`, `CustomerFriend.html`, and the graph gains shallow relationship edges — enriched in later tasks.) + +- [ ] **Step 6: Hygiene + commit.** + +```bash +grep -rniE "/home/" test/fixture/input/acme/shop/meta.shop.yaml && echo LEAK || echo clean +git add test/ +git commit -m "test(docs-site): extend acme fixture with M:N + directed/symmetric self-joins + onDelete" +``` + +--- + +## Task 2: Extend `Ref`, enrich belongs-to, dedupe the relationship-vs-FK edge + +**Files:** +- Modify: `src/link-graph.ts` (the `Ref` interface + the `kind === "object"` derivation block) +- Modify: `test/link-graph.test.ts` +- Modify: `test/fixture/golden/**` (regenerate) + +**Interfaces:** +- Produces: `Ref` extended with `cardinality?: "one" | "many"; through?: string; sourceJoinField?: string; targetJoinField?: string; symmetric?: boolean; onDelete?: string; subtype?: string;`. Belongs-to relationship edges carry normalized `cardinality`, `onDelete`, `subtype`; the bare `fk` edge for the same reference is suppressed. +- Consumes: fixture from Task 1. + +- [ ] **Step 1: Extend the `Ref` interface.** In `src/link-graph.ts`, replace: + +```ts +export interface Ref { from: string; to: string; via: string; kind: "field" | "fk" | "extends" | "payload" | "relationship" | "origin"; cardinality?: string; } +``` +with: +```ts +export interface Ref { + from: string; to: string; via: string; + kind: "field" | "fk" | "extends" | "payload" | "relationship" | "origin"; + cardinality?: "one" | "many" | undefined; + through?: string | undefined; // junction FQN (M:N) + sourceJoinField?: string | undefined; // junction source FK (M:N) + targetJoinField?: string | undefined; // junction target FK (M:N) + symmetric?: boolean | undefined; // undirected self-join (M:N) + onDelete?: string | undefined; // referential action + subtype?: string | undefined; // association / aggregation / composition +} +``` + +- [ ] **Step 2: Import the metadata primitives.** In `src/link-graph.ts`, replace the top import: + +```ts +import type { MetaData } from "@metaobjectsdev/metadata"; +``` +with: +```ts +import type { MetaData, MetaObject, MetaRelationship } from "@metaobjectsdev/metadata"; +import { deriveM2MFields, stripPackage } from "@metaobjectsdev/metadata"; +``` +(`deriveM2MFields` is used in Task 3; importing it now keeps the import block stable.) + +- [ ] **Step 3: Write the failing tests.** In `test/link-graph.test.ts`, add: + +```ts +test("belongs-to relationship edge carries cardinality/onDelete/subtype and supersedes the bare FK", async () => { + const model = await loadModel([join(FIX, "acme")]); + const g = new LinkGraph(model); + const toCustomer = g.refsFrom("acme::shop::Order").filter((r) => r.to === "acme::shop::Customer"); + // the FK edge is de-duplicated away; only the enriched relationship edge remains + expect(toCustomer.map((r) => r.kind)).toEqual(["relationship"]); + const rel = toCustomer[0]!; + expect(rel.cardinality).toBe("one"); + expect(rel.onDelete).toBe("cascade"); + expect(rel.subtype).toBe("association"); + expect(rel.via).toBe("customer"); +}); +``` +Also UPDATE the existing assertion in the "builds nodes, refs, backlinks, degree, hrefs" test — the FK edge to Customer is now superseded, and Customer gains the two self-join relationships from Customer itself is not a backlink target of those (they point Customer→Customer). Replace: +```ts + // FK ref Order -> Customer via customerId + expect(g.refsFrom("acme::shop::Order").some((r) => r.to === "acme::shop::Customer" && r.kind === "fk")).toBe(true); +``` +```ts + // backlink + degree: 3 refs (Order fk + Order relationship + LineItemView origin-passthrough) + expect(g.refsTo("acme::shop::Customer").length).toBe(3); + expect(g.degree("acme::shop::Customer")).toBe(3); +``` +with: +```ts + // Order -> Customer is now the enriched relationship edge (the bare FK is de-duped away) + expect(g.refsFrom("acme::shop::Order").some((r) => r.to === "acme::shop::Customer" && r.kind === "relationship")).toBe(true); + expect(g.refsFrom("acme::shop::Order").some((r) => r.to === "acme::shop::Customer" && r.kind === "fk")).toBe(false); + // backlinks to Customer: Order.customer (relationship) + LineItemView origin-passthrough + // + the self-join junction FKs (CustomerReferral x2, CustomerFriend x2) + the self relationships (referrals, friends) + expect(g.refsTo("acme::shop::Customer").some((r) => r.from === "acme::shop::Order" && r.kind === "relationship")).toBe(true); + expect(g.refsTo("acme::shop::Customer").some((r) => r.from === "acme::shop::Order" && r.kind === "fk")).toBe(false); +``` + +- [ ] **Step 4: Run to verify failure.** Run: `bun test test/link-graph.test.ts`. Expected: FAIL (edges still `fk`; `cardinality`/`onDelete`/`subtype` undefined). + +- [ ] **Step 5: Rewrite the relationship + FK derivation.** In `src/link-graph.ts`, inside `if (dn.kind === "object") { … }`, the current order is: field(objectRef) → identity(fk) → relationship → field(origin) → extends. Replace the **identity(fk)** block and the **relationship** block with a single relationship-first + dedupe sequence. Specifically, replace: + +```ts + for (const id of dn.node.childrenOfType("identity")) { + if (id.subType !== "reference") continue; + const ref = id.attr("references"); + if (typeof ref === "string") { + const to = resolveRef(ref, dn.pkg); + if (to) { + const fieldsValue = id.attr("fields") ?? id.name; + const via = Array.isArray(fieldsValue) ? fieldsValue.join(", ") : String(fieldsValue); + addRef({ from: fqn, to, via, kind: "fk" }); + } + } + } + for (const rel of dn.node.childrenOfType("relationship")) { + const ref = rel.attr("objectRef"); + if (typeof ref === "string") { + const to = resolveRef(ref, dn.pkg); + if (to) addRef({ from: fqn, to, via: rel.name, kind: "relationship", cardinality: String(rel.attr("cardinality") ?? "") }); + } + } +``` +with: +```ts + const obj = dn.node as unknown as MetaObject; + // Relationship edges FIRST, so we can suppress the bare FK edge a belongs-to + // relationship supersedes. Dedupe is keyed by `${targetFqn}::${fkField}` + // (a string, robust to node-instance identity) — the FK loop skips any + // reference whose (target, first-field) a belongs-to relationship covered. + const coveredFk = new Set(); + for (const rel of obj.relationships()) { + const objectRef = rel.objectRef; + if (typeof objectRef !== "string") continue; + const to = resolveRef(objectRef, dn.pkg); + if (!to) continue; + const cardinality = rel.cardinality === "many" ? "many" : rel.cardinality === "one" ? "one" : undefined; + const onDelete = rel.onDelete; + const subtype = rel.subType; + if (cardinality === "many" && rel.through !== undefined) { + addM2mEdge(fqn, to, rel, obj, dn.pkg, onDelete, subtype); // Task 3 helper + continue; + } + // belongs-to (1:N, one) — find the matching identity.reference to dedupe (mirrors + // relation-resolver: first reference whose target matches, package-stripped). + const target = stripPackage(objectRef); + const match = obj.referenceIdentities().find((r) => stripPackage(r.targetEntity ?? "") === target); + const fkField = match?.fields?.[0]; + if (fkField) coveredFk.add(`${to}::${fkField}`); + addRef({ from: fqn, to, via: rel.name, kind: "relationship", cardinality, onDelete, subtype }); + } + for (const id of dn.node.childrenOfType("identity")) { + if (id.subType !== "reference") continue; + const ref = id.attr("references"); + if (typeof ref === "string") { + const to = resolveRef(ref, dn.pkg); + if (to) { + const fieldsValue = id.attr("fields") ?? id.name; + const firstField = Array.isArray(fieldsValue) ? String(fieldsValue[0] ?? "") : String(fieldsValue); + if (coveredFk.has(`${to}::${firstField}`)) continue; // superseded by a relationship edge + const via = Array.isArray(fieldsValue) ? fieldsValue.join(", ") : String(fieldsValue); + addRef({ from: fqn, to, via, kind: "fk" }); + } + } + } +``` + +Note: dedupe is keyed by the `${targetFqn}::${fkField}` string, so it does not depend on `referenceIdentities()` and `childrenOfType("identity")` returning the same node instances. `addM2mEdge` is defined in Task 3; add a temporary stub NOW so this compiles: + +```ts + // TEMP stub — replaced in Task 3. For now, emit a plain (un-derived) M:N edge. + const addM2mEdge = (from: string, to: string, rel: MetaRelationship, _obj: MetaObject, _pkg: string, onDelete: string | undefined, subtype: string | undefined): void => { + addRef({ from, to, via: rel.name, kind: "relationship", cardinality: "many", through: rel.through, symmetric: rel.symmetric, onDelete, subtype }); + }; +``` +Place this `addM2mEdge` declaration next to `addRef`/`resolveRef` (before the `for (const dn of this._nodes.values())` loop) so it is in scope. + +- [ ] **Step 6: Run to verify pass.** Run: `bun test test/link-graph.test.ts`. Expected: PASS (all tests, including the updated backlink assertions). + +- [ ] **Step 7: Regenerate the golden + determinism.** Run the regen command, then `bun test test/golden.test.ts` twice. Expected: PASS both. (Golden diff: Order's neighborhood edge to Customer flips from the `customerId` FK label to the `customer` relationship; the relations table gains `onDelete`.) + +- [ ] **Step 8: Typecheck + commit.** + +```bash +bun run typecheck +grep -rniE "/home/" src/link-graph.ts && echo LEAK || echo clean +git add src/link-graph.ts test/link-graph.test.ts test/fixture/golden +git commit -m "feat(docs-site): enrich belongs-to relationship edges (cardinality/onDelete/subtype) + dedupe the bare FK" +``` +Expected: typecheck clean. + +--- + +## Task 3: M:N-through-junction edges via `deriveM2MFields` (hetero + directed + symmetric) + +**Files:** +- Modify: `src/link-graph.ts` (replace the `addM2mEdge` stub with the real derivation) +- Modify: `test/link-graph.test.ts` +- Modify: `test/fixture/golden/**` (regenerate) + +**Interfaces:** +- Consumes: `deriveM2MFields(rel, source, root): { sourceField, targetField }`, `MetaRelationship` (`through`/`symmetric`), the `Ref` M:N fields from Task 2. +- Produces: M:N relationship edges carrying `through` (junction FQN), `sourceJoinField`, `targetJoinField`, `symmetric`. A derivation failure skips the edge (never throws out of generation). + +- [ ] **Step 1: Write the failing tests.** In `test/link-graph.test.ts`, add: + +```ts +test("M:N through-junction edge carries the derived join fields (hetero)", async () => { + const model = await loadModel([join(FIX, "acme")]); + const g = new LinkGraph(model); + const e = g.refsFrom("acme::shop::Order").find((r) => r.to === "acme::shop::Product"); + expect(e).toBeDefined(); + expect(e!.kind).toBe("relationship"); + expect(e!.cardinality).toBe("many"); + expect(e!.through).toBe("acme::shop::OrderProduct"); + expect(e!.sourceJoinField).toBe("orderId"); + expect(e!.targetJoinField).toBe("productId"); + // the junction is still its own node with its two FK edges (neither covered by a relationship) + expect(g.refsFrom("acme::shop::OrderProduct").filter((r) => r.kind === "fk").map((r) => r.to).sort()) + .toEqual(["acme::shop::Order", "acme::shop::Product"]); +}); + +test("directed self-join (@sourceRefField) resolves source/target join fields", async () => { + const model = await loadModel([join(FIX, "acme")]); + const g = new LinkGraph(model); + const e = g.refsFrom("acme::shop::Customer").find((r) => r.to === "acme::shop::Customer" && r.via === "referrals"); + expect(e).toBeDefined(); + expect(e!.through).toBe("acme::shop::CustomerReferral"); + expect(e!.sourceJoinField).toBe("referrerId"); + expect(e!.targetJoinField).toBe("referredId"); + expect(e!.symmetric).toBeFalsy(); +}); + +test("symmetric self-join (@symmetric) is flagged symmetric", async () => { + const model = await loadModel([join(FIX, "acme")]); + const g = new LinkGraph(model); + const e = g.refsFrom("acme::shop::Customer").find((r) => r.to === "acme::shop::Customer" && r.via === "friends"); + expect(e).toBeDefined(); + expect(e!.through).toBe("acme::shop::CustomerFriend"); + expect(e!.symmetric).toBe(true); +}); +``` + +- [ ] **Step 2: Run to verify failure.** Run: `bun test test/link-graph.test.ts -t "join fields"`. Expected: FAIL (`sourceJoinField`/`targetJoinField` undefined — the stub does not derive them). + +- [ ] **Step 3: Replace the `addM2mEdge` stub with the real derivation.** In `src/link-graph.ts`, replace the temporary `addM2mEdge` stub from Task 2 with: + +```ts + // M:N through-junction edge. Derives the junction FK fields via the metadata + // SSOT (deriveM2MFields) — hetero, directed (@sourceRefField), or symmetric + // (@symmetric). On a derivation failure (ambiguous self-join) the edge is + // SKIPPED — generation never fails. + const addM2mEdge = (from: string, to: string, rel: MetaRelationship, obj: MetaObject, ctxPkg: string, onDelete: string | undefined, subtype: string | undefined): void => { + const through = rel.through ? (resolveRef(rel.through, ctxPkg) ?? rel.through) : undefined; + let sourceJoinField: string | undefined, targetJoinField: string | undefined; + try { + const f = deriveM2MFields(rel, obj, model.root); + sourceJoinField = f.sourceField; + targetJoinField = f.targetField; + } catch { + return; // ambiguous junction — skip the logical edge (the two FK edges still show) + } + addRef({ from, to, via: rel.name, kind: "relationship", cardinality: "many", through, sourceJoinField, targetJoinField, symmetric: rel.symmetric, onDelete, subtype }); + }; +``` + +- [ ] **Step 4: Run to verify pass.** Run: `bun test test/link-graph.test.ts`. Expected: PASS (all). + +- [ ] **Step 5: Regenerate the golden + determinism.** Run the regen command, then `bun test test/golden.test.ts` twice. Expected: PASS both. (Golden diff: Order and Customer neighborhoods gain the M:N logical edges; junctions remain as nodes.) + +- [ ] **Step 6: Typecheck + commit.** + +```bash +bun run typecheck +git add src/link-graph.ts test/link-graph.test.ts test/fixture/golden +git commit -m "feat(docs-site): derive M:N through-junction edges (hetero/directed/symmetric) via deriveM2MFields" +``` + +--- + +## Task 4: Render cardinality — `}o--o{` (ER) + dashed M:N (flowchart) + +**Files:** +- Modify: `src/mermaid.ts` (`ErEdge`, `erDiagramRich`, `flowchartDomain`) +- Modify: `src/builders/object-data.ts` (thread `cardinality`/`through`/`onDelete` into edges + labels) +- Modify: `src/builders/index-data.ts` (thread `cardinality`/`style` into hero edges) +- Modify: `test/mermaid.test.ts` +- Modify: `test/fixture/golden/**` (regenerate) + +**Interfaces:** +- Produces: `ErEdge` gains `cardinality?: "one" | "many"`; `flowchartDomain` edge input gains `style?: "dashed"`. `erDiagramRich` emits `}o--o{` for `cardinality === "many"`, else `||--o{`; `flowchartDomain` emits `-.->` for `style === "dashed"`, else `-->`. + +- [ ] **Step 1: Write the failing mermaid tests.** In `test/mermaid.test.ts`, add: + +```ts +test("erDiagramRich uses }o--o{ for M:N edges and ||--o{ otherwise", () => { + const { erDiagramRich } = require("../src/mermaid"); + const nodes = [ + { name: "Order", pkg: "acme::shop", role: "focal", attrs: [], more: 0 }, + { name: "Product", pkg: "acme::shop", role: "normal", attrs: [], more: 0 }, + { name: "Customer", pkg: "acme::shop", role: "normal", attrs: [], more: 0 }, + ]; + const out = erDiagramRich(nodes, [ + { parent: "Order", child: "Product", label: "products", cardinality: "many" }, + { parent: "Customer", child: "Order", label: "customer", cardinality: "one" }, + ]); + expect(out).toContain("Order }o--o{ Product"); + expect(out).toContain("Customer ||--o{ Order"); +}); + +test("flowchartDomain draws a dashed link for style:dashed", () => { + const { flowchartDomain } = require("../src/mermaid"); + const r = flowchartDomain( + [{ name: "Order", pkg: "acme::shop" }, { name: "Product", pkg: "acme::shop" }], + [{ from: "Order", to: "Product", label: "M:N via OrderProduct", style: "dashed" }], + ); + expect(r.mermaid).toContain("-.->|M:N via OrderProduct|"); +}); +``` + +- [ ] **Step 2: Run to verify failure.** Run: `bun test test/mermaid.test.ts -t "M:N"`. Expected: FAIL. + +- [ ] **Step 3: Extend `ErEdge` + the emitters.** In `src/mermaid.ts`: + +Replace the `ErEdge` interface (line 1): +```ts +export interface ErEdge { parent: string; child: string; label: string; } +``` +with: +```ts +export interface ErEdge { parent: string; child: string; label: string; cardinality?: "one" | "many" | undefined; } +``` + +Replace the `erDiagramRich` edge line (currently `for (const e of [...edges].sort(edgeSort)) lines.push(\` ${nodeId(e.parent)} ||--o{ ${nodeId(e.child)} : "${safe(e.label)}"\`);`) with: +```ts + for (const e of [...edges].sort(edgeSort)) { + const conn = e.cardinality === "many" ? "}o--o{" : "||--o{"; + lines.push(` ${nodeId(e.parent)} ${conn} ${nodeId(e.child)} : "${safe(e.label)}"`); + } +``` + +In `flowchartDomain`, change the edge input type and the edge emission. Replace the signature's edge param type `edges: { from: string; to: string; label?: string }[]` with `edges: { from: string; to: string; label?: string; style?: "dashed" | undefined }[]`, and replace the emission block: +```ts + for (const e of [...edges].sort((a, b) => a.from.localeCompare(b.from) || a.to.localeCompare(b.to))) { + const lbl = e.label ? edgeLabel(e.label) : ""; + lines.push(lbl ? ` ${nodeId(e.from)} -->|${lbl}| ${nodeId(e.to)}` : ` ${nodeId(e.from)} --> ${nodeId(e.to)}`); + } +``` +with: +```ts + for (const e of [...edges].sort((a, b) => a.from.localeCompare(b.from) || a.to.localeCompare(b.to))) { + const lbl = e.label ? edgeLabel(e.label) : ""; + const arrow = e.style === "dashed" ? "-.->" : "-->"; + lines.push(lbl ? ` ${nodeId(e.from)} ${arrow}|${lbl}| ${nodeId(e.to)}` : ` ${nodeId(e.from)} ${arrow} ${nodeId(e.to)}`); + } +``` + +- [ ] **Step 4: Run to verify pass.** Run: `bun test test/mermaid.test.ts`. Expected: PASS. + +- [ ] **Step 5: Thread the new edge data through `object-data.ts`.** In `src/builders/object-data.ts`, the neighborhood builds `nbCandidates` with `edge: { parent, child, label }`. Enrich the label + cardinality. Replace the two `nbCandidates.set(...)` lines (the `refsFrom` and `refsTo` loops) so the edge object becomes: + +```ts + for (const r of g.refsFrom(fqn)) { const t = g.byFqn(r.to); if (t && !nbCandidates.has(r.to)) nbCandidates.set(r.to, { node: t, edge: { parent: t.name, child: dn.name, label: edgeLabelFor(r), cardinality: r.cardinality } }); } + for (const r of g.refsTo(fqn)) { const s = g.byFqn(r.from); if (s && s.kind === "object" && !nbCandidates.has(r.from)) nbCandidates.set(r.from, { node: s, edge: { parent: dn.name, child: s.name, label: edgeLabelFor(r), cardinality: r.cardinality } }); } +``` +First, extend the existing `../link-graph` import at the top of `object-data.ts` to bring in the `Ref` type — change: +```ts +import { LinkGraph, fqnOf } from "../link-graph"; +``` +to: +```ts +import { LinkGraph, fqnOf, type Ref } from "../link-graph"; +``` +Then add this helper at module scope (above `buildObjectPage`): +```ts +// Neighborhood edge label: relationship edges show their name + (M:N) junction + onDelete; extends/others show via. +function edgeLabelFor(r: Ref): string { + if (r.kind === "extends") return "extends"; + if (r.kind === "relationship") { + const junction = r.through ? ` · M:N via ${r.through.split("::").pop()}` : ""; + const od = r.onDelete ? ` · ${r.onDelete}` : ""; + return `${r.via}${junction}${od}`; + } + return r.via; +} +``` +The `ErEdge` for the rich diagram is built at the `erNodes`/`erDiagramRich(erNodes, nbEdges)` call — `nbEdges` are the `edge` objects collected above, which now include `cardinality`. No further change needed there (the `edge` objects ARE `ErEdge`-shaped). For the large-neighborhood `flowchartDomain` branch, map the M:N `cardinality` to `style: "dashed"`. Replace: +```ts + const r = flowchartDomain([...nbNodes.values()].map((n) => ({ name: n.name, pkg: n.pkg, kind: n.node.subType })), nbEdges.map((e) => ({ from: e.parent, to: e.child, label: e.label }))); +``` +with: +```ts + const r = flowchartDomain([...nbNodes.values()].map((n) => ({ name: n.name, pkg: n.pkg, kind: n.node.subType })), nbEdges.map((e) => ({ from: e.parent, to: e.child, label: e.label, ...(e.cardinality === "many" ? { style: "dashed" as const } : {}) }))); +``` + +- [ ] **Step 6: Thread `style` through the core diagram in `index-data.ts`.** In `src/builders/index-data.ts`, the `heroEdges` are built as `{ from, to, label: "" }`. Carry cardinality → dashed for M:N. Replace the `heroEdges.push(...)` line: +```ts + if (!seenEdge.has(key)) { seenEdge.add(key); heroEdges.push({ from: dn.name, to, label: "" }); } +``` +with: +```ts + if (!seenEdge.has(key)) { seenEdge.add(key); heroEdges.push({ from: dn.name, to, label: "", ...(e.cardinality === "many" ? { style: "dashed" as const } : {}) }); } +``` +and widen the `heroEdges` declaration type: +```ts + const heroEdges: { from: string; to: string; label: string; style?: "dashed" }[] = []; +``` + +- [ ] **Step 7: Run the unit suites.** Run: `bun test test/mermaid.test.ts test/object-data.test.ts test/object-data-v2.test.ts test/graph-v2.test.ts`. Expected: PASS (fix any assertion that pinned an exact old edge string — update it to the enriched label). + +- [ ] **Step 8: Regenerate the golden + determinism.** Run the regen command, then `bun test test/golden.test.ts` twice. Expected: PASS both. (Golden diff: M:N edges render `}o--o{` / dashed; relationship labels carry `· cascade` / `· M:N via …`.) + +- [ ] **Step 9: Typecheck + commit.** + +```bash +bun run typecheck +git add src/mermaid.ts src/builders/object-data.ts src/builders/index-data.ts test/mermaid.test.ts test/fixture/golden +git commit -m "feat(docs-site): render M:N with }o--o{ (ER) + dashed flowchart edge; label onDelete/junction" +``` + +--- + +## Task 5: Hygiene — genericize the `CURATED` palette keys + +**Files:** +- Modify: `src/mermaid.ts` (`CURATED` keys only — values + order unchanged) +- Modify: `test/fixture/golden/**` (verify byte-identical — must NOT change) + +**Interfaces:** +- Produces: `CURATED` keyed by neutral slot names; `PALETTE = Object.values(CURATED)` unchanged (same values, same order) → `domainColor` hashing unchanged → zero golden impact. + +- [ ] **Step 1: Rename the `CURATED` keys to neutral slots.** In `src/mermaid.ts`, replace the `CURATED` object so **every value stays identical and in the same order**, only the keys change: + +```ts +const CURATED: Record = { + slot1: { fill: "#1e3a5f", stroke: "#60a5fa", text: "#93c5fd" }, + slot2: { fill: "#3b2f1e", stroke: "#fbbf24", text: "#fde68a" }, + slot3: { fill: "#3f2d5c", stroke: "#a78bfa", text: "#ede9fe" }, + slot4: { fill: "#3f1f2e", stroke: "#fb7185", text: "#fecdd3" }, + slot5: { fill: "#14342b", stroke: "#34d399", text: "#a7f3d0" }, + slot6: { fill: "#1f2937", stroke: "#94a3b8", text: "#e2e8f0" }, + slot7: { fill: "#2a2440", stroke: "#818cf8", text: "#e0e7ff" }, + slot8: { fill: "#1a2e35", stroke: "#22d3ee", text: "#cffafe" }, + slot9: { fill: "#332018", stroke: "#fb923c", text: "#fed7aa" }, + slot10: { fill: "#1c2431", stroke: "#64748b", text: "#cbd5e1" }, +}; +``` +(This drops the per-domain-name color feature — acme packages never matched those keys, so behavior for any real model is unchanged: everything now hashes into `PALETTE`.) + +- [ ] **Step 2: Confirm the golden is byte-identical.** Run: `bun test test/golden.test.ts`. Expected: PASS with **no regeneration** — the `PALETTE` values/order are unchanged, so colors are identical. If it fails, a value or the order changed — fix it (do not regenerate). + +- [ ] **Step 3: Confirm the domain-color tests still pass.** Run: `bun test test/mermaid.test.ts`. Expected: PASS (those tests compute expected colors via `domainColor()`, so key renaming is transparent). + +- [ ] **Step 4: Hygiene + commit.** + +```bash +# The CURATED keys should now be neutral slotN names only. Eyeball the block: +grep -nE "^\s+slot[0-9]+:" src/mermaid.ts # expect 10 neutral slot keys, no domain words +grep -rniE "/home/" src/mermaid.ts && echo LEAK || echo clean +bun run typecheck +git add src/mermaid.ts +git commit -m "chore(docs-site): genericize CURATED palette keys (neutral slots; palette values unchanged)" +``` + +--- + +## Task 6: Full verification + wrap + +**Files:** none (verification only). + +- [ ] **Step 1: Full docs-site suite.** Run: `cd server/typescript/packages/docs-site && bun test`. Expected: all pass, 0 fail. + +- [ ] **Step 2: Determinism double-check.** Run the regen command, then `git status --short test/fixture/golden` — expected: **no changes** (the committed golden already equals a fresh regeneration). If files change, determinism regressed — investigate before proceeding. + +- [ ] **Step 3: Build + typecheck.** Run: `bun run build && bun run typecheck`. Expected: both clean. + +- [ ] **Step 4: No-relationship model is unaffected.** Confirm a model without relationship children still renders `||--o{` only: + +```bash +bun -e 'import {LinkGraph} from "./src/link-graph.ts"; import {loadModel} from "./src/load.ts"; const m = await loadModel(["test/fixture/input/acme"]); const g = new LinkGraph(m); const orphan = g.refsFrom("acme::shop::OrphanLog"); console.log("OrphanLog edges:", orphan.length, "— expect 0 relationship edges:", orphan.filter((r)=>r.kind==="relationship").length);' +``` +Expected: `OrphanLog` (no relationships/FKs) has 0 relationship edges — proves the enrichment is scoped to entities that declare relationships. + +- [ ] **Step 5: Hygiene sweep of all touched files.** + +```bash +grep -rniE "/home/" src test/fixture/input && echo REVIEW || echo clean +``` +Expected: clean. + +- [ ] **Step 6: Confirm the commit series.** Run: `git log --oneline main..HEAD`. Expected: the Phase-1 commits + the spec + Tasks 1–5 commits, all present. + +--- + +## Self-Review + +**Spec coverage:** M:N-through-junction with junction-as-node → Task 1 fixture + Task 3 + Task 4 rendering; belongs-to cardinality/onDelete/subtype → Task 2; directed + symmetric self-joins → Task 1 fixture + Task 3; relationship-vs-FK dedupe → Task 2; `}o--o{` / dashed rendering → Task 4; consume metadata primitives (no codegen-ts dep) → Task 2 imports + Task 3; extend acme + regenerate golden → every output task; determinism + link-check + golden gates → regen-twice steps + Task 6; escaping preserved → labels routed through `safe()`/`edgeLabel()`; genericize `CURATED` keys → Task 5; no private-name/path leak → per-task grep + Task 6. + +**Placeholder scan:** none — every code step shows the exact replacement; the Task-2 `addM2mEdge` stub is explicitly a temporary that Task 3 Step 3 replaces (called out in both tasks). + +**Type consistency:** `Ref` fields (`cardinality`/`through`/`sourceJoinField`/`targetJoinField`/`symmetric`/`onDelete`/`subtype`) defined in Task 2 Step 1 and consumed in Tasks 3–4; `ErEdge.cardinality` and the flowchart `style` defined in Task 4 Step 3 and consumed in Task 4 Steps 5–6; `edgeLabelFor(r: Ref)` uses only `Ref` fields; `deriveM2MFields`/`MetaObject`/`MetaRelationship`/`stripPackage` imported in Task 2 Step 2 and used in Tasks 2–3. From 7a9acc7e0a0ccbaa322a138054bfbe993cb10df1 Mon Sep 17 00:00:00 2001 From: Doug Mealing Date: Sat, 4 Jul 2026 14:30:49 -0400 Subject: [PATCH 08/22] test(docs-site): extend acme fixture with M:N + directed/symmetric self-joins + onDelete MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Adds Product, OrderProduct, CustomerReferral, CustomerFriend to the acme::shop fixture: Order.products (M:N via OrderProduct), Order.customer gains @onDelete: cascade, Customer.referrals (directed self-join via CustomerReferral, @sourceRefField: referrerId), Customer.friends (symmetric self-join via CustomerFriend). Golden regenerated (4 new pages), determinism verified via two independent regenerations (byte-identical). Also updates load.test.ts and package-index-data.test.ts hardcoded object name/count lists — pure structural fallout of the 4 new fixture entities, unrelated to relationship-edge derivation. link-graph.test.ts's pre-existing backlink/degree assertions on Customer (refsTo length/degree == 3) are left failing on purpose: they assume the FK-vs-relationship dedupe that Task 2 of this plan implements. --- .../test/fixture/golden/acme/ai/ItemView.html | 4 + .../fixture/golden/acme/ai/NpcPayload.html | 4 + .../fixture/golden/acme/ai/NpcResponse.html | 4 + .../fixture/golden/acme/ai/OrderLine.html | 4 + .../test/fixture/golden/acme/ai/OrphanVO.html | 4 + .../test/fixture/golden/acme/ai/index.html | 4 + .../fixture/golden/acme/ai/npcReview.html | 4 + .../golden/acme/ai/npcReviewOutput.html | 4 + .../golden/acme/common/BaseEntity.html | 45 +++++- .../fixture/golden/acme/common/index.html | 4 + .../fixture/golden/acme/shop/Customer.html | 27 +++- .../golden/acme/shop/CustomerFriend.html | 137 +++++++++++++++++ .../golden/acme/shop/CustomerReferral.html | 137 +++++++++++++++++ .../golden/acme/shop/LineItemView.html | 4 + .../test/fixture/golden/acme/shop/Order.html | 25 ++- .../golden/acme/shop/OrderProduct.html | 145 ++++++++++++++++++ .../fixture/golden/acme/shop/OrphanLog.html | 4 + .../fixture/golden/acme/shop/Product.html | 144 +++++++++++++++++ .../test/fixture/golden/acme/shop/index.html | 69 ++++----- .../fixture/golden/assets/search-index.json | 2 +- .../test/fixture/golden/coverage.html | 8 +- .../docs-site/test/fixture/golden/enums.html | 4 + .../docs-site/test/fixture/golden/index.html | 28 +++- .../fixture/input/acme/shop/meta.shop.yaml | 38 ++++- .../docs-site/test/link-graph.test.ts | 8 + .../packages/docs-site/test/load.test.ts | 2 +- .../docs-site/test/package-index-data.test.ts | 4 +- 27 files changed, 814 insertions(+), 53 deletions(-) create mode 100644 server/typescript/packages/docs-site/test/fixture/golden/acme/shop/CustomerFriend.html create mode 100644 server/typescript/packages/docs-site/test/fixture/golden/acme/shop/CustomerReferral.html create mode 100644 server/typescript/packages/docs-site/test/fixture/golden/acme/shop/OrderProduct.html create mode 100644 server/typescript/packages/docs-site/test/fixture/golden/acme/shop/Product.html diff --git a/server/typescript/packages/docs-site/test/fixture/golden/acme/ai/ItemView.html b/server/typescript/packages/docs-site/test/fixture/golden/acme/ai/ItemView.html index 0a6f69690..f077a741a 100644 --- a/server/typescript/packages/docs-site/test/fixture/golden/acme/ai/ItemView.html +++ b/server/typescript/packages/docs-site/test/fixture/golden/acme/ai/ItemView.html @@ -24,9 +24,13 @@
acme::shop Customer +CustomerFriend +CustomerReferral LineItemView Order +OrderProduct OrphanLog +Product
Prompts
diff --git a/server/typescript/packages/docs-site/test/fixture/golden/acme/ai/NpcPayload.html b/server/typescript/packages/docs-site/test/fixture/golden/acme/ai/NpcPayload.html index d2d338d11..37b53c908 100644 --- a/server/typescript/packages/docs-site/test/fixture/golden/acme/ai/NpcPayload.html +++ b/server/typescript/packages/docs-site/test/fixture/golden/acme/ai/NpcPayload.html @@ -24,9 +24,13 @@
acme::shop Customer +CustomerFriend +CustomerReferral LineItemView Order +OrderProduct OrphanLog +Product
Prompts
diff --git a/server/typescript/packages/docs-site/test/fixture/golden/acme/ai/NpcResponse.html b/server/typescript/packages/docs-site/test/fixture/golden/acme/ai/NpcResponse.html index ac16ef82f..d166a992c 100644 --- a/server/typescript/packages/docs-site/test/fixture/golden/acme/ai/NpcResponse.html +++ b/server/typescript/packages/docs-site/test/fixture/golden/acme/ai/NpcResponse.html @@ -24,9 +24,13 @@
acme::shop Customer +CustomerFriend +CustomerReferral LineItemView Order +OrderProduct OrphanLog +Product
Prompts
diff --git a/server/typescript/packages/docs-site/test/fixture/golden/acme/ai/OrderLine.html b/server/typescript/packages/docs-site/test/fixture/golden/acme/ai/OrderLine.html index ef2ff8287..1bb9c3f6d 100644 --- a/server/typescript/packages/docs-site/test/fixture/golden/acme/ai/OrderLine.html +++ b/server/typescript/packages/docs-site/test/fixture/golden/acme/ai/OrderLine.html @@ -24,9 +24,13 @@
acme::shop Customer +CustomerFriend +CustomerReferral LineItemView Order +OrderProduct OrphanLog +Product
Prompts
diff --git a/server/typescript/packages/docs-site/test/fixture/golden/acme/ai/OrphanVO.html b/server/typescript/packages/docs-site/test/fixture/golden/acme/ai/OrphanVO.html index 24491fd96..8bb09d857 100644 --- a/server/typescript/packages/docs-site/test/fixture/golden/acme/ai/OrphanVO.html +++ b/server/typescript/packages/docs-site/test/fixture/golden/acme/ai/OrphanVO.html @@ -24,9 +24,13 @@
acme::shop Customer +CustomerFriend +CustomerReferral LineItemView Order +OrderProduct OrphanLog +Product
Prompts
diff --git a/server/typescript/packages/docs-site/test/fixture/golden/acme/ai/index.html b/server/typescript/packages/docs-site/test/fixture/golden/acme/ai/index.html index 70fadfe64..c527760fa 100644 --- a/server/typescript/packages/docs-site/test/fixture/golden/acme/ai/index.html +++ b/server/typescript/packages/docs-site/test/fixture/golden/acme/ai/index.html @@ -24,9 +24,13 @@
acme::shop Customer +CustomerFriend +CustomerReferral LineItemView Order +OrderProduct OrphanLog +Product
Prompts
diff --git a/server/typescript/packages/docs-site/test/fixture/golden/acme/ai/npcReview.html b/server/typescript/packages/docs-site/test/fixture/golden/acme/ai/npcReview.html index f1e2b1c71..2e7a416d8 100644 --- a/server/typescript/packages/docs-site/test/fixture/golden/acme/ai/npcReview.html +++ b/server/typescript/packages/docs-site/test/fixture/golden/acme/ai/npcReview.html @@ -24,9 +24,13 @@
acme::shop Customer +CustomerFriend +CustomerReferral LineItemView Order +OrderProduct OrphanLog +Product
Prompts
diff --git a/server/typescript/packages/docs-site/test/fixture/golden/acme/ai/npcReviewOutput.html b/server/typescript/packages/docs-site/test/fixture/golden/acme/ai/npcReviewOutput.html index 2da368dc2..36b586602 100644 --- a/server/typescript/packages/docs-site/test/fixture/golden/acme/ai/npcReviewOutput.html +++ b/server/typescript/packages/docs-site/test/fixture/golden/acme/ai/npcReviewOutput.html @@ -24,9 +24,13 @@
acme::shop Customer +CustomerFriend +CustomerReferral LineItemView Order +OrderProduct OrphanLog +Product
Prompts
diff --git a/server/typescript/packages/docs-site/test/fixture/golden/acme/common/BaseEntity.html b/server/typescript/packages/docs-site/test/fixture/golden/acme/common/BaseEntity.html index 7f53e8ae0..629f2ac8c 100644 --- a/server/typescript/packages/docs-site/test/fixture/golden/acme/common/BaseEntity.html +++ b/server/typescript/packages/docs-site/test/fixture/golden/acme/common/BaseEntity.html @@ -24,9 +24,13 @@
acme::shop Customer +CustomerFriend +CustomerReferral LineItemView Order +OrderProduct OrphanLog +Product
Prompts
@@ -76,9 +80,17 @@

BaseEntity flowchart TD BaseEntity["BaseEntity"]:::self Customer["Customer"] + CustomerFriend["CustomerFriend"] + CustomerReferral["CustomerReferral"] Order["Order"] + OrderProduct["OrderProduct"] + Product["Product"] BaseEntity --> Customer + BaseEntity --> CustomerFriend + BaseEntity --> CustomerReferral BaseEntity --> Order + BaseEntity --> OrderProduct + BaseEntity --> Product classDef self fill:#1e3a5f,stroke:#60a5fa,color:#e2e8f0,font-weight:bold

Neighborhood

@@ -91,19 +103,50 @@

BaseEntity string id PK "" string email "req" } + CustomerFriend["▭ CustomerFriend"] { + string id PK "" + string friendAId FK "Customer" + string friendBId FK "Customer" + } + CustomerReferral["▭ CustomerReferral"] { + string id PK "" + string referrerId FK "Customer" + string referredId FK "Customer" + } Order["▭ Order"] { string id PK "" string customerId FK "Customer" enum status "enum" } + OrderProduct["▭ OrderProduct"] { + string id PK "" + string orderId FK "Order" + string productId FK "Product" + } + Product["▭ Product"] { + string id PK "" + string sku "req" + } BaseEntity ||--o{ Customer : "extends" + BaseEntity ||--o{ CustomerFriend : "extends" + BaseEntity ||--o{ CustomerReferral : "extends" BaseEntity ||--o{ Order : "extends" + BaseEntity ||--o{ OrderProduct : "extends" + BaseEntity ||--o{ Product : "extends" BaseEntity:::c_BaseEntity classDef c_BaseEntity fill:#1c2431,stroke:#60a5fa,color:#cbd5e1 Customer:::c_Customer classDef c_Customer fill:#3f2d5c,stroke:#334155,color:#cbd5e1 + CustomerFriend:::c_CustomerFriend + classDef c_CustomerFriend fill:#3f2d5c,stroke:#334155,color:#cbd5e1 + CustomerReferral:::c_CustomerReferral + classDef c_CustomerReferral fill:#3f2d5c,stroke:#334155,color:#cbd5e1 Order:::c_Order - classDef c_Order fill:#3f2d5c,stroke:#334155,color:#cbd5e1 + classDef c_Order fill:#3f2d5c,stroke:#334155,color:#cbd5e1 + OrderProduct:::c_OrderProduct + classDef c_OrderProduct fill:#3f2d5c,stroke:#334155,color:#cbd5e1 + Product:::c_Product + classDef c_Product fill:#3f2d5c,stroke:#334155,color:#cbd5e1
entity · value object · view (ER boxes: dashed = value, dotted = view)

diff --git a/server/typescript/packages/docs-site/test/fixture/golden/acme/common/index.html b/server/typescript/packages/docs-site/test/fixture/golden/acme/common/index.html index 9cbb24024..fc51b88ef 100644 --- a/server/typescript/packages/docs-site/test/fixture/golden/acme/common/index.html +++ b/server/typescript/packages/docs-site/test/fixture/golden/acme/common/index.html @@ -24,9 +24,13 @@
acme::shop Customer +CustomerFriend +CustomerReferral LineItemView Order +OrderProduct OrphanLog +Product
Prompts
diff --git a/server/typescript/packages/docs-site/test/fixture/golden/acme/shop/Customer.html b/server/typescript/packages/docs-site/test/fixture/golden/acme/shop/Customer.html index b50f7c912..0a7bb8c85 100644 --- a/server/typescript/packages/docs-site/test/fixture/golden/acme/shop/Customer.html +++ b/server/typescript/packages/docs-site/test/fixture/golden/acme/shop/Customer.html @@ -24,9 +24,13 @@
acme::shop Customer +CustomerFriend +CustomerReferral LineItemView Order +OrderProduct OrphanLog +Product
Prompts
@@ -67,7 +71,8 @@

Customer - +

Relationships

+
friendsmanyCustomer
referralsmanyCustomer
@@ -89,6 +94,16 @@

Customer string id PK "" string email "req" } + CustomerFriend["▭ CustomerFriend"] { + string id PK "" + string friendAId FK "Customer" + string friendBId FK "Customer" + } + CustomerReferral["▭ CustomerReferral"] { + string id PK "" + string referrerId FK "Customer" + string referredId FK "Customer" + } LineItemView["▱ LineItemView"] { } Order["▭ Order"] { @@ -97,12 +112,19 @@

Customer enum status "enum" } BaseEntity ||--o{ Customer : "extends" + Customer ||--o{ Customer : "referrals" + Customer ||--o{ CustomerFriend : "friendAId" + Customer ||--o{ CustomerReferral : "referrerId" Customer ||--o{ LineItemView : "customerName (origin)" Customer ||--o{ Order : "customerId" BaseEntity:::c_BaseEntity classDef c_BaseEntity fill:#1c2431,stroke:#334155,color:#cbd5e1 Customer:::c_Customer classDef c_Customer fill:#3f2d5c,stroke:#60a5fa,color:#cbd5e1 + CustomerFriend:::c_CustomerFriend + classDef c_CustomerFriend fill:#3f2d5c,stroke:#3b5170,color:#cbd5e1 + CustomerReferral:::c_CustomerReferral + classDef c_CustomerReferral fill:#3f2d5c,stroke:#3b5170,color:#cbd5e1 LineItemView:::c_LineItemView classDef c_LineItemView fill:#3f2d5c,stroke:#2dd4bf,color:#cbd5e1,stroke-dasharray:2 2 Order:::c_Order @@ -111,13 +133,14 @@

Customer
entity · value object · view (ER boxes: dashed = value, dotted = view)
-
referenced by LineItemView Order Order
+
referenced by Customer Customer CustomerFriend CustomerFriend CustomerReferral CustomerReferral LineItemView Order Order
meta.shop.yaml