@rse/specbook 0.9.6 → 0.9.8

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 (41) hide show
  1. package/README.md +65 -10
  2. package/dst/specbook-api.js +19 -18
  3. package/dst/specbook-cli.js +9 -6
  4. package/dst/specbook-cmd-describe.js +1 -3
  5. package/dst/specbook-cmd-export.js +24 -17
  6. package/dst/specbook-cmd-init.d.ts +1 -1
  7. package/dst/specbook-cmd-init.js +5 -4
  8. package/dst/specbook-cmd-lint.d.ts +0 -1
  9. package/dst/specbook-cmd-lint.js +29 -5
  10. package/dst/specbook-config.d.ts +1 -1
  11. package/dst/specbook-config.js +90 -71
  12. package/dst/specbook-diagnostic.js +4 -1
  13. package/dst/specbook-diagram.d.ts +8 -3
  14. package/dst/specbook-diagram.js +101 -105
  15. package/dst/specbook-export-ast.d.ts +1 -1
  16. package/dst/specbook-export-ast.js +1 -1
  17. package/dst/specbook-export-common.d.ts +2 -1
  18. package/dst/specbook-export-common.js +12 -14
  19. package/dst/specbook-export-html-search.js +10 -14
  20. package/dst/specbook-export-html.css +24 -29
  21. package/dst/specbook-export-html.js +101 -83
  22. package/dst/specbook-export-md.d.ts +1 -1
  23. package/dst/specbook-export-md.js +56 -23
  24. package/dst/specbook-export-pdf.d.ts +4 -2
  25. package/dst/specbook-export-pdf.js +48 -38
  26. package/dst/specbook-format-schema.js +6 -3
  27. package/dst/specbook-format.md +47 -20
  28. package/dst/specbook-link.d.ts +1 -0
  29. package/dst/specbook-link.js +7 -4
  30. package/dst/specbook-mcp.js +17 -10
  31. package/dst/specbook-parse-common.d.ts +2 -1
  32. package/dst/specbook-parse-common.js +2 -0
  33. package/dst/specbook-parse-semantic.d.ts +4 -2
  34. package/dst/specbook-parse-semantic.js +114 -43
  35. package/dst/specbook-parse-syntax.js +179 -137
  36. package/dst/specbook-parse-value.d.ts +1 -1
  37. package/dst/specbook-parse-value.js +9 -5
  38. package/dst/specbook-parse.d.ts +2 -5
  39. package/dst/specbook-parse.js +27 -34
  40. package/dst/specbook-verbose.js +2 -1
  41. package/package.json +1 -1
@@ -4,26 +4,79 @@
4
4
  ** Licensed under Apache 2.0 <https://spdx.org/licenses/Apache-2.0>
5
5
  */
6
6
  import * as fs from "node:fs";
7
- import { parse as parseYaml, parseDocument, YAMLParseError } from "yaml";
7
+ import { parseDocument, isNode, LineCounter } from "yaml";
8
8
  import * as v from "valibot";
9
9
  import { Schema } from "./specbook-format-schema.js";
10
- import { compileValueExpr } from "./specbook-parse-value.js";
11
- /* determine line/column of a YAML document path via the CST */
12
- const lineColOfPath = (yaml, cst, path) => {
10
+ import { compileValueExpr, anchored } from "./specbook-parse-value.js";
11
+ /* determine line/column of a YAML document path via the node ranges
12
+ of the parsed document, falling back to the closest ancestor node */
13
+ const lineColOfPath = (doc, lines, path) => {
13
14
  for (let i = path.length; i >= 0; i--) {
14
- const node = cst.getIn(path.slice(0, i), true);
15
- if (typeof node === "object" && node !== null
16
- && "range" in node && Array.isArray(node.range)) {
17
- const start = node.range[0];
18
- const lines = yaml.slice(0, start).split("\n");
19
- return { line: lines.length, column: lines[lines.length - 1].length + 1 };
15
+ const node = doc.getIn(path.slice(0, i), true);
16
+ const start = isNode(node) ? node.range?.[0] : undefined;
17
+ if (start !== undefined) {
18
+ const pos = lines.linePos(start);
19
+ return { line: pos.line, column: pos.col };
20
20
  }
21
21
  }
22
22
  return { line: 1, column: 1 };
23
23
  };
24
+ /* check the constraints of a structurally valid configuration which
25
+ are beyond its schema: sibling objects have to stay distinctly
26
+ resolvable, "file" fields are allowed on the first (artifact) level
27
+ only, property value expressions have to be syntactically valid, and
28
+ the names of non-artifact objects have to be valid patterns (as they
29
+ are compiled into regular expressions) */
30
+ const checkConstraints = (config, file, posOfPath) => {
31
+ const diagnostics = new Array();
32
+ const diagnose = (path, message) => {
33
+ const pos = posOfPath(path);
34
+ diagnostics.push({ file, line: pos.line, column: pos.column, message });
35
+ };
36
+ const check = (objects, path, depth) => {
37
+ /* the sibling objects are resolved by kind and id on the first
38
+ (artifact) level and by kind alone below it, so a sibling
39
+ colliding on this key stays unreachable dead configuration */
40
+ const seen = new Set();
41
+ for (const [i, object] of objects.entries()) {
42
+ const at = [...path, i];
43
+ const ident = depth > 1 ? object.kind : `${object.kind}:${object.id ?? object.name ?? ""}`;
44
+ if (seen.has(ident))
45
+ diagnose([...at, "kind"], `object "${ident}" collides with a preceding sibling object (the later one is unreachable)`);
46
+ else
47
+ seen.add(ident);
48
+ if (depth > 1 && object.file !== undefined)
49
+ diagnose([...at, "file"], `"file" field is only allowed on the first (artifact) level (found on level ${depth})`);
50
+ for (const [j, prop] of (object.props ?? []).entries()) {
51
+ if (prop.value === undefined)
52
+ continue;
53
+ try {
54
+ compileValueExpr(prop.value);
55
+ }
56
+ catch (err) {
57
+ diagnose([...at, "props", j, "value"], `invalid value expression "${prop.value}": ` +
58
+ (err instanceof Error ? err.message : String(err)));
59
+ }
60
+ }
61
+ if (depth > 1 && object.name !== undefined) {
62
+ try {
63
+ anchored(object.name);
64
+ }
65
+ catch (err) {
66
+ diagnose([...at, "name"], `invalid name pattern "${object.name}": ` +
67
+ (err instanceof Error ? err.message : String(err)));
68
+ }
69
+ }
70
+ if (object.childs !== undefined)
71
+ check(object.childs, [...at, "childs"], depth + 1);
72
+ }
73
+ };
74
+ check(config, [], 1);
75
+ return diagnostics;
76
+ };
24
77
  /* load and validate a YAML schema configuration file */
25
78
  export const loadConfig = (file) => {
26
- const diagnostics = [];
79
+ const diagnostics = new Array();
27
80
  /* read the configuration file */
28
81
  let yaml;
29
82
  try {
@@ -33,74 +86,40 @@ export const loadConfig = (file) => {
33
86
  diagnostics.push({ file, line: 1, column: 1,
34
87
  message: "cannot read configuration file: " +
35
88
  (err instanceof Error ? err.message : String(err)) });
36
- return { config: null, diagnostics };
89
+ return { diagnostics };
37
90
  }
38
- /* syntactically parse the YAML content */
39
- let obj;
40
- try {
41
- obj = parseYaml(yaml);
42
- }
43
- catch (err) {
44
- const e = err instanceof YAMLParseError ? err : null;
45
- diagnostics.push({
46
- file,
47
- line: e?.linePos?.[0]?.line ?? 1,
48
- column: e?.linePos?.[0]?.col ?? 1,
49
- message: "invalid YAML syntax: " +
50
- (err instanceof Error ? err.message.replace(/:[\s\S]*$/, "") : String(err))
51
- });
52
- return { config: null, diagnostics };
91
+ /* syntactically parse the YAML content into its document, keeping
92
+ the line positions for the diagnostics */
93
+ const lines = new LineCounter();
94
+ const doc = parseDocument(yaml, { lineCounter: lines });
95
+ if (doc.errors.length > 0) {
96
+ /* strip the position and the source snippet the YAML parser
97
+ appends to its message, as the diagnostic carries the
98
+ position itself */
99
+ for (const err of doc.errors)
100
+ diagnostics.push({
101
+ file,
102
+ line: err.linePos?.[0].line ?? 1,
103
+ column: err.linePos?.[0].col ?? 1,
104
+ message: "invalid YAML syntax: " + err.message.replace(/ at line \d+, column \d+(?::\n[\s\S]*)?$/, "")
105
+ });
106
+ return { diagnostics };
53
107
  }
54
- /* semantically validate against the schema of the configuration
55
- (the CST is parsed lazily, as it is needed for positions only) */
56
- let cst;
57
- const posOfPath = (path) => {
58
- cst ??= parseDocument(yaml);
59
- return lineColOfPath(yaml, cst, path);
60
- };
108
+ const obj = doc.toJS();
109
+ /* semantically validate against the schema of the configuration */
110
+ const posOfPath = (path) => lineColOfPath(doc, lines, path);
61
111
  const result = v.safeParse(Schema, obj);
62
112
  if (!result.success) {
63
113
  for (const issue of result.issues) {
64
114
  const path = (issue.path ?? []).map((item) => item.key);
65
115
  const pos = posOfPath(path);
116
+ const where = path.length > 0 ? `${path.join(".")}: ` : "";
66
117
  diagnostics.push({ file, line: pos.line, column: pos.column,
67
- message: `invalid configuration: ${path.join(".")}: ${issue.message}` });
118
+ message: `invalid configuration: ${where}${issue.message}` });
68
119
  }
69
- return { config: null, diagnostics };
120
+ return { diagnostics };
70
121
  }
71
- /* reject "file" fields below the first (artifact) level */
72
- const checkFileField = (objects, path, depth) => {
73
- objects.forEach((object, i) => {
74
- if (depth > 1 && object.file !== undefined) {
75
- const pos = posOfPath([...path, i, "file"]);
76
- diagnostics.push({ file, line: pos.line, column: pos.column,
77
- message: `"file" field is only allowed on the first (artifact) level (found on level ${depth})` });
78
- }
79
- if (object.childs !== undefined)
80
- checkFileField(object.childs, [...path, i, "childs"], depth + 1);
81
- });
82
- };
83
- checkFileField(result.output, [], 1);
84
- /* reject syntactically invalid property value expressions */
85
- const checkValueExpr = (objects, path) => {
86
- objects.forEach((object, i) => {
87
- (object.props ?? []).forEach((prop, j) => {
88
- if (prop.value === undefined)
89
- return;
90
- try {
91
- compileValueExpr(prop.value);
92
- }
93
- catch (err) {
94
- const pos = posOfPath([...path, i, "props", j, "value"]);
95
- diagnostics.push({ file, line: pos.line, column: pos.column,
96
- message: `invalid value expression "${prop.value}": ` +
97
- (err instanceof Error ? err.message : String(err)) });
98
- }
99
- });
100
- if (object.childs !== undefined)
101
- checkValueExpr(object.childs, [...path, i, "childs"]);
102
- });
103
- };
104
- checkValueExpr(result.output, []);
105
- return { config: diagnostics.length === 0 ? result.output : null, diagnostics };
122
+ /* check the constraints beyond the structural schema */
123
+ diagnostics.push(...checkConstraints(result.output, file, posOfPath));
124
+ return { config: diagnostics.length === 0 ? result.output : undefined, diagnostics };
106
125
  };
@@ -9,7 +9,8 @@ import sourceCodeError from "source-code-error";
9
9
  export const renderDiagnostic = (diagnostic) => `${diagnostic.file}:${diagnostic.line}:${diagnostic.column}: ${diagnostic.message}`;
10
10
  /* render a diagnostic as a multi-line message with the affected
11
11
  source snippet, falling back to the single-line message when the
12
- source file is unreadable (e.g. the file is a directory) */
12
+ source file is unreadable (e.g. the file is a directory) or empty
13
+ (as there is no snippet to show and sourceCodeError rejects it) */
13
14
  export const renderDiagnosticVerbose = (diagnostic, colors = false) => {
14
15
  let code;
15
16
  try {
@@ -18,6 +19,8 @@ export const renderDiagnosticVerbose = (diagnostic, colors = false) => {
18
19
  catch {
19
20
  return `${renderDiagnostic(diagnostic)}\n`;
20
21
  }
22
+ if (code === "")
23
+ return `${renderDiagnostic(diagnostic)}\n`;
21
24
  return sourceCodeError({ message: diagnostic.message, filename: diagnostic.file,
22
25
  code, line: diagnostic.line, column: diagnostic.column, colors });
23
26
  };
@@ -1,9 +1,14 @@
1
1
  import type { Spec, SpecObject } from "./specbook-format-spec.js";
2
- import type { Schema } from "./specbook-format-schema.js";
3
- import { type ParseContext } from "./specbook-parse-common.js";
2
+ import type { Schema, SchemaDiagram } from "./specbook-format-schema.js";
3
+ import type { ParseContext } from "./specbook-parse-common.js";
4
+ export interface DiagramError {
5
+ reason: string;
6
+ object?: SpecObject;
7
+ }
4
8
  export interface DiagramResult {
5
9
  spec?: string;
6
- errors: string[];
10
+ config?: SchemaDiagram["config"];
11
+ errors: DiagramError[];
7
12
  }
8
13
  export declare const specDiagrams: (specification: Spec, config: Schema) => Map<SpecObject, DiagramResult>;
9
14
  export declare const validateDiagrams: (ctx: ParseContext, specification: Spec, config: Schema) => void;
@@ -3,13 +3,11 @@
3
3
  ** Copyright (c) 2026 Dr. Ralf S. Engelschall <rse@engelschall.com>
4
4
  ** Licensed under Apache 2.0 <https://spdx.org/licenses/Apache-2.0>
5
5
  */
6
- import { referenceRegex, buildLinkIndex, resolveUnique, resolveSet, anchorPaths } from "./specbook-link.js";
6
+ import { referenceRegex, buildLinkIndex, resolveUnique, resolveSet, anchorPaths, expandReferences, plainText } from "./specbook-link.js";
7
+ import { collectSchemas } from "./specbook-parse-semantic.js";
7
8
  /* the single Wiki-style reference match (the non-global sibling of
8
9
  the imported, global "referenceRegex") */
9
- const referenceOnce = /\[\[([^[\]]+)\]\]/;
10
- /* strip the inline code markup of a name or property value
11
- (preserved in the AST for rendering) for labeling purposes */
12
- const plainText = (text) => text.replace(/`/g, "");
10
+ const referenceOnce = new RegExp(referenceRegex.source);
13
11
  /* render a text as a Gradia atom: a bareword where possible
14
12
  (no whitespace, no special characters, no "--"), a quoted
15
13
  string otherwise */
@@ -37,11 +35,9 @@ const configValue = (value) => {
37
35
  const renderSpec = (diagram, type, center, nodes, edges, index, anchors) => {
38
36
  const lines = [`#type ${type}`];
39
37
  const config = diagram.config ?? {};
40
- for (const key of Object.keys(config)) {
41
- const value = config[key];
38
+ for (const [key, value] of Object.entries(config))
42
39
  if (value !== undefined)
43
40
  lines.push(`#config ${key} ${configValue(value)}`);
44
- }
45
41
  lines.push("");
46
42
  for (const node of nodes) {
47
43
  const anchor = anchors.get(node) ?? node.id;
@@ -57,10 +53,7 @@ const renderSpec = (diagram, type, center, nodes, edges, index, anchors) => {
57
53
  for (const key of diagram.properties ?? []) {
58
54
  const value = node.properties.find((property) => property.key === key)?.value;
59
55
  if (value !== undefined) {
60
- const text = value.replace(referenceRegex, (_, reference) => {
61
- const target = resolveUnique(index, reference.trim()).target;
62
- return target !== undefined ? target.name : reference.trim();
63
- });
56
+ const text = expandReferences(value, (reference) => resolveUnique(index, reference).target?.name ?? reference);
64
57
  attrs.push(`${atom(key)}: ${atom(plainText(text))}`);
65
58
  }
66
59
  }
@@ -78,56 +71,14 @@ const renderSpec = (diagram, type, center, nodes, edges, index, anchors) => {
78
71
  }
79
72
  return lines.join("\n") + "\n";
80
73
  };
81
- /* derive the Gradia spec of a single object from its "diagram:"
82
- schema configuration (the returned spec is absent whenever the
83
- configured diagram situation is invalid) */
84
- const deriveDiagram = (object, diagram, index, anchors, parents) => {
85
- const type = diagram.type ?? "graph";
86
- const errors = new Array();
87
- /* resolve a comma-separated "[[...]]" reference pattern string
88
- into its (deduplicated, order-preserving) object match set */
89
- const resolvePatterns = (value, field) => {
90
- const matches = new Array();
91
- const seen = new Set();
92
- for (const m of value.matchAll(referenceRegex)) {
93
- const pattern = m[1].trim();
94
- const set = resolveSet(index, pattern);
95
- if (set.length === 0)
96
- errors.push(`unresolvable diagram "${field}" pattern "[[${pattern}]]"`);
97
- for (const match of set) {
98
- if (!seen.has(match)) {
99
- seen.add(match);
100
- matches.push(match);
101
- }
102
- }
103
- }
104
- return matches;
105
- };
106
- /* determine the node objects (default: the current object plus
107
- all objects below it) and the edge objects (default: none),
108
- with the edge objects never acting as nodes themselves */
109
- let nodes;
110
- if (diagram.nodes !== undefined)
111
- nodes = resolvePatterns(diagram.nodes, "nodes");
112
- else {
113
- nodes = new Array();
114
- const walk = (o) => {
115
- nodes.push(o);
116
- o.childs.forEach(walk);
117
- };
118
- walk(object);
119
- }
120
- const edgeObjects = diagram.edges !== undefined ? resolvePatterns(diagram.edges, "edges") : [];
121
- const edgeSet = new Set(edgeObjects);
122
- nodes = nodes.filter((node) => !edgeSet.has(node));
123
- const nodeSet = new Set(nodes);
124
- /* derive the edges: from the "[[...]]" references of the node
125
- objects (per the "links" selection) and from the edge objects by
126
- convention (source: parent object, target: first reference in the
127
- property values, name: object name, arity: "ARITY" property),
128
- overridable via "edgeSource"/"edgeTarget"/"edgeArity" (a "grid"
129
- diagram is edge-less by definition, so no edges are derived at all) */
130
- let edges = new Array();
74
+ /* derive the edges of a diagram: from the "[[...]]" references of the
75
+ node objects (per the "links" selection) and from the edge objects by
76
+ convention (source: parent object, target: first reference in the
77
+ property values, name: object name, arity: "ARITY" property),
78
+ overridable via "edgeSource"/"edgeTarget"/"edgeArity" (a "grid"
79
+ diagram is edge-less by definition, so no edges are derived at all) */
80
+ const deriveEdges = (diagram, type, nodes, nodeSet, edgeObjects, index, anchors, parents, errors) => {
81
+ const edges = new Array();
131
82
  if (type !== "grid") {
132
83
  for (const node of nodes) {
133
84
  const texts = node.properties.map((property) => property.value);
@@ -159,13 +110,13 @@ const deriveDiagram = (object, diagram, index, anchors, parents) => {
159
110
  edgeNode(edgeObject, diagram.edgeSource) : parents.get(edgeObject);
160
111
  const target = edgeNode(edgeObject, diagram.edgeTarget);
161
112
  if (source === undefined) {
162
- errors.push(diagram.edgeSource !== undefined ?
163
- `diagram edge object "${edgeObject.name}" carries no resolvable source reference` :
164
- `diagram edge object "${edgeObject.name}" has no parent object as source`);
113
+ errors.push({ object: edgeObject, reason: diagram.edgeSource !== undefined ?
114
+ "carries no resolvable source reference" :
115
+ "has no parent object as source" });
165
116
  continue;
166
117
  }
167
118
  if (target === undefined) {
168
- errors.push(`diagram edge object "${edgeObject.name}" carries no resolvable target reference`);
119
+ errors.push({ object: edgeObject, reason: "carries no resolvable target reference" });
169
120
  continue;
170
121
  }
171
122
  if (!nodeSet.has(source) || !nodeSet.has(target))
@@ -185,14 +136,16 @@ const deriveDiagram = (object, diagram, index, anchors, parents) => {
185
136
  }
186
137
  else {
187
138
  if (diagram.edges !== undefined)
188
- errors.push("\"grid\" diagram cannot carry an \"edges\" configuration");
139
+ errors.push({ reason: "\"grid\" diagram cannot carry an \"edges\" configuration" });
189
140
  if (diagram.hierarchy === true)
190
- errors.push("\"grid\" diagram cannot carry a \"hierarchy\" configuration");
141
+ errors.push({ reason: "\"grid\" diagram cannot carry a \"hierarchy\" configuration" });
142
+ if (diagram.onlyConnected === true)
143
+ errors.push({ reason: "\"grid\" diagram cannot carry an \"onlyConnected\" configuration" });
191
144
  }
192
145
  /* deduplicate the edges (the same reference can occur in
193
146
  multiple texts of the same node object) */
194
147
  const seenEdges = new Set();
195
- edges = edges.filter((edge) => {
148
+ return edges.filter((edge) => {
196
149
  const key = `${anchors.get(edge.source) ?? edge.source.id}` +
197
150
  `\u0000${anchors.get(edge.target) ?? edge.target.id}` +
198
151
  `\u0000${edge.name ?? ""}\u0000${edge.arity ?? ""}`;
@@ -201,16 +154,69 @@ const deriveDiagram = (object, diagram, index, anchors, parents) => {
201
154
  seenEdges.add(key);
202
155
  return true;
203
156
  });
157
+ };
158
+ /* resolve a comma-separated "[[...]]" reference pattern string into
159
+ its (deduplicated, order-preserving) object match set, reporting
160
+ every unresolvable pattern as an error */
161
+ const resolvePatterns = (index, errors, value, field) => {
162
+ const matches = new Array();
163
+ const seen = new Set();
164
+ let found = false;
165
+ for (const m of value.matchAll(referenceRegex)) {
166
+ found = true;
167
+ const pattern = m[1].trim();
168
+ const set = resolveSet(index, pattern);
169
+ if (set.length === 0)
170
+ errors.push({ reason: `unresolvable diagram "${field}" pattern "[[${pattern}]]"` });
171
+ for (const match of set) {
172
+ if (!seen.has(match)) {
173
+ seen.add(match);
174
+ matches.push(match);
175
+ }
176
+ }
177
+ }
178
+ if (!found)
179
+ errors.push({ reason: `diagram "${field}" carries no "[[...]]" reference pattern` });
180
+ return matches;
181
+ };
182
+ /* derive the Gradia spec of a single object from its "diagram:"
183
+ schema configuration (the returned spec is absent whenever the
184
+ configured diagram situation is invalid) */
185
+ const deriveDiagram = (object, diagram, index, anchors, parents) => {
186
+ const type = diagram.type ?? "graph";
187
+ const errors = new Array();
188
+ /* determine the node objects (default: the current object plus
189
+ all objects below it) and the edge objects (default: none),
190
+ with the edge objects never acting as nodes themselves */
191
+ let nodes;
192
+ if (diagram.nodes !== undefined)
193
+ nodes = resolvePatterns(index, errors, diagram.nodes, "nodes");
194
+ else {
195
+ nodes = new Array();
196
+ const walk = (o) => {
197
+ nodes.push(o);
198
+ o.childs.forEach(walk);
199
+ };
200
+ walk(object);
201
+ }
202
+ const edgeObjects = diagram.edges !== undefined ? resolvePatterns(index, errors, diagram.edges, "edges") : [];
203
+ const edgeSet = new Set(edgeObjects);
204
+ nodes = nodes.filter((node) => !edgeSet.has(node));
205
+ const nodeSet = new Set(nodes);
206
+ /* derive the (deduplicated) edges of the diagram */
207
+ let edges = deriveEdges(diagram, type, nodes, nodeSet, edgeObjects, index, anchors, parents, errors);
204
208
  /* determine the center object of a "hub" diagram: the current
205
209
  object for the default "self" configuration, the uniquely
206
210
  resolved object of an explicit "[[...]]" reference otherwise */
207
211
  let center = object;
212
+ if (type !== "hub" && diagram.center !== undefined)
213
+ errors.push({ reason: `"${type}" diagram cannot carry a "center" configuration` });
208
214
  if (type === "hub" && diagram.center !== undefined && diagram.center !== "self") {
209
215
  const reference = diagram.center.match(referenceOnce)?.[1].trim();
210
216
  const resolved = reference !== undefined ? resolveUnique(index, reference) : undefined;
211
217
  if (resolved?.target === undefined) {
212
- errors.push(`${resolved?.ambiguous === true ? "ambiguous" : "unresolvable"}` +
213
- ` diagram "center" reference "${diagram.center}"`);
218
+ errors.push({ reason: (resolved?.ambiguous === true ? "ambiguous" : "unresolvable") +
219
+ ` diagram "center" reference "${diagram.center}"` });
214
220
  return { errors };
215
221
  }
216
222
  center = resolved.target;
@@ -221,7 +227,7 @@ const deriveDiagram = (object, diagram, index, anchors, parents) => {
221
227
  Gradia requires exactly this constrained topology */
222
228
  if (type === "hub") {
223
229
  if (!nodeSet.has(center))
224
- errors.push(`"hub" diagram center "${center.name}" is not part of the node set`);
230
+ errors.push({ reason: `"hub" diagram center "${center.name}" is not part of the node set` });
225
231
  else {
226
232
  edges = edges.filter((edge) => (edge.source === center || edge.target === center)
227
233
  && edge.source !== edge.target);
@@ -244,38 +250,15 @@ const deriveDiagram = (object, diagram, index, anchors, parents) => {
244
250
  nodes = nodes.filter((node) => connected.has(node));
245
251
  }
246
252
  if (nodes.length === 0)
247
- errors.push("diagram yields no nodes");
253
+ errors.push({ reason: "diagram yields no nodes" });
248
254
  if (errors.length > 0)
249
255
  return { errors };
250
256
  /* the "collapse" handling (enabled by default) silently omits a
251
- degenerated diagram, consisting of a single node only, as such a
257
+ degenerate diagram, consisting of a single node only, as such a
252
258
  diagram carries no information beyond the object itself */
253
259
  if (diagram.collapse !== false && nodes.length === 1 && edges.length === 0)
254
260
  return { errors };
255
- return { spec: renderSpec(diagram, type, center, nodes, edges, index, anchors), errors };
256
- };
257
- /* collect the objects whose schema configuration carries a "diagram:"
258
- field (the schema resolution mirrors the semantic validation) */
259
- const collectDiagrams = (specification, config) => {
260
- const diagrams = new Map();
261
- const walk = (object, schema) => {
262
- if (schema.diagram !== undefined)
263
- diagrams.set(object, schema.diagram);
264
- for (const child of object.childs) {
265
- const childSchema = (schema.childs ?? []).find((c) => c.kind === child.kind);
266
- if (childSchema !== undefined)
267
- walk(child, childSchema);
268
- }
269
- };
270
- for (const artifact of specification.artifacts) {
271
- for (const object of artifact.objects) {
272
- const schema = config.find((s) => (s.kind === object.kind && s.id === object.id) || `${s.kind}-${s.id}` === object.id) ??
273
- config.find((s) => (s.name ?? "").toUpperCase() === plainText(object.name).toUpperCase());
274
- if (schema !== undefined)
275
- walk(object, schema);
276
- }
277
- }
278
- return diagrams;
261
+ return { spec: renderSpec(diagram, type, center, nodes, edges, index, anchors), config: diagram.config, errors };
279
262
  };
280
263
  /* derive the Gradia specs of all diagram-configured objects
281
264
  of a specification */
@@ -286,16 +269,29 @@ export const specDiagrams = (specification, config) => {
286
269
  for (const node of index)
287
270
  parents.set(node.object, node.parent?.object);
288
271
  const results = new Map();
289
- for (const [object, diagram] of collectDiagrams(specification, config))
290
- results.set(object, deriveDiagram(object, diagram, index, anchors, parents));
272
+ for (const [object, schema] of collectSchemas(specification, config))
273
+ if (schema.diagram !== undefined)
274
+ results.set(object, deriveDiagram(object, schema.diagram, index, anchors, parents));
291
275
  return results;
292
276
  };
293
277
  /* validate all configured diagrams of a specification, reporting
294
278
  every invalid diagram situation as a file/line-precise diagnostic */
295
279
  export const validateDiagrams = (ctx, specification, config) => {
296
- for (const [object, result] of specDiagrams(specification, config)) {
297
- const meta = ctx.objectMeta.get(object) ?? { file: "", line: 1 };
298
- for (const error of result.errors)
299
- ctx.diagnose(meta.file, meta.line, `invalid diagram on ${object.kind} "${object.name}": ${error}`);
300
- }
280
+ const seen = new Set();
281
+ for (const [object, result] of specDiagrams(specification, config))
282
+ for (const error of result.errors) {
283
+ /* attribute an edge object situation to the offending edge
284
+ object itself and report it just once, as a wildcard
285
+ "edges" pattern lets every diagram walk the very same
286
+ edge objects again */
287
+ const meta = ctx.objectMeta.get(error.object ?? object) ?? { file: "", line: 1 };
288
+ const message = error.object !== undefined ?
289
+ `invalid diagram edge on ${error.object.kind} "${error.object.name}": ${error.reason}` :
290
+ `invalid diagram on ${object.kind} "${object.name}": ${error.reason}`;
291
+ const key = `${meta.file}${meta.line}${message}`;
292
+ if (seen.has(key))
293
+ continue;
294
+ seen.add(key);
295
+ ctx.diagnose(meta.file, meta.line, message);
296
+ }
301
297
  };
@@ -1,4 +1,4 @@
1
1
  import type { Spec } from "./specbook-format-spec.js";
2
2
  import type { Schema } from "./specbook-format-schema.js";
3
3
  export type AstFormat = "json" | "json5" | "yaml" | "toon";
4
- export declare const renderAst: (specification: Spec, format: AstFormat, config?: Schema) => Promise<Buffer>;
4
+ export declare const renderAst: (specification: Spec, format: AstFormat, config?: Schema) => Buffer;
@@ -8,7 +8,7 @@ import { stringify as stringifyYaml } from "yaml";
8
8
  import { encode as encodeToon } from "@toon-format/toon";
9
9
  import { specDiagrams } from "./specbook-diagram.js";
10
10
  /* render the specification AST into a serialization format */
11
- export const renderAst = async (specification, format, config) => {
11
+ export const renderAst = (specification, format, config) => {
12
12
  /* reduce the specification to plain JSON values (ISO date strings) */
13
13
  const plain = JSON.parse(JSON.stringify(specification));
14
14
  /* attach the Gradia specs of the diagram-configured objects as
@@ -1,8 +1,9 @@
1
1
  import type { Spec, SpecObject } from "./specbook-format-spec.js";
2
+ import type { embeddingThemes } from "./specbook-parse-common.js";
2
3
  export declare const escapeHtml: (text: string) => string;
3
4
  export declare const stylesheet: () => string;
4
5
  export declare const searchScript: () => string;
5
- export declare const fallbackLogo: (theme: string) => string;
6
+ export declare const fallbackLogo: (theme: (typeof embeddingThemes)[number]) => string;
6
7
  export declare const isTitleObject: (object: SpecObject) => boolean;
7
8
  export declare const titleObject: (specification: Spec) => SpecObject | undefined;
8
9
  export declare const documentLang: (specification: Spec) => string | undefined;
@@ -3,20 +3,19 @@
3
3
  ** Copyright (c) 2026 Dr. Ralf S. Engelschall <rse@engelschall.com>
4
4
  ** Licensed under Apache 2.0 <https://spdx.org/licenses/Apache-2.0>
5
5
  */
6
- import path from "node:path";
7
6
  import fs from "node:fs";
8
- import { fileURLToPath } from "node:url";
9
7
  /* escape a text for embedding into template HTML (text and attributes) */
10
8
  export const escapeHtml = (text) => text.replace(/&/g, "&amp;").replace(/</g, "&lt;").replace(/>/g, "&gt;")
11
9
  .replace(/"/g, "&quot;").replace(/'/g, "&#39;");
12
10
  /* provide the build-time pre-assembled stylesheet (with the
13
11
  font faces already inlined as base64 data: URIs) */
14
- export const stylesheet = () => fs.readFileSync(path.join(path.dirname(fileURLToPath(import.meta.url)), "specbook-export-html.css"), "utf8");
12
+ export const stylesheet = () => fs.readFileSync(new URL("specbook-export-html.css", import.meta.url), "utf8");
15
13
  /* provide the build-time bundled client-side fuzzy search script */
16
- export const searchScript = () => fs.readFileSync(path.join(path.dirname(fileURLToPath(import.meta.url)), "specbook-export-html-search.js"), "utf8");
14
+ export const searchScript = () => fs.readFileSync(new URL("specbook-export-html-search.js", import.meta.url), "utf8");
17
15
  /* provide a theme variant of the build-time bundled fallback logo of
18
16
  SpecBook itself (as a self-contained data: URL, to keep its styles isolated) */
19
- export const fallbackLogo = (theme) => "data:image/svg+xml;base64," + fs.readFileSync(path.join(path.dirname(fileURLToPath(import.meta.url)), `specbook-export-logo-${theme}.svg`)).toString("base64");
17
+ export const fallbackLogo = (theme) => "data:image/svg+xml;base64," +
18
+ fs.readFileSync(new URL(`specbook-export-logo-${theme}.svg`, import.meta.url)).toString("base64");
20
19
  /* check whether an object is the specification title object */
21
20
  export const isTitleObject = (object) => object.kind === "META" && object.name.toUpperCase() === "TITLE";
22
21
  /* determine the title object of the specification, searched at any
@@ -71,14 +70,15 @@ export const documentPaperSize = (specification) => {
71
70
  `(expected ${paperSizes.join(", ")})`);
72
71
  return paper;
73
72
  };
74
- /* provide the paper-dependent print stylesheet: a diagram is scaled
75
- down to still fit onto a single page (the paper height less the
76
- print margins, the own vertical margins of the diagram, and the
77
- room its introducing heading claims above it) and is never broken
78
- across a page boundary; without the heading reserve a maximally
73
+ /* the vertical room (in rem) the introducing heading of a diagram
74
+ claims above it on the same page: without this reserve a maximally
79
75
  sized diagram could not share its page with the heading, which
80
76
  would defeat the "break-after: avoid" bundling of the stylesheet */
81
77
  const headingReserve = 6;
78
+ /* provide the paper-dependent print stylesheet: a diagram is scaled
79
+ down to still fit onto a single page (the paper height less the
80
+ print margins, the own vertical margins of the diagram, and the
81
+ heading reserve above it) and is never broken across a page boundary */
82
82
  export const paperStylesheet = (paper) => {
83
83
  const setup = paperSetup(paper);
84
84
  const avail = setup.height - setup.margin.top - setup.margin.bottom;
@@ -131,9 +131,7 @@ const typographyGlyphs = [
131
131
  glyphs (no charset or a full Unicode charset keeps the fonts complete) */
132
132
  export const subsetStylesheet = async (charset) => {
133
133
  const css = stylesheet();
134
- if (charset === undefined)
135
- return css;
136
- const codepoints = charsetCodepoints(charset);
134
+ const codepoints = charset !== undefined ? charsetCodepoints(charset) : undefined;
137
135
  if (codepoints === undefined)
138
136
  return css;
139
137
  const { default: subsetFont } = await import("subset-font");
@@ -162,7 +160,7 @@ export const documentTitle = (specification) => ({
162
160
  export const documentLogo = (specification) => {
163
161
  const content = titleObject(specification)
164
162
  ?.properties.find((property) => property.key === "LOGO")?.embedding?.[0];
165
- if (content === undefined)
163
+ if (content === undefined || content === "")
166
164
  return fallbackLogo("light");
167
165
  return content.startsWith("data:") ? content :
168
166
  `data:image/svg+xml;base64,${Buffer.from(content, "utf8").toString("base64")}`;