@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.
- package/README.md +65 -10
- package/dst/specbook-api.js +19 -18
- package/dst/specbook-cli.js +9 -6
- package/dst/specbook-cmd-describe.js +1 -3
- package/dst/specbook-cmd-export.js +24 -17
- package/dst/specbook-cmd-init.d.ts +1 -1
- package/dst/specbook-cmd-init.js +5 -4
- package/dst/specbook-cmd-lint.d.ts +0 -1
- package/dst/specbook-cmd-lint.js +29 -5
- package/dst/specbook-config.d.ts +1 -1
- package/dst/specbook-config.js +90 -71
- package/dst/specbook-diagnostic.js +4 -1
- package/dst/specbook-diagram.d.ts +8 -3
- package/dst/specbook-diagram.js +101 -105
- package/dst/specbook-export-ast.d.ts +1 -1
- package/dst/specbook-export-ast.js +1 -1
- package/dst/specbook-export-common.d.ts +2 -1
- package/dst/specbook-export-common.js +12 -14
- package/dst/specbook-export-html-search.js +10 -14
- package/dst/specbook-export-html.css +24 -29
- package/dst/specbook-export-html.js +101 -83
- package/dst/specbook-export-md.d.ts +1 -1
- package/dst/specbook-export-md.js +56 -23
- package/dst/specbook-export-pdf.d.ts +4 -2
- package/dst/specbook-export-pdf.js +48 -38
- package/dst/specbook-format-schema.js +6 -3
- package/dst/specbook-format.md +47 -20
- package/dst/specbook-link.d.ts +1 -0
- package/dst/specbook-link.js +7 -4
- package/dst/specbook-mcp.js +17 -10
- package/dst/specbook-parse-common.d.ts +2 -1
- package/dst/specbook-parse-common.js +2 -0
- package/dst/specbook-parse-semantic.d.ts +4 -2
- package/dst/specbook-parse-semantic.js +114 -43
- package/dst/specbook-parse-syntax.js +179 -137
- package/dst/specbook-parse-value.d.ts +1 -1
- package/dst/specbook-parse-value.js +9 -5
- package/dst/specbook-parse.d.ts +2 -5
- package/dst/specbook-parse.js +27 -34
- package/dst/specbook-verbose.js +2 -1
- package/package.json +1 -1
package/dst/specbook-config.js
CHANGED
|
@@ -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 {
|
|
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
|
|
12
|
-
|
|
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 =
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
const
|
|
18
|
-
|
|
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 {
|
|
89
|
+
return { diagnostics };
|
|
37
90
|
}
|
|
38
|
-
/* syntactically parse the YAML content
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
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
|
-
|
|
55
|
-
|
|
56
|
-
|
|
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: ${
|
|
118
|
+
message: `invalid configuration: ${where}${issue.message}` });
|
|
68
119
|
}
|
|
69
|
-
return {
|
|
120
|
+
return { diagnostics };
|
|
70
121
|
}
|
|
71
|
-
/*
|
|
72
|
-
|
|
73
|
-
|
|
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 {
|
|
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
|
-
|
|
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;
|
package/dst/specbook-diagram.js
CHANGED
|
@@ -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.
|
|
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
|
|
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
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
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
|
-
|
|
164
|
-
|
|
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(
|
|
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
|
-
|
|
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(
|
|
213
|
-
|
|
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
|
-
|
|
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,
|
|
290
|
-
|
|
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
|
-
|
|
297
|
-
|
|
298
|
-
for (const error of result.errors)
|
|
299
|
-
|
|
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) =>
|
|
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 =
|
|
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:
|
|
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, "&").replace(/</g, "<").replace(/>/g, ">")
|
|
11
9
|
.replace(/"/g, """).replace(/'/g, "'");
|
|
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(
|
|
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(
|
|
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," +
|
|
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
|
-
/*
|
|
75
|
-
|
|
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
|
-
|
|
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")}`;
|