@metaobjectsdev/docs-site 0.15.8-rc.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (104) hide show
  1. package/LICENSE +189 -0
  2. package/README.md +62 -0
  3. package/assets/site.css +13 -0
  4. package/assets/site.js +25 -0
  5. package/dist/badges.d.ts +14 -0
  6. package/dist/badges.d.ts.map +1 -0
  7. package/dist/badges.js +21 -0
  8. package/dist/badges.js.map +1 -0
  9. package/dist/builders/extras.d.ts +23 -0
  10. package/dist/builders/extras.d.ts.map +1 -0
  11. package/dist/builders/extras.js +70 -0
  12. package/dist/builders/extras.js.map +1 -0
  13. package/dist/builders/index-data.d.ts +52 -0
  14. package/dist/builders/index-data.d.ts.map +1 -0
  15. package/dist/builders/index-data.js +115 -0
  16. package/dist/builders/index-data.js.map +1 -0
  17. package/dist/builders/object-data.d.ts +97 -0
  18. package/dist/builders/object-data.d.ts.map +1 -0
  19. package/dist/builders/object-data.js +336 -0
  20. package/dist/builders/object-data.js.map +1 -0
  21. package/dist/builders/output-data.d.ts +26 -0
  22. package/dist/builders/output-data.d.ts.map +1 -0
  23. package/dist/builders/output-data.js +35 -0
  24. package/dist/builders/output-data.js.map +1 -0
  25. package/dist/builders/package-data.d.ts +48 -0
  26. package/dist/builders/package-data.d.ts.map +1 -0
  27. package/dist/builders/package-data.js +142 -0
  28. package/dist/builders/package-data.js.map +1 -0
  29. package/dist/builders/prompt-data.d.ts +31 -0
  30. package/dist/builders/prompt-data.d.ts.map +1 -0
  31. package/dist/builders/prompt-data.js +68 -0
  32. package/dist/builders/prompt-data.js.map +1 -0
  33. package/dist/coverage.d.ts +19 -0
  34. package/dist/coverage.d.ts.map +1 -0
  35. package/dist/coverage.js +26 -0
  36. package/dist/coverage.js.map +1 -0
  37. package/dist/index.d.ts +4 -0
  38. package/dist/index.d.ts.map +1 -0
  39. package/dist/index.js +3 -0
  40. package/dist/index.js.map +1 -0
  41. package/dist/link-check.d.ts +2 -0
  42. package/dist/link-check.d.ts.map +1 -0
  43. package/dist/link-check.js +30 -0
  44. package/dist/link-check.js.map +1 -0
  45. package/dist/link-graph.d.ts +54 -0
  46. package/dist/link-graph.d.ts.map +1 -0
  47. package/dist/link-graph.js +198 -0
  48. package/dist/link-graph.js.map +1 -0
  49. package/dist/load.d.ts +11 -0
  50. package/dist/load.d.ts.map +1 -0
  51. package/dist/load.js +40 -0
  52. package/dist/load.js.map +1 -0
  53. package/dist/mermaid.d.ts +56 -0
  54. package/dist/mermaid.d.ts.map +1 -0
  55. package/dist/mermaid.js +136 -0
  56. package/dist/mermaid.js.map +1 -0
  57. package/dist/mustache-highlight.d.ts +10 -0
  58. package/dist/mustache-highlight.d.ts.map +1 -0
  59. package/dist/mustache-highlight.js +55 -0
  60. package/dist/mustache-highlight.js.map +1 -0
  61. package/dist/package-docs.d.ts +12 -0
  62. package/dist/package-docs.d.ts.map +1 -0
  63. package/dist/package-docs.js +34 -0
  64. package/dist/package-docs.js.map +1 -0
  65. package/dist/scaffold.d.ts +7 -0
  66. package/dist/scaffold.d.ts.map +1 -0
  67. package/dist/scaffold.js +24 -0
  68. package/dist/scaffold.js.map +1 -0
  69. package/dist/site.d.ts +23 -0
  70. package/dist/site.d.ts.map +1 -0
  71. package/dist/site.js +219 -0
  72. package/dist/site.js.map +1 -0
  73. package/dist/yaml-comments.d.ts +6 -0
  74. package/dist/yaml-comments.d.ts.map +1 -0
  75. package/dist/yaml-comments.js +49 -0
  76. package/dist/yaml-comments.js.map +1 -0
  77. package/package.json +45 -0
  78. package/src/badges.ts +26 -0
  79. package/src/builders/extras.ts +61 -0
  80. package/src/builders/index-data.ts +95 -0
  81. package/src/builders/object-data.ts +261 -0
  82. package/src/builders/output-data.ts +35 -0
  83. package/src/builders/package-data.ts +134 -0
  84. package/src/builders/prompt-data.ts +61 -0
  85. package/src/coverage.ts +29 -0
  86. package/src/index.ts +3 -0
  87. package/src/link-check.ts +24 -0
  88. package/src/link-graph.ts +204 -0
  89. package/src/load.ts +47 -0
  90. package/src/mermaid.ts +142 -0
  91. package/src/mustache-highlight.ts +43 -0
  92. package/src/package-docs.ts +33 -0
  93. package/src/scaffold.ts +26 -0
  94. package/src/site.ts +289 -0
  95. package/src/yaml-comments.ts +33 -0
  96. package/templates/chrome-foot.mustache +17 -0
  97. package/templates/chrome-head.mustache +21 -0
  98. package/templates/coverage.html.mustache +24 -0
  99. package/templates/enums.html.mustache +14 -0
  100. package/templates/index.html.mustache +54 -0
  101. package/templates/object.html.mustache +46 -0
  102. package/templates/output.html.mustache +16 -0
  103. package/templates/package.html.mustache +43 -0
  104. package/templates/prompt.html.mustache +28 -0
@@ -0,0 +1,204 @@
1
+ import type { MetaData, MetaObject, MetaRelationship } from "@metaobjectsdev/metadata";
2
+ import { deriveM2MFields, stripPackage } from "@metaobjectsdev/metadata";
3
+ import { type LoadedModel, treeOf } from "./load";
4
+
5
+ export interface DocNode { kind: "object" | "prompt" | "output"; name: string; pkg: string; pkgPath: string; href: string; node: MetaData; tree: string; }
6
+ export interface Ref {
7
+ from: string; to: string; via: string;
8
+ kind: "field" | "fk" | "extends" | "payload" | "relationship" | "origin";
9
+ cardinality?: "one" | "many" | undefined;
10
+ through?: string | undefined; // junction FQN (M:N)
11
+ sourceJoinField?: string | undefined; // junction source FK (M:N)
12
+ targetJoinField?: string | undefined; // junction target FK (M:N)
13
+ symmetric?: boolean | undefined; // undirected self-join (M:N)
14
+ onDelete?: string | undefined; // referential action
15
+ subtype?: string | undefined; // association / aggregation / composition
16
+ }
17
+ export interface OriginRef { field: string; from: string; via: string; }
18
+
19
+ export function pkgOf(node: MetaData): string {
20
+ return (node.package ?? (node as { fileDefaultPackage?: string }).fileDefaultPackage ?? "");
21
+ }
22
+ export function fqnOf(node: MetaData): string {
23
+ const p = pkgOf(node);
24
+ return p ? `${p}::${node.name}` : node.name;
25
+ }
26
+
27
+ export class LinkGraph {
28
+ private _nodes = new Map<string, DocNode>();
29
+ private _from = new Map<string, Ref[]>();
30
+ private _to = new Map<string, Ref[]>();
31
+ private _extBy = new Map<string, DocNode[]>();
32
+ private _origins = new Map<string, OriginRef[]>();
33
+
34
+ constructor(model: LoadedModel) {
35
+ for (const o of model.root.ownChildren()) {
36
+ let kind: DocNode["kind"] | undefined;
37
+ if (o.type === "object") kind = "object";
38
+ else if (o.type === "template") kind = o.subType === "prompt" ? "prompt" : "output";
39
+ if (!kind) continue;
40
+ const pkg = pkgOf(o);
41
+ const pkgPath = pkg.split("::").join("/");
42
+ this._nodes.set(fqnOf(o), {
43
+ kind, name: o.name, pkg, pkgPath,
44
+ href: `${pkgPath}/${o.name}.html`, node: o, tree: treeOf(o, model),
45
+ });
46
+ }
47
+ const addRef = (r: Ref) => {
48
+ (this._from.get(r.from) ?? this._from.set(r.from, []).get(r.from)!).push(r);
49
+ (this._to.get(r.to) ?? this._to.set(r.to, []).get(r.to)!).push(r);
50
+ };
51
+ const resolveRef = (raw: string, ctxPkg: string): string | undefined => {
52
+ const cand = raw.includes("::") ? raw : `${ctxPkg}::${raw}`;
53
+ return this._nodes.has(cand) ? cand : (this._nodes.has(raw) ? raw : undefined);
54
+ };
55
+ // M:N through-junction edge. Derives the junction FK fields via the metadata
56
+ // SSOT (deriveM2MFields) — hetero, directed (@sourceRefField), or symmetric
57
+ // (@symmetric). On a derivation failure (ambiguous self-join) the edge is
58
+ // SKIPPED — generation never fails.
59
+ const addM2mEdge = (from: string, to: string, rel: MetaRelationship, obj: MetaObject, ctxPkg: string, onDelete: string | undefined, subtype: string | undefined): void => {
60
+ const through = rel.through ? (resolveRef(rel.through, ctxPkg) ?? rel.through) : undefined;
61
+ let sourceJoinField: string | undefined, targetJoinField: string | undefined;
62
+ try {
63
+ const f = deriveM2MFields(rel, obj, model.root);
64
+ sourceJoinField = f.sourceField;
65
+ targetJoinField = f.targetField;
66
+ } catch {
67
+ return; // ambiguous junction — skip the logical edge (the two FK edges still show)
68
+ }
69
+ addRef({ from, to, via: rel.name, kind: "relationship", cardinality: "many", through, sourceJoinField, targetJoinField, symmetric: rel.symmetric, onDelete, subtype });
70
+ };
71
+ for (const dn of this._nodes.values()) {
72
+ const fqn = fqnOf(dn.node);
73
+ if (dn.kind === "object") {
74
+ for (const f of dn.node.childrenOfType("field")) {
75
+ const ref = f.attr("objectRef");
76
+ if (typeof ref === "string") {
77
+ const to = resolveRef(ref, dn.pkg);
78
+ if (to) addRef({ from: fqn, to, via: f.name, kind: "field" });
79
+ }
80
+ }
81
+ // dn.node is typed MetaData; the enclosing dn.kind === "object" guard
82
+ // guarantees it is a MetaObject at runtime. The `unknown` bridge is needed
83
+ // because MetaData does not structurally narrow to MetaObject for tsc.
84
+ const obj = dn.node as unknown as MetaObject;
85
+ // Relationship edges FIRST, so we can suppress the bare FK edge a belongs-to
86
+ // relationship supersedes. Dedupe is keyed by `${targetFqn}::${fkField}`
87
+ // (a string, robust to node-instance identity) — the FK loop skips any
88
+ // reference whose (target, first-field) a belongs-to relationship covered.
89
+ const coveredFk = new Set<string>();
90
+ for (const rel of obj.relationships()) {
91
+ const objectRef = rel.objectRef;
92
+ if (typeof objectRef !== "string") continue;
93
+ const to = resolveRef(objectRef, dn.pkg);
94
+ if (!to) continue;
95
+ const cardinality = rel.cardinality === "many" ? "many" : rel.cardinality === "one" ? "one" : undefined;
96
+ const onDelete = rel.onDelete;
97
+ const subtype = rel.subType;
98
+ if (cardinality === "many" && rel.through !== undefined) {
99
+ addM2mEdge(fqn, to, rel, obj, dn.pkg, onDelete, subtype); // Task 3 helper
100
+ continue;
101
+ }
102
+ // belongs-to (1:N, one) — find the matching identity.reference to dedupe (mirrors
103
+ // relation-resolver: first reference whose target matches, package-stripped).
104
+ const target = stripPackage(objectRef);
105
+ const match = obj.referenceIdentities().find((r) => stripPackage(r.targetEntity ?? "") === target);
106
+ const fkField = match?.fields?.[0];
107
+ if (fkField) coveredFk.add(`${to}::${fkField}`);
108
+ addRef({ from: fqn, to, via: rel.name, kind: "relationship", cardinality, onDelete, subtype });
109
+ }
110
+ for (const id of dn.node.childrenOfType("identity")) {
111
+ if (id.subType !== "reference") continue;
112
+ const ref = id.attr("references");
113
+ if (typeof ref === "string") {
114
+ const to = resolveRef(ref, dn.pkg);
115
+ if (to) {
116
+ const fieldsValue = id.attr("fields") ?? id.name;
117
+ const firstField = Array.isArray(fieldsValue) ? String(fieldsValue[0] ?? "") : String(fieldsValue);
118
+ if (coveredFk.has(`${to}::${firstField}`)) continue; // superseded by a relationship edge
119
+ const via = Array.isArray(fieldsValue) ? fieldsValue.join(", ") : String(fieldsValue);
120
+ addRef({ from: fqn, to, via, kind: "fk" });
121
+ }
122
+ }
123
+ }
124
+ for (const f of dn.node.childrenOfType("field")) {
125
+ for (const org of f.childrenOfType("origin")) {
126
+ const from = String(org.attr("from") ?? ""), via = String(org.attr("via") ?? "");
127
+ (this._origins.get(fqn) ?? this._origins.set(fqn, []).get(fqn)!).push({ field: f.name, from, via });
128
+ // connect a projection to its source object so it is not orphaned on diagrams:
129
+ // `from` is "pkg::Entity.field" — strip the trailing ".field" to get the source object ref.
130
+ const dot = from.lastIndexOf(".");
131
+ const srcRef = dot > 0 ? from.slice(0, dot) : "";
132
+ const to = srcRef ? resolveRef(srcRef, dn.pkg) : undefined;
133
+ if (to && to !== fqn) addRef({ from: fqn, to, via: `${f.name} (origin)`, kind: "origin" });
134
+ }
135
+ }
136
+ const sup = dn.node.superResolved;
137
+ if (sup) {
138
+ const supFqn = fqnOf(sup);
139
+ addRef({ from: fqn, to: supFqn, via: "extends", kind: "extends" });
140
+ (this._extBy.get(supFqn) ?? this._extBy.set(supFqn, []).get(supFqn)!).push(dn);
141
+ }
142
+ } else {
143
+ const p = dn.node.attr("payloadRef");
144
+ if (typeof p === "string") {
145
+ const to = resolveRef(p, dn.pkg);
146
+ if (to) addRef({ from: fqn, to, via: "payloadRef", kind: "payload" });
147
+ }
148
+ }
149
+ }
150
+ }
151
+ nodes(): DocNode[] { return [...this._nodes.values()]; }
152
+ byFqn(fqn: string): DocNode | undefined { return this._nodes.get(fqn); }
153
+ refsFrom(fqn: string): Ref[] { return this._from.get(fqn) ?? []; }
154
+ refsTo(fqn: string): Ref[] { return this._to.get(fqn) ?? []; }
155
+ degree(fqn: string): number {
156
+ const n = (rs: Ref[]) => rs.filter((r) => r.kind !== "extends").length;
157
+ return n(this.refsFrom(fqn)) + n(this.refsTo(fqn));
158
+ }
159
+ extendedBy(fqn: string): DocNode[] { return this._extBy.get(fqn) ?? []; }
160
+ originsOf(fqn: string): OriginRef[] { return this._origins.get(fqn) ?? []; }
161
+ ancestors(fqn: string): DocNode[] {
162
+ const out: DocNode[] = [];
163
+ const start = this._nodes.get(fqn);
164
+ if (!start) return out;
165
+ for (let s = start.node.superResolved; s; s = s.superResolved) {
166
+ const t = this._nodes.get(fqnOf(s));
167
+ if (!t) break;
168
+ out.push(t);
169
+ }
170
+ return out;
171
+ }
172
+ relationshipsOf(fqn: string): { name: string; toFqn: string; cardinality: string }[] {
173
+ return this.refsFrom(fqn).filter((r) => r.kind === "relationship")
174
+ .map((r) => ({ name: r.via, toFqn: r.to, cardinality: r.cardinality ?? "" }))
175
+ .sort((a, b) => a.name.localeCompare(b.name));
176
+ }
177
+ relHref(fromHref: string, toHref: string): string {
178
+ const fromParts = fromHref.split("/");
179
+ const toParts = toHref.split("/");
180
+
181
+ // Remove the filename from fromHref to get the directory
182
+ fromParts.pop();
183
+
184
+ // Find the common prefix length
185
+ // Note: toParts.length - 1 excludes the filename segment, comparing only directories
186
+ let commonLen = 0;
187
+ for (let i = 0; i < Math.min(fromParts.length, toParts.length - 1); i++) {
188
+ if (fromParts[i] === toParts[i]) {
189
+ commonLen++;
190
+ } else {
191
+ break;
192
+ }
193
+ }
194
+
195
+ // Calculate levels to go up
196
+ const up = fromParts.length - commonLen;
197
+
198
+ // Get the remaining path from the common ancestor
199
+ const remaining = toParts.slice(commonLen).join("/");
200
+
201
+ // Construct the relative path
202
+ return up > 0 ? "../".repeat(up) + remaining : remaining;
203
+ }
204
+ }
package/src/load.ts ADDED
@@ -0,0 +1,47 @@
1
+ import { mkdtempSync, rmSync, symlinkSync } from "node:fs";
2
+ import { tmpdir } from "node:os";
3
+ import { basename, join, resolve } from "node:path";
4
+ import { MetaDataLoader, composeRegistry, coreTypesProvider, dbProvider, docProvider, promptProvider, uiProvider } from "@metaobjectsdev/metadata";
5
+ import type { MetaData, MetaRoot } from "@metaobjectsdev/metadata";
6
+
7
+ export interface LoadedModel {
8
+ root: MetaRoot;
9
+ warnings: string[];
10
+ sourceDirs: string[];
11
+ }
12
+
13
+ /** Load N metadata source dirs into ONE root via a staging dir of symlinks. */
14
+ export async function loadModel(sourceDirs: string[]): Promise<LoadedModel> {
15
+ const staging = mkdtempSync(join(tmpdir(), "metadocs-"));
16
+ try {
17
+ const usedBasenames = new Set<string>();
18
+ for (const dir of sourceDirs) {
19
+ const baseName = basename(dir);
20
+ if (usedBasenames.has(baseName)) {
21
+ throw new Error(`duplicate source dir basename: ${baseName}`);
22
+ }
23
+ usedBasenames.add(baseName);
24
+ symlinkSync(resolve(dir), join(staging, baseName));
25
+ }
26
+ const registry = composeRegistry([coreTypesProvider, dbProvider, docProvider, promptProvider, uiProvider]);
27
+ const result = await MetaDataLoader.fromDirectory(staging, { registry, strict: false });
28
+ if (result.errors.length > 0) {
29
+ throw new Error(`metadata load failed:\n${result.errors.map((e) => String(e)).join("\n")}`);
30
+ }
31
+ return {
32
+ root: result.root,
33
+ warnings: result.warnings.map((w) => w.message),
34
+ sourceDirs: sourceDirs.map((d) => basename(resolve(d))),
35
+ };
36
+ } finally {
37
+ rmSync(staging, { recursive: true, force: true });
38
+ }
39
+ }
40
+
41
+ /** Which top-level source dir a node came from (first file path segment of its source envelope). */
42
+ export function treeOf(node: MetaData, model: LoadedModel): string {
43
+ const src = node.source as { files?: string[] };
44
+ const f = src.files?.[0] ?? "";
45
+ const seg = f.replace(/\\/g, "/").split("/")[0] ?? "";
46
+ return model.sourceDirs.includes(seg) ? seg : (model.sourceDirs[0] ?? "");
47
+ }
package/src/mermaid.ts ADDED
@@ -0,0 +1,142 @@
1
+ export interface ErEdge { parent: string; child: string; label: string; cardinality?: "one" | "many" | undefined; }
2
+
3
+ // base theme is the only theme that honors custom themeVariables; the built-in `dark`
4
+ // theme renders attribute-bearing ERDs unreadably. Surfaces match the site's dark palette.
5
+ export const THEME_INIT =
6
+ `%%{init: {'theme':'base','themeVariables':{'darkMode':true,'background':'#0b1220',` +
7
+ `'primaryColor':'#1e2a3a','primaryTextColor':'#cbd5e1','primaryBorderColor':'#4a7fa5',` +
8
+ `'lineColor':'#64748b','secondaryColor':'#1a2535','tertiaryColor':'#0f1826',` +
9
+ `'fontSize':'13px'}}}%%\n`;
10
+
11
+ const safe = (s: string) => s.replace(/"/g, "'");
12
+ const nodeId = (s: string) => s.replace(/[^a-zA-Z0-9_]/g, "_");
13
+ const edgeSort = (a: ErEdge, b: ErEdge) =>
14
+ a.parent.localeCompare(b.parent) || a.child.localeCompare(b.child) || a.label.localeCompare(b.label);
15
+
16
+ export function packageFlowchart(edges: { from: string; to: string; n: number }[], counts: Map<string, number>): string {
17
+ const lines = [THEME_INIT + "flowchart LR"];
18
+ const used = new Set(edges.flatMap((e) => [e.from, e.to]));
19
+ for (const p of [...used].sort()) lines.push(` ${p}["${safe(p)} · ${counts.get(p) ?? 0}"]`);
20
+ for (const e of [...edges].sort((a, b) => a.from.localeCompare(b.from) || a.to.localeCompare(b.to)))
21
+ lines.push(` ${e.from} -->|${e.n}| ${e.to}`);
22
+ return lines.join("\n");
23
+ }
24
+
25
+ export function inheritanceTree(rows: { name: string; level: number; self?: boolean }[]): string {
26
+ const lines = [THEME_INIT + "flowchart TD"];
27
+ const ordered = [...rows].sort((a, b) => a.level - b.level || a.name.localeCompare(b.name));
28
+ for (const r of ordered) lines.push(` ${nodeId(r.name)}["${safe(r.name)}"]${r.self ? ":::self" : ""}`);
29
+ // connect each node to the alphabetically-first node one level below it whose ancestor it is:
30
+ // for a simple chain+children, link consecutive levels deterministically.
31
+ const byLevel = new Map<number, string[]>();
32
+ for (const r of ordered) (byLevel.get(r.level) ?? byLevel.set(r.level, []).get(r.level)!).push(nodeId(r.name));
33
+ const levels = [...byLevel.keys()].sort((a, b) => a - b);
34
+ for (let i = 1; i < levels.length; i++) {
35
+ const parents = byLevel.get(levels[i - 1]!)!;
36
+ const parent = parents[parents.length - 1]; // the chain node at the shallower level
37
+ for (const child of byLevel.get(levels[i]!)!.sort()) lines.push(` ${parent} --> ${child}`);
38
+ }
39
+ lines.push(" classDef self fill:#1e3a5f,stroke:#60a5fa,color:#e2e8f0,font-weight:bold");
40
+ return lines.join("\n");
41
+ }
42
+
43
+ // ---- v2 diagram system: domain palette + rich/simple emitters ----
44
+ export const RICH_MAX = 8;
45
+ const ROLE_STROKE: Record<string, string> = { focal: "#60a5fa", view: "#2dd4bf", external: "#334155", normal: "#3b5170" };
46
+ // Deterministic domain palette — a fixed set of dark-surface color slots. Every
47
+ // package's leaf name hashes into a slot, so the same package always renders the
48
+ // same color, with no hardcoded domain→color vocabulary baked in.
49
+ const PALETTE: { fill: string; stroke: string; text: string }[] = [
50
+ { fill: "#1e3a5f", stroke: "#60a5fa", text: "#93c5fd" },
51
+ { fill: "#3b2f1e", stroke: "#fbbf24", text: "#fde68a" },
52
+ { fill: "#3f2d5c", stroke: "#a78bfa", text: "#ede9fe" },
53
+ { fill: "#3f1f2e", stroke: "#fb7185", text: "#fecdd3" },
54
+ { fill: "#14342b", stroke: "#34d399", text: "#a7f3d0" },
55
+ { fill: "#1f2937", stroke: "#94a3b8", text: "#e2e8f0" },
56
+ { fill: "#2a2440", stroke: "#818cf8", text: "#e0e7ff" },
57
+ { fill: "#1a2e35", stroke: "#22d3ee", text: "#cffafe" },
58
+ { fill: "#332018", stroke: "#fb923c", text: "#fed7aa" },
59
+ { fill: "#1c2431", stroke: "#64748b", text: "#cbd5e1" },
60
+ ];
61
+ export function domainColor(pkg: string): { fill: string; stroke: string; text: string } {
62
+ const leaf = pkg.split("::").pop() ?? pkg;
63
+ // stable slot: hash the leaf name deterministically into the palette
64
+ let h = 0; for (let i = 0; i < leaf.length; i++) h = (h * 31 + leaf.charCodeAt(i)) >>> 0;
65
+ return PALETTE[h % PALETTE.length]!;
66
+ }
67
+ const cls = (pkg: string) => `d_${(pkg.split("::").pop() ?? pkg).replace(/[^a-zA-Z0-9]/g, "_")}`;
68
+
69
+ export interface ErAttr { type: string; name: string; key: "PK" | "FK" | "UK" | ""; note: string; }
70
+ export interface ErNode { name: string; pkg: string; role: "focal" | "view" | "external" | "normal"; kind?: string; attrs: ErAttr[]; more: number; }
71
+ // erDiagram can't vary box shape, so KIND is encoded as a border dash + a glyph prefix in the box title.
72
+ const KIND_DASH: Record<string, string> = { value: ",stroke-dasharray:5 3", projection: ",stroke-dasharray:2 2" };
73
+ const KIND_GLYPH: Record<string, string> = { entity: "▭", value: "⬭", projection: "▱" };
74
+ const kindGlyph = (kind?: string) => KIND_GLYPH[kind ?? ""] ?? "▭";
75
+
76
+ export function erDiagramRich(nodes: ErNode[], edges: ErEdge[]): string {
77
+ const lines = [THEME_INIT + "erDiagram"];
78
+ for (const n of [...nodes].sort((a, b) => a.name.localeCompare(b.name))) {
79
+ lines.push(` ${nodeId(n.name)}["${kindGlyph(n.kind)} ${safe(n.name)}"] {`);
80
+ for (const a of n.attrs) {
81
+ const keyPart = a.key ? ` ${a.key}` : "";
82
+ lines.push(` ${a.type} ${a.name}${keyPart} "${safe(a.note)}"`);
83
+ }
84
+ if (n.more > 0) lines.push(` _ plus "+${n.more} more"`);
85
+ lines.push(" }");
86
+ }
87
+ for (const e of [...edges].sort(edgeSort)) {
88
+ const conn = e.cardinality === "many" ? "}o--o{" : "||--o{";
89
+ lines.push(` ${nodeId(e.parent)} ${conn} ${nodeId(e.child)} : "${safe(e.label)}"`);
90
+ }
91
+ // one classDef per entity: fill = domain, stroke = role
92
+ for (const n of [...nodes].sort((a, b) => a.name.localeCompare(b.name))) {
93
+ const dc = domainColor(n.pkg);
94
+ lines.push(` ${nodeId(n.name)}:::c_${nodeId(n.name)}`);
95
+ lines.push(` classDef c_${nodeId(n.name)} fill:${dc.fill},stroke:${ROLE_STROKE[n.role]},color:#cbd5e1${KIND_DASH[n.kind ?? ""] ?? ""}`);
96
+ }
97
+ return lines.join("\n");
98
+ }
99
+
100
+ // node shape encodes object KIND: entity = rectangle, value object = stadium (rounded ends),
101
+ // projection/view = parallelogram. Domain is the fill color; kind is the silhouette.
102
+ function shapeDecl(id: string, label: string, kind?: string): string {
103
+ const l = `"${label}"`;
104
+ if (kind === "value") return `${id}([${l}])`;
105
+ if (kind === "projection") return `${id}[/${l}/]`;
106
+ return `${id}[${l}]`; // entity / default
107
+ }
108
+
109
+ export function flowchartDomain(
110
+ nodes: { name: string; pkg: string; kind?: string }[],
111
+ edges: { from: string; to: string; label?: string; style?: "dashed" | undefined }[],
112
+ ): { mermaid: string; legend: { pkg: string; fill: string; stroke: string }[] } {
113
+ const lines = [THEME_INIT + "flowchart LR"];
114
+ const kindByName = new Map(nodes.map((n) => [n.name, n.kind]));
115
+ // collect all node names (from node list + edge endpoints), deduplicate, declare with explicit labels
116
+ const allNodeNames = new Set<string>([
117
+ ...nodes.map((n) => n.name),
118
+ ...edges.flatMap((e) => [e.from, e.to]),
119
+ ]);
120
+ for (const name of [...allNodeNames].sort()) lines.push(` ${shapeDecl(nodeId(name), safe(name), kindByName.get(name))}`);
121
+ // edge labels must not contain flowchart-breaking chars (parens, pipes, brackets, braces)
122
+ const edgeLabel = (s: string) => s.replace(/["|(){}\[\]]/g, "").replace(/\s+/g, " ").trim();
123
+ for (const e of [...edges].sort((a, b) => a.from.localeCompare(b.from) || a.to.localeCompare(b.to))) {
124
+ const lbl = e.label ? edgeLabel(e.label) : "";
125
+ const arrow = e.style === "dashed" ? "-.->" : "-->";
126
+ lines.push(lbl ? ` ${nodeId(e.from)} ${arrow}|${lbl}| ${nodeId(e.to)}` : ` ${nodeId(e.from)} ${arrow} ${nodeId(e.to)}`);
127
+ }
128
+ // group node names by domain class, assign, and emit one classDef per used domain
129
+ const byCls = new Map<string, { pkg: string; ids: string[] }>();
130
+ for (const n of [...nodes].sort((a, b) => a.name.localeCompare(b.name))) {
131
+ const c = cls(n.pkg);
132
+ (byCls.get(c) ?? byCls.set(c, { pkg: n.pkg, ids: [] }).get(c)!).ids.push(nodeId(n.name));
133
+ }
134
+ for (const [c, g] of [...byCls.entries()].sort(([a], [b]) => a.localeCompare(b))) {
135
+ const dc = domainColor(g.pkg);
136
+ lines.push(` class ${g.ids.sort().join(",")} ${c}`);
137
+ lines.push(` classDef ${c} fill:${dc.fill},stroke:${dc.stroke},color:${dc.text}`);
138
+ }
139
+ const legend = [...new Map(nodes.map((n) => [n.pkg, n.pkg])).keys()].sort()
140
+ .map((pkg) => ({ pkg, fill: domainColor(pkg).fill, stroke: domainColor(pkg).stroke }));
141
+ return { mermaid: lines.join("\n"), legend };
142
+ }
@@ -0,0 +1,43 @@
1
+ export interface HighlightResult { html: string; toc: { name: string; anchor: string }[]; refs: string[]; }
2
+
3
+ const esc = (s: string) => s.replace(/&/g, "&amp;").replace(/</g, "&lt;").replace(/>/g, "&gt;").replace(/"/g, "&quot;");
4
+
5
+ export function highlightMustache(src: string, resolveHref: (p: string) => string | undefined): HighlightResult {
6
+ let out = "";
7
+ const toc: { name: string; anchor: string }[] = [];
8
+ const refs: string[] = [];
9
+ let depth = 0;
10
+ let i = 0;
11
+ while (i < src.length) {
12
+ const open = src.indexOf("{{", i);
13
+ if (open === -1) { out += esc(src.slice(i)); break; }
14
+ out += esc(src.slice(i, open));
15
+ const triple = src.startsWith("{{{", open);
16
+ const close = src.indexOf(triple ? "}}}" : "}}", open);
17
+ if (close === -1) { out += esc(src.slice(open)); break; }
18
+ const end = close + (triple ? 3 : 2);
19
+ const rawTok = src.slice(open, end);
20
+ const inner = src.slice(open + (triple ? 3 : 2), close).trim();
21
+ const sigil = triple ? "{" : inner[0] ?? "";
22
+ const name = triple ? inner : inner.replace(/^[#^\/>!&]\s*/, "");
23
+ const span = (cls: string, extra = "") => `<span class="${cls}"${extra}>${esc(rawTok)}</span>`;
24
+ if (!triple && sigil === "!") out += span("mu-com");
25
+ else if (!triple && sigil === ">") out += span("mu-par");
26
+ else if (!triple && (sigil === "#" || sigil === "^")) {
27
+ refs.push(name);
28
+ const href = resolveHref(name);
29
+ if (depth === 0 && sigil === "#") { toc.push({ name, anchor: `sec-${name}` }); }
30
+ const idAttr = depth === 0 && sigil === "#" ? ` id="sec-${esc(name)}"` : "";
31
+ out += href ? `<a href="${esc(href)}" class="mu-sec"${idAttr}>${esc(rawTok)}</a>` : span("mu-sec mu-unresolved", idAttr);
32
+ depth++;
33
+ } else if (!triple && sigil === "/") { depth = Math.max(0, depth - 1); out += span("mu-sec"); }
34
+ else {
35
+ refs.push(name);
36
+ const cls = triple || sigil === "&" ? "mu-raw" : "mu-var";
37
+ const href = resolveHref(name);
38
+ out += href ? `<a href="${esc(href)}" class="${cls}">${esc(rawTok)}</a>` : span(`${cls} mu-unresolved`);
39
+ }
40
+ i = end;
41
+ }
42
+ return { html: out, toc, refs };
43
+ }
@@ -0,0 +1,33 @@
1
+ import { readdirSync, readFileSync, statSync } from "node:fs";
2
+ import { join } from "node:path";
3
+ import { parse } from "yaml";
4
+ import { LinkGraph, fqnOf } from "./link-graph";
5
+
6
+ export interface PackageDoc { title: string; description: string; }
7
+
8
+ export function harvestPackageDocs(sourceDirs: string[]): Map<string, PackageDoc> {
9
+ const out = new Map<string, PackageDoc>();
10
+ const walk = (d: string) => {
11
+ for (const e of readdirSync(d)) {
12
+ const p = join(d, e);
13
+ if (statSync(p).isDirectory()) walk(p);
14
+ else if (e === "_package.yaml") {
15
+ try {
16
+ const y = parse(readFileSync(p, "utf8")) as { metadata?: { package?: string; title?: string; description?: string } };
17
+ const m = y?.metadata;
18
+ if (m?.package) out.set(m.package, { title: String(m.title ?? ""), description: String(m.description ?? "") });
19
+ } catch { /* malformed _package.yaml is skipped; coverage/anomalies surface it elsewhere */ }
20
+ }
21
+ }
22
+ };
23
+ for (const d of sourceDirs) walk(d);
24
+ return out;
25
+ }
26
+
27
+ export function keyEntities(pkg: string, g: LinkGraph, n = 4): { name: string; href: string; inbound: number }[] {
28
+ return g.nodes()
29
+ .filter((dn) => dn.kind === "object" && dn.pkg === pkg && !dn.node.isAbstract)
30
+ .map((dn) => ({ name: dn.name, href: dn.href, inbound: g.refsTo(fqnOf(dn.node)).filter((r) => r.kind !== "extends").length }))
31
+ .sort((a, b) => b.inbound - a.inbound || a.name.localeCompare(b.name))
32
+ .slice(0, n);
33
+ }
@@ -0,0 +1,26 @@
1
+ import { readFileSync } from "node:fs";
2
+ import { dirname, join, resolve } from "node:path";
3
+ import { fileURLToPath } from "node:url";
4
+
5
+ /** The 9 mustache templates the site is built from (basenames under templates/). */
6
+ export const SITE_TEMPLATE_NAMES: readonly string[] = [
7
+ "chrome-head.mustache",
8
+ "chrome-foot.mustache",
9
+ "index.html.mustache",
10
+ "package.html.mustache",
11
+ "object.html.mustache",
12
+ "prompt.html.mustache",
13
+ "output.html.mustache",
14
+ "enums.html.mustache",
15
+ "coverage.html.mustache",
16
+ ];
17
+
18
+ /** The site's themeable assets (basenames under assets/). search-index.json is generated, not themed. */
19
+ export const SITE_ASSET_NAMES: readonly string[] = ["site.css", "site.js"];
20
+
21
+ /** Read a bundled template or asset by basename (for scaffolding into a consumer). */
22
+ export function readSiteFile(kind: "template" | "asset", name: string): string {
23
+ const selfDir = dirname(fileURLToPath(import.meta.url));
24
+ const dir = resolve(selfDir, kind === "template" ? "../templates" : "../assets");
25
+ return readFileSync(join(dir, name), "utf8");
26
+ }