@rse/specbook 0.9.5 → 0.9.7
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 +108 -328
- package/dst/specbook-api.d.ts +13 -20
- package/dst/specbook-api.js +43 -39
- package/dst/specbook-cli.js +42 -55
- package/dst/specbook-cmd-describe.d.ts +5 -2
- package/dst/specbook-cmd-describe.js +30 -91
- package/dst/specbook-cmd-export.d.ts +3 -3
- package/dst/specbook-cmd-export.js +23 -19
- package/dst/specbook-cmd-init.d.ts +3 -3
- package/dst/specbook-cmd-init.js +38 -15
- package/dst/specbook-cmd-lint.d.ts +5 -6
- package/dst/specbook-cmd-lint.js +61 -24
- package/dst/specbook-config.d.ts +2 -2
- package/dst/specbook-config.js +82 -73
- package/dst/specbook-diagnostic.js +4 -1
- package/dst/specbook-diagram.d.ts +6 -5
- package/dst/specbook-diagram.js +94 -104
- package/dst/specbook-export-ast.d.ts +3 -3
- package/dst/specbook-export-ast.js +1 -1
- package/dst/specbook-export-common.d.ts +11 -9
- package/dst/specbook-export-common.js +34 -22
- package/dst/specbook-export-html-search.js +10 -14
- package/dst/specbook-export-html.css +37 -32
- package/dst/specbook-export-html.d.ts +4 -4
- package/dst/specbook-export-html.js +153 -107
- package/dst/specbook-export-logo-dark.svg +35 -0
- package/dst/specbook-export-logo-light.svg +35 -0
- package/dst/specbook-export-md.d.ts +3 -3
- package/dst/specbook-export-md.js +50 -24
- package/dst/specbook-export-pdf.d.ts +4 -2
- package/dst/specbook-export-pdf.js +48 -38
- package/dst/{specbook-struct-schema.d.ts → specbook-format-schema.d.ts} +3 -2
- package/dst/{specbook-struct-schema.js → specbook-format-schema.js} +8 -4
- package/dst/specbook-format-spec.d.ts +31 -0
- package/dst/{specbook-struct-spec.js → specbook-format-spec.js} +10 -10
- package/dst/specbook-format.md +750 -0
- package/dst/specbook-link.d.ts +3 -2
- package/dst/specbook-link.js +7 -4
- package/dst/specbook-mcp.d.ts +2 -1
- package/dst/specbook-mcp.js +30 -62
- package/dst/specbook-parse-common.d.ts +5 -3
- package/dst/specbook-parse-common.js +8 -0
- package/dst/specbook-parse-semantic.d.ts +6 -4
- package/dst/specbook-parse-semantic.js +98 -106
- package/dst/specbook-parse-syntax.d.ts +2 -2
- package/dst/specbook-parse-syntax.js +175 -144
- package/dst/specbook-parse-value.d.ts +1 -1
- package/dst/specbook-parse-value.js +6 -3
- package/dst/specbook-parse.d.ts +4 -7
- package/dst/specbook-parse.js +28 -35
- package/dst/specbook-verbose.d.ts +2 -0
- package/dst/specbook-verbose.js +17 -0
- package/package.json +3 -7
- package/dst/specbook-cmd-edit.d.ts +0 -9
- package/dst/specbook-cmd-edit.js +0 -74
- package/dst/specbook-cmd-import.d.ts +0 -9
- package/dst/specbook-cmd-import.js +0 -78
- package/dst/specbook-export-logo.svg +0 -35
- package/dst/specbook-llm.d.ts +0 -13
- package/dst/specbook-llm.js +0 -51
- package/dst/specbook-struct-spec.d.ts +0 -31
|
@@ -1,15 +1,14 @@
|
|
|
1
1
|
import { type Diagnostic } from "./specbook-diagnostic.js";
|
|
2
|
-
import { type
|
|
3
|
-
import { type
|
|
2
|
+
import { type Spec } from "./specbook-format-spec.js";
|
|
3
|
+
import { type Schema } from "./specbook-format-schema.js";
|
|
4
4
|
export interface LintOptions {
|
|
5
|
-
config
|
|
5
|
+
config: string;
|
|
6
6
|
basedir: string;
|
|
7
7
|
verbose: (msg: string) => void;
|
|
8
8
|
}
|
|
9
9
|
export interface LintResult {
|
|
10
|
-
specification:
|
|
10
|
+
specification: Spec;
|
|
11
11
|
diagnostics: Diagnostic[];
|
|
12
|
-
config?:
|
|
12
|
+
config?: Schema;
|
|
13
13
|
}
|
|
14
|
-
export declare const scanMarkdown: (basedir: string) => string[];
|
|
15
14
|
export declare const lint: (options: LintOptions) => LintResult;
|
package/dst/specbook-cmd-lint.js
CHANGED
|
@@ -6,43 +6,80 @@
|
|
|
6
6
|
import * as fs from "node:fs";
|
|
7
7
|
import * as path from "node:path";
|
|
8
8
|
import { loadConfig } from "./specbook-config.js";
|
|
9
|
-
import {
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
9
|
+
import { literal } from "./specbook-verbose.js";
|
|
10
|
+
import { parseSpecification, resolveArtifact } from "./specbook-parse.js";
|
|
11
|
+
/* collect the distinct artifact files the schema configuration
|
|
12
|
+
references, in their declaration order, each mapped onto whether
|
|
13
|
+
all of its artifacts are optional and hence the file may be absent */
|
|
14
|
+
const schemaFiles = (config) => {
|
|
15
|
+
const files = new Map();
|
|
16
|
+
for (const artifact of config) {
|
|
17
|
+
if (artifact.file === undefined)
|
|
18
|
+
continue;
|
|
19
|
+
files.set(artifact.file, (files.get(artifact.file) ?? true) && artifact.optional === true);
|
|
20
|
+
}
|
|
21
|
+
return files;
|
|
22
|
+
};
|
|
15
23
|
/* lint the specification Markdown files below the base directory
|
|
16
|
-
against the
|
|
24
|
+
against the configuration */
|
|
17
25
|
export const lint = (options) => {
|
|
18
26
|
const diagnostics = new Array();
|
|
19
|
-
/* load the
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
if (files.length === 0)
|
|
32
|
-
diagnostics.push({ file: options.basedir, line: 1, column: 1,
|
|
33
|
-
message: exists ? "no Markdown files found below base directory" :
|
|
34
|
-
"base directory does not exist" });
|
|
27
|
+
/* load the mandatory YAML schema configuration */
|
|
28
|
+
options.verbose(`loading configuration "${literal(options.config)}"`);
|
|
29
|
+
const loaded = loadConfig(options.config);
|
|
30
|
+
diagnostics.push(...loaded.diagnostics);
|
|
31
|
+
const config = loaded.config;
|
|
32
|
+
/* read the artifact files the configuration references, resolved
|
|
33
|
+
against the base directory and tolerating an absent file if all
|
|
34
|
+
its artifacts are optional */
|
|
35
|
+
const files = config !== undefined ? schemaFiles(config) : new Map();
|
|
36
|
+
if (config !== undefined && files.size === 0)
|
|
37
|
+
diagnostics.push({ file: options.config, line: 1, column: 1,
|
|
38
|
+
message: "no artifact files configured" });
|
|
35
39
|
const sources = new Array();
|
|
36
|
-
|
|
40
|
+
const present = new Set();
|
|
41
|
+
for (const [name, optional] of files) {
|
|
42
|
+
const file = path.join(options.basedir, name);
|
|
43
|
+
if (!fs.existsSync(file)) {
|
|
44
|
+
if (!optional)
|
|
45
|
+
diagnostics.push({ file, line: 1, column: 1,
|
|
46
|
+
message: "missing artifact file" });
|
|
47
|
+
continue;
|
|
48
|
+
}
|
|
37
49
|
try {
|
|
38
50
|
sources.push({ file, text: fs.readFileSync(file, "utf8") });
|
|
51
|
+
present.add(name);
|
|
39
52
|
}
|
|
40
53
|
catch (err) {
|
|
41
54
|
diagnostics.push({ file, line: 1, column: 1,
|
|
42
55
|
message: `unreadable file: ${err instanceof Error ? err.message : String(err)}` });
|
|
43
56
|
}
|
|
44
57
|
}
|
|
58
|
+
/* parse and validate the artifact files against the configuration */
|
|
59
|
+
options.verbose(`parsing ${literal(sources.length)} specification file(s) ` +
|
|
60
|
+
`below "${literal(options.basedir)}"`);
|
|
45
61
|
const result = parseSpecification(sources, config);
|
|
46
62
|
diagnostics.push(...result.diagnostics);
|
|
63
|
+
/* report the non-optional artifacts absent from the specification,
|
|
64
|
+
against their loaded artifact file (an absent or unreadable file
|
|
65
|
+
is already reported above) or else against the configuration */
|
|
66
|
+
if (config !== undefined) {
|
|
67
|
+
const found = new Set();
|
|
68
|
+
for (const artifact of result.specification.artifacts)
|
|
69
|
+
for (const object of artifact.objects) {
|
|
70
|
+
const schema = resolveArtifact(config, object);
|
|
71
|
+
if (schema !== undefined)
|
|
72
|
+
found.add(schema);
|
|
73
|
+
}
|
|
74
|
+
for (const schema of config) {
|
|
75
|
+
if (schema.optional === true || found.has(schema)
|
|
76
|
+
|| (schema.file !== undefined && !present.has(schema.file)))
|
|
77
|
+
continue;
|
|
78
|
+
const file = schema.file !== undefined ? path.join(options.basedir, schema.file) : options.config;
|
|
79
|
+
const paren = schema.id !== undefined ? ` (${schema.id})` : "";
|
|
80
|
+
diagnostics.push({ file, line: 1, column: 1,
|
|
81
|
+
message: `missing artifact "${schema.kind}: ${schema.name ?? ""}${paren}"` });
|
|
82
|
+
}
|
|
83
|
+
}
|
|
47
84
|
return { specification: result.specification, diagnostics, config };
|
|
48
85
|
};
|
package/dst/specbook-config.d.ts
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
|
-
import {
|
|
1
|
+
import { Schema } from "./specbook-format-schema.js";
|
|
2
2
|
import { type Diagnostic } from "./specbook-diagnostic.js";
|
|
3
3
|
export declare const loadConfig: (file: string) => {
|
|
4
|
-
config
|
|
4
|
+
config?: Schema;
|
|
5
5
|
diagnostics: Diagnostic[];
|
|
6
6
|
};
|
package/dst/specbook-config.js
CHANGED
|
@@ -4,26 +4,69 @@
|
|
|
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
|
-
import {
|
|
10
|
-
import { compileValueExpr } from "./specbook-parse-value.js";
|
|
11
|
-
/* determine line/column of a YAML document path via the
|
|
12
|
-
|
|
9
|
+
import { Schema } from "./specbook-format-schema.js";
|
|
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: "file" fields are allowed on the first
|
|
26
|
+
(artifact) level only, property value expressions have to be
|
|
27
|
+
syntactically valid, and the names of non-artifact objects have to
|
|
28
|
+
be valid patterns (as they are compiled into regular expressions) */
|
|
29
|
+
const checkConstraints = (config, file, posOfPath) => {
|
|
30
|
+
const diagnostics = new Array();
|
|
31
|
+
const diagnose = (path, message) => {
|
|
32
|
+
const pos = posOfPath(path);
|
|
33
|
+
diagnostics.push({ file, line: pos.line, column: pos.column, message });
|
|
34
|
+
};
|
|
35
|
+
const check = (objects, path, depth) => {
|
|
36
|
+
for (const [i, object] of objects.entries()) {
|
|
37
|
+
const at = [...path, i];
|
|
38
|
+
if (depth > 1 && object.file !== undefined)
|
|
39
|
+
diagnose([...at, "file"], `"file" field is only allowed on the first (artifact) level (found on level ${depth})`);
|
|
40
|
+
for (const [j, prop] of (object.props ?? []).entries()) {
|
|
41
|
+
if (prop.value === undefined)
|
|
42
|
+
continue;
|
|
43
|
+
try {
|
|
44
|
+
compileValueExpr(prop.value);
|
|
45
|
+
}
|
|
46
|
+
catch (err) {
|
|
47
|
+
diagnose([...at, "props", j, "value"], `invalid value expression "${prop.value}": ` +
|
|
48
|
+
(err instanceof Error ? err.message : String(err)));
|
|
49
|
+
}
|
|
50
|
+
}
|
|
51
|
+
if (depth > 1 && object.name !== undefined) {
|
|
52
|
+
try {
|
|
53
|
+
anchored(object.name);
|
|
54
|
+
}
|
|
55
|
+
catch (err) {
|
|
56
|
+
diagnose([...at, "name"], `invalid name pattern "${object.name}": ` +
|
|
57
|
+
(err instanceof Error ? err.message : String(err)));
|
|
58
|
+
}
|
|
59
|
+
}
|
|
60
|
+
if (object.childs !== undefined)
|
|
61
|
+
check(object.childs, [...at, "childs"], depth + 1);
|
|
62
|
+
}
|
|
63
|
+
};
|
|
64
|
+
check(config, [], 1);
|
|
65
|
+
return diagnostics;
|
|
66
|
+
};
|
|
24
67
|
/* load and validate a YAML schema configuration file */
|
|
25
68
|
export const loadConfig = (file) => {
|
|
26
|
-
const diagnostics =
|
|
69
|
+
const diagnostics = new Array();
|
|
27
70
|
/* read the configuration file */
|
|
28
71
|
let yaml;
|
|
29
72
|
try {
|
|
@@ -33,74 +76,40 @@ export const loadConfig = (file) => {
|
|
|
33
76
|
diagnostics.push({ file, line: 1, column: 1,
|
|
34
77
|
message: "cannot read configuration file: " +
|
|
35
78
|
(err instanceof Error ? err.message : String(err)) });
|
|
36
|
-
return {
|
|
79
|
+
return { diagnostics };
|
|
37
80
|
}
|
|
38
|
-
/* syntactically parse the YAML content
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
81
|
+
/* syntactically parse the YAML content into its document, keeping
|
|
82
|
+
the line positions for the diagnostics */
|
|
83
|
+
const lines = new LineCounter();
|
|
84
|
+
const doc = parseDocument(yaml, { lineCounter: lines });
|
|
85
|
+
if (doc.errors.length > 0) {
|
|
86
|
+
/* strip the position and the source snippet the YAML parser
|
|
87
|
+
appends to its message, as the diagnostic carries the
|
|
88
|
+
position itself */
|
|
89
|
+
for (const err of doc.errors)
|
|
90
|
+
diagnostics.push({
|
|
91
|
+
file,
|
|
92
|
+
line: err.linePos?.[0].line ?? 1,
|
|
93
|
+
column: err.linePos?.[0].col ?? 1,
|
|
94
|
+
message: "invalid YAML syntax: " + err.message.replace(/ at line \d+, column \d+(?::\n[\s\S]*)?$/, "")
|
|
95
|
+
});
|
|
96
|
+
return { diagnostics };
|
|
53
97
|
}
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
const
|
|
58
|
-
cst ??= parseDocument(yaml);
|
|
59
|
-
return lineColOfPath(yaml, cst, path);
|
|
60
|
-
};
|
|
61
|
-
const result = v.safeParse(SchemaSpecification, obj);
|
|
98
|
+
const obj = doc.toJS();
|
|
99
|
+
/* semantically validate against the schema of the configuration */
|
|
100
|
+
const posOfPath = (path) => lineColOfPath(doc, lines, path);
|
|
101
|
+
const result = v.safeParse(Schema, obj);
|
|
62
102
|
if (!result.success) {
|
|
63
103
|
for (const issue of result.issues) {
|
|
64
104
|
const path = (issue.path ?? []).map((item) => item.key);
|
|
65
105
|
const pos = posOfPath(path);
|
|
106
|
+
const where = path.length > 0 ? `${path.join(".")}: ` : "";
|
|
66
107
|
diagnostics.push({ file, line: pos.line, column: pos.column,
|
|
67
|
-
message: `invalid configuration: ${
|
|
108
|
+
message: `invalid configuration: ${where}${issue.message}` });
|
|
68
109
|
}
|
|
69
|
-
return {
|
|
110
|
+
return { diagnostics };
|
|
70
111
|
}
|
|
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 };
|
|
112
|
+
/* check the constraints beyond the structural schema */
|
|
113
|
+
diagnostics.push(...checkConstraints(result.output, file, posOfPath));
|
|
114
|
+
return { config: diagnostics.length === 0 ? result.output : undefined, diagnostics };
|
|
106
115
|
};
|
|
@@ -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,10 @@
|
|
|
1
|
-
import type {
|
|
2
|
-
import type {
|
|
3
|
-
import {
|
|
1
|
+
import type { Spec, SpecObject } from "./specbook-format-spec.js";
|
|
2
|
+
import type { Schema, SchemaDiagram } from "./specbook-format-schema.js";
|
|
3
|
+
import type { ParseContext } from "./specbook-parse-common.js";
|
|
4
4
|
export interface DiagramResult {
|
|
5
5
|
spec?: string;
|
|
6
|
+
config?: SchemaDiagram["config"];
|
|
6
7
|
errors: string[];
|
|
7
8
|
}
|
|
8
|
-
export declare const specDiagrams: (specification:
|
|
9
|
-
export declare const validateDiagrams: (ctx: ParseContext, specification:
|
|
9
|
+
export declare const specDiagrams: (specification: Spec, config: Schema) => Map<SpecObject, DiagramResult>;
|
|
10
|
+
export declare const validateDiagrams: (ctx: ParseContext, specification: Spec, config: Schema) => void;
|
package/dst/specbook-diagram.js
CHANGED
|
@@ -3,16 +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, "");
|
|
13
|
-
/* strip a trailing parenthesized annotation from a
|
|
14
|
-
property key (e.g. "WHEN (Context)") */
|
|
15
|
-
const plainKey = (key) => key.replace(/\s*\([^)]*\)\s*$/, "").trim();
|
|
10
|
+
const referenceOnce = new RegExp(referenceRegex.source);
|
|
16
11
|
/* render a text as a Gradia atom: a bareword where possible
|
|
17
12
|
(no whitespace, no special characters, no "--"), a quoted
|
|
18
13
|
string otherwise */
|
|
@@ -40,11 +35,9 @@ const configValue = (value) => {
|
|
|
40
35
|
const renderSpec = (diagram, type, center, nodes, edges, index, anchors) => {
|
|
41
36
|
const lines = [`#type ${type}`];
|
|
42
37
|
const config = diagram.config ?? {};
|
|
43
|
-
for (const key of Object.
|
|
44
|
-
const value = config[key];
|
|
38
|
+
for (const [key, value] of Object.entries(config))
|
|
45
39
|
if (value !== undefined)
|
|
46
40
|
lines.push(`#config ${key} ${configValue(value)}`);
|
|
47
|
-
}
|
|
48
41
|
lines.push("");
|
|
49
42
|
for (const node of nodes) {
|
|
50
43
|
const anchor = anchors.get(node) ?? node.id;
|
|
@@ -58,12 +51,9 @@ const renderSpec = (diagram, type, center, nodes, edges, index, anchors) => {
|
|
|
58
51
|
the node set can mix objects of different kinds), with every
|
|
59
52
|
"[[...]]" reference stripped to its target object name */
|
|
60
53
|
for (const key of diagram.properties ?? []) {
|
|
61
|
-
const value = node.properties.find((property) =>
|
|
54
|
+
const value = node.properties.find((property) => property.key === key)?.value;
|
|
62
55
|
if (value !== undefined) {
|
|
63
|
-
const text = value
|
|
64
|
-
const target = resolveUnique(index, reference.trim()).target;
|
|
65
|
-
return target !== undefined ? target.name : reference.trim();
|
|
66
|
-
});
|
|
56
|
+
const text = expandReferences(value, (reference) => resolveUnique(index, reference).target?.name ?? reference);
|
|
67
57
|
attrs.push(`${atom(key)}: ${atom(plainText(text))}`);
|
|
68
58
|
}
|
|
69
59
|
}
|
|
@@ -81,56 +71,14 @@ const renderSpec = (diagram, type, center, nodes, edges, index, anchors) => {
|
|
|
81
71
|
}
|
|
82
72
|
return lines.join("\n") + "\n";
|
|
83
73
|
};
|
|
84
|
-
/* derive the
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
const resolvePatterns = (value, field) => {
|
|
93
|
-
const matches = new Array();
|
|
94
|
-
const seen = new Set();
|
|
95
|
-
for (const m of value.matchAll(referenceRegex)) {
|
|
96
|
-
const pattern = m[1].trim();
|
|
97
|
-
const set = resolveSet(index, pattern);
|
|
98
|
-
if (set.length === 0)
|
|
99
|
-
errors.push(`unresolvable diagram "${field}" pattern "[[${pattern}]]"`);
|
|
100
|
-
for (const match of set) {
|
|
101
|
-
if (!seen.has(match)) {
|
|
102
|
-
seen.add(match);
|
|
103
|
-
matches.push(match);
|
|
104
|
-
}
|
|
105
|
-
}
|
|
106
|
-
}
|
|
107
|
-
return matches;
|
|
108
|
-
};
|
|
109
|
-
/* determine the node objects (default: the current object plus
|
|
110
|
-
all objects below it) and the edge objects (default: none),
|
|
111
|
-
with the edge objects never acting as nodes themselves */
|
|
112
|
-
let nodes;
|
|
113
|
-
if (diagram.nodes !== undefined)
|
|
114
|
-
nodes = resolvePatterns(diagram.nodes, "nodes");
|
|
115
|
-
else {
|
|
116
|
-
nodes = new Array();
|
|
117
|
-
const walk = (o) => {
|
|
118
|
-
nodes.push(o);
|
|
119
|
-
o.childs.forEach(walk);
|
|
120
|
-
};
|
|
121
|
-
walk(object);
|
|
122
|
-
}
|
|
123
|
-
const edgeObjects = diagram.edges !== undefined ? resolvePatterns(diagram.edges, "edges") : [];
|
|
124
|
-
const edgeSet = new Set(edgeObjects);
|
|
125
|
-
nodes = nodes.filter((node) => !edgeSet.has(node));
|
|
126
|
-
const nodeSet = new Set(nodes);
|
|
127
|
-
/* derive the edges: from the "[[...]]" references of the node
|
|
128
|
-
objects (per the "links" selection) and from the edge objects by
|
|
129
|
-
convention (source: parent object, target: first reference in the
|
|
130
|
-
property values, name: object name, arity: "ARITY" property),
|
|
131
|
-
overridable via "edgeTarget"/"edgeArity" (a "grid" diagram is
|
|
132
|
-
edge-less by definition, so no edges are derived for it at all) */
|
|
133
|
-
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();
|
|
134
82
|
if (type !== "grid") {
|
|
135
83
|
for (const node of nodes) {
|
|
136
84
|
const texts = node.properties.map((property) => property.value);
|
|
@@ -146,16 +94,25 @@ const deriveDiagram = (object, diagram, index, anchors, parents) => {
|
|
|
146
94
|
edges.push({ source: node, target });
|
|
147
95
|
}
|
|
148
96
|
}
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
|
|
97
|
+
/* resolve the node an edge object references through a named
|
|
98
|
+
property, or through its first single-reference property when
|
|
99
|
+
no name is configured (skipping the source property) */
|
|
100
|
+
const edgeNode = (edgeObject, key) => {
|
|
101
|
+
const value = key !== undefined ?
|
|
102
|
+
edgeObject.properties.find((property) => property.key === key)?.value :
|
|
103
|
+
edgeObject.properties.filter((property) => property.key !== diagram.edgeSource)
|
|
104
|
+
.map((property) => property.value).find((v) => referenceOnce.test(v));
|
|
155
105
|
const reference = value?.match(referenceOnce)?.[1].trim();
|
|
156
|
-
|
|
106
|
+
return reference !== undefined ? resolveUnique(index, reference).target : undefined;
|
|
107
|
+
};
|
|
108
|
+
for (const edgeObject of edgeObjects) {
|
|
109
|
+
const source = diagram.edgeSource !== undefined ?
|
|
110
|
+
edgeNode(edgeObject, diagram.edgeSource) : parents.get(edgeObject);
|
|
111
|
+
const target = edgeNode(edgeObject, diagram.edgeTarget);
|
|
157
112
|
if (source === undefined) {
|
|
158
|
-
errors.push(
|
|
113
|
+
errors.push(diagram.edgeSource !== undefined ?
|
|
114
|
+
`diagram edge object "${edgeObject.name}" carries no resolvable source reference` :
|
|
115
|
+
`diagram edge object "${edgeObject.name}" has no parent object as source`);
|
|
159
116
|
continue;
|
|
160
117
|
}
|
|
161
118
|
if (target === undefined) {
|
|
@@ -164,7 +121,7 @@ const deriveDiagram = (object, diagram, index, anchors, parents) => {
|
|
|
164
121
|
}
|
|
165
122
|
if (!nodeSet.has(source) || !nodeSet.has(target))
|
|
166
123
|
continue;
|
|
167
|
-
const arity = edgeObject.properties.find((property) =>
|
|
124
|
+
const arity = edgeObject.properties.find((property) => property.key === (diagram.edgeArity ?? "ARITY"))?.value;
|
|
168
125
|
edges.push({ source, target, name: plainText(edgeObject.name),
|
|
169
126
|
arity: arity !== undefined ? plainText(arity) : undefined });
|
|
170
127
|
}
|
|
@@ -182,11 +139,13 @@ const deriveDiagram = (object, diagram, index, anchors, parents) => {
|
|
|
182
139
|
errors.push("\"grid\" diagram cannot carry an \"edges\" configuration");
|
|
183
140
|
if (diagram.hierarchy === true)
|
|
184
141
|
errors.push("\"grid\" diagram cannot carry a \"hierarchy\" configuration");
|
|
142
|
+
if (diagram.onlyConnected === true)
|
|
143
|
+
errors.push("\"grid\" diagram cannot carry an \"onlyConnected\" configuration");
|
|
185
144
|
}
|
|
186
145
|
/* deduplicate the edges (the same reference can occur in
|
|
187
146
|
multiple texts of the same node object) */
|
|
188
147
|
const seenEdges = new Set();
|
|
189
|
-
|
|
148
|
+
return edges.filter((edge) => {
|
|
190
149
|
const key = `${anchors.get(edge.source) ?? edge.source.id}` +
|
|
191
150
|
`\u0000${anchors.get(edge.target) ?? edge.target.id}` +
|
|
192
151
|
`\u0000${edge.name ?? ""}\u0000${edge.arity ?? ""}`;
|
|
@@ -195,15 +154,68 @@ const deriveDiagram = (object, diagram, index, anchors, parents) => {
|
|
|
195
154
|
seenEdges.add(key);
|
|
196
155
|
return true;
|
|
197
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(`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(`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);
|
|
198
208
|
/* determine the center object of a "hub" diagram: the current
|
|
199
209
|
object for the default "self" configuration, the uniquely
|
|
200
210
|
resolved object of an explicit "[[...]]" reference otherwise */
|
|
201
211
|
let center = object;
|
|
212
|
+
if (type !== "hub" && diagram.center !== undefined)
|
|
213
|
+
errors.push(`"${type}" diagram cannot carry a "center" configuration`);
|
|
202
214
|
if (type === "hub" && diagram.center !== undefined && diagram.center !== "self") {
|
|
203
215
|
const reference = diagram.center.match(referenceOnce)?.[1].trim();
|
|
204
216
|
const resolved = reference !== undefined ? resolveUnique(index, reference) : undefined;
|
|
205
217
|
if (resolved?.target === undefined) {
|
|
206
|
-
errors.push(
|
|
218
|
+
errors.push((resolved?.ambiguous === true ? "ambiguous" : "unresolvable") +
|
|
207
219
|
` diagram "center" reference "${diagram.center}"`);
|
|
208
220
|
return { errors };
|
|
209
221
|
}
|
|
@@ -242,34 +254,11 @@ const deriveDiagram = (object, diagram, index, anchors, parents) => {
|
|
|
242
254
|
if (errors.length > 0)
|
|
243
255
|
return { errors };
|
|
244
256
|
/* the "collapse" handling (enabled by default) silently omits a
|
|
245
|
-
|
|
257
|
+
degenerate diagram, consisting of a single node only, as such a
|
|
246
258
|
diagram carries no information beyond the object itself */
|
|
247
259
|
if (diagram.collapse !== false && nodes.length === 1 && edges.length === 0)
|
|
248
260
|
return { errors };
|
|
249
|
-
return { spec: renderSpec(diagram, type, center, nodes, edges, index, anchors), errors };
|
|
250
|
-
};
|
|
251
|
-
/* collect the objects whose schema configuration carries a "diagram:"
|
|
252
|
-
field (the schema resolution mirrors the semantic validation) */
|
|
253
|
-
const collectDiagrams = (specification, config) => {
|
|
254
|
-
const diagrams = new Map();
|
|
255
|
-
const walk = (object, schema) => {
|
|
256
|
-
if (schema.diagram !== undefined)
|
|
257
|
-
diagrams.set(object, schema.diagram);
|
|
258
|
-
for (const child of object.childs) {
|
|
259
|
-
const childSchema = (schema.childs ?? []).find((c) => c.kind === child.kind);
|
|
260
|
-
if (childSchema !== undefined)
|
|
261
|
-
walk(child, childSchema);
|
|
262
|
-
}
|
|
263
|
-
};
|
|
264
|
-
for (const artifact of specification.artifacts) {
|
|
265
|
-
for (const object of artifact.objects) {
|
|
266
|
-
const schema = config.find((s) => (s.kind === object.kind && s.id === object.id) || `${s.kind}-${s.id}` === object.id) ??
|
|
267
|
-
config.find((s) => (s.name ?? "").toUpperCase() === plainText(object.name).toUpperCase());
|
|
268
|
-
if (schema !== undefined)
|
|
269
|
-
walk(object, schema);
|
|
270
|
-
}
|
|
271
|
-
}
|
|
272
|
-
return diagrams;
|
|
261
|
+
return { spec: renderSpec(diagram, type, center, nodes, edges, index, anchors), config: diagram.config, errors };
|
|
273
262
|
};
|
|
274
263
|
/* derive the Gradia specs of all diagram-configured objects
|
|
275
264
|
of a specification */
|
|
@@ -280,8 +269,9 @@ export const specDiagrams = (specification, config) => {
|
|
|
280
269
|
for (const node of index)
|
|
281
270
|
parents.set(node.object, node.parent?.object);
|
|
282
271
|
const results = new Map();
|
|
283
|
-
for (const [object,
|
|
284
|
-
|
|
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));
|
|
285
275
|
return results;
|
|
286
276
|
};
|
|
287
277
|
/* validate all configured diagrams of a specification, reporting
|