@rse/specbook 0.9.3 → 0.9.4
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 +4 -4
- package/dst/specbook-api.d.ts +1 -0
- package/dst/specbook-api.js +10 -1
- package/dst/specbook-cli.js +14 -14
- package/dst/specbook-cmd-describe.js +3 -1
- package/dst/specbook-cmd-edit.js +6 -1
- package/dst/specbook-cmd-export.js +3 -2
- package/dst/specbook-cmd-import.js +6 -1
- package/dst/specbook-cmd-init.js +4 -2
- package/dst/specbook-cmd-lint.js +4 -2
- package/dst/specbook-config.js +22 -7
- package/dst/specbook-diagram.js +67 -57
- package/dst/specbook-export-ast.js +5 -2
- package/dst/specbook-export-common.d.ts +0 -1
- package/dst/specbook-export-common.js +32 -19
- package/dst/specbook-export-html-search.js +6 -4
- package/dst/specbook-export-html.css +7 -0
- package/dst/specbook-export-html.js +32 -13
- package/dst/specbook-export-md.js +10 -6
- package/dst/specbook-export-pdf.js +4 -3
- package/dst/specbook-llm.js +18 -28
- package/dst/specbook-mcp.js +2 -2
- package/dst/specbook-parse-semantic.js +73 -58
- package/dst/specbook-parse-syntax.js +17 -16
- package/dst/specbook-parse-value.js +7 -1
- package/dst/specbook-parse.js +4 -0
- package/dst/specbook-struct-schema.js +5 -5
- package/dst/specbook-theme.js +1 -1
- package/package.json +2 -2
package/README.md
CHANGED
|
@@ -131,7 +131,7 @@ with the (optionally backquoted) name directly:
|
|
|
131
131
|
|
|
132
132
|
The heading kind is singularized by stripping one trailing `S`, so both
|
|
133
133
|
`### STATES` and `### STATE` group objects of kind `STATE`. The grouped
|
|
134
|
-
objects become
|
|
134
|
+
objects become children of the object the heading is nested under. An
|
|
135
135
|
unquoted name must not contain a `:` (else the item would look like a
|
|
136
136
|
key/value pair) -- backquote it in this case.
|
|
137
137
|
|
|
@@ -238,7 +238,7 @@ constrain reference-valued properties (e.g. `[[PERSONA:*]]` or
|
|
|
238
238
|
|
|
239
239
|
A Markdown image `` inside a description or property
|
|
240
240
|
value embeds a local image file, resolved relative to the artifact file:
|
|
241
|
-
SVG files are inlined as-is
|
|
241
|
+
SVG files are inlined as-is; PNG/JPEG files are embedded as base64
|
|
242
242
|
`data:` URLs. URLs and other file types are left untouched.
|
|
243
243
|
|
|
244
244
|
### Normalization
|
|
@@ -285,7 +285,7 @@ descriptions as well. As the *nesting* of the objects carries no such
|
|
|
285
285
|
reference, `hierarchy: true` additionally derives a containment edge
|
|
286
286
|
from every node object to each of its child objects which is part of the
|
|
287
287
|
node set. This is what turns a parent object into the hub of its own
|
|
288
|
-
|
|
288
|
+
children, as the `TIER` objects of the sample schema configuration
|
|
289
289
|
demonstrate:
|
|
290
290
|
|
|
291
291
|
```yaml
|
|
@@ -304,7 +304,7 @@ how the HTML/PDF export renders the child objects of every object of
|
|
|
304
304
|
that kind. Its `type` selects the rendering: `complex` (nested
|
|
305
305
|
sections), `concise` (compact per-kind tables), or the default `auto`,
|
|
306
306
|
which collapses only the deepest level into tables. Below a `concise`
|
|
307
|
-
object, unconfigured
|
|
307
|
+
object, unconfigured children implicitly stay `concise` and render as
|
|
308
308
|
sub-tables inside the description cells, while an explicitly configured
|
|
309
309
|
`type` is always honored, even a `complex` rendering pressed into a
|
|
310
310
|
cell. `maxTableColumns` (default: `4`) bounds the columns of the compact
|
package/dst/specbook-api.d.ts
CHANGED
|
@@ -6,6 +6,7 @@ export { renderDiagnostic, renderDiagnosticVerbose, type Diagnostic };
|
|
|
6
6
|
export { type LintResult };
|
|
7
7
|
export type { Specification, Artifact, Object, Description, Property } from "./specbook-struct-spec.js";
|
|
8
8
|
export type { SchemaSpecification, SchemaObject, SchemaProperty } from "./specbook-struct-schema.js";
|
|
9
|
+
export declare const version: string;
|
|
9
10
|
export interface SpecBookOptions {
|
|
10
11
|
verbose?: (msg: string) => void;
|
|
11
12
|
}
|
package/dst/specbook-api.js
CHANGED
|
@@ -3,6 +3,9 @@
|
|
|
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 * as fs from "node:fs";
|
|
7
|
+
import * as path from "node:path";
|
|
8
|
+
import { fileURLToPath } from "node:url";
|
|
6
9
|
import { loadConfig } from "./specbook-config.js";
|
|
7
10
|
import { renderDiagnostic, renderDiagnosticVerbose } from "./specbook-diagnostic.js";
|
|
8
11
|
import { initSpecification } from "./specbook-cmd-init.js";
|
|
@@ -14,6 +17,12 @@ import { editSpecification } from "./specbook-cmd-edit.js";
|
|
|
14
17
|
/* re-export the central types for API consumers */
|
|
15
18
|
export { formats, parseOutputSpec };
|
|
16
19
|
export { renderDiagnostic, renderDiagnosticVerbose };
|
|
20
|
+
/* the own version, taken from the package manifest, which resides one
|
|
21
|
+
level above both the source and the compiled module directory */
|
|
22
|
+
export const version = (() => {
|
|
23
|
+
const manifest = path.join(path.dirname(fileURLToPath(import.meta.url)), "..", "package.json");
|
|
24
|
+
return JSON.parse(fs.readFileSync(manifest, "utf8")).version;
|
|
25
|
+
})();
|
|
17
26
|
/* the SpecBook API */
|
|
18
27
|
export class SpecBook {
|
|
19
28
|
verbose;
|
|
@@ -53,7 +62,7 @@ export class SpecBook {
|
|
|
53
62
|
if (result.specification.artifacts.length === 0)
|
|
54
63
|
throw new Error("unexportable specification:\n" +
|
|
55
64
|
result.diagnostics.map(renderDiagnostic).join("\n"));
|
|
56
|
-
const buffers =
|
|
65
|
+
const buffers = new Array();
|
|
57
66
|
for (const format of options.formats ?? ["json"])
|
|
58
67
|
buffers.push(await exportSpecification(result.specification, format, this.verbose, result.config));
|
|
59
68
|
return buffers;
|
package/dst/specbook-cli.js
CHANGED
|
@@ -6,7 +6,7 @@
|
|
|
6
6
|
*/
|
|
7
7
|
import * as fs from "node:fs";
|
|
8
8
|
import { Command } from "commander";
|
|
9
|
-
import { SpecBook, renderDiagnostic, renderDiagnosticVerbose, parseOutputSpec } from "./specbook-api.js";
|
|
9
|
+
import { SpecBook, renderDiagnostic, renderDiagnosticVerbose, parseOutputSpec, version } from "./specbook-api.js";
|
|
10
10
|
import { serveMcp } from "./specbook-mcp.js";
|
|
11
11
|
/* route verbose messages to stderr, keeping stdout reserved
|
|
12
12
|
for the command outputs and the MCP protocol */
|
|
@@ -33,7 +33,7 @@ const writeOutput = async (output, data, verbose) => {
|
|
|
33
33
|
await writeStdout(data);
|
|
34
34
|
else {
|
|
35
35
|
await fs.promises.writeFile(output, data);
|
|
36
|
-
verbose(`wrote "${output}" (${data
|
|
36
|
+
verbose(`wrote "${output}" (${Buffer.byteLength(data)} bytes)`);
|
|
37
37
|
}
|
|
38
38
|
};
|
|
39
39
|
/* determine an option default value, overridable via a
|
|
@@ -46,14 +46,14 @@ const envDefaultFlag = (name, fallback) => {
|
|
|
46
46
|
return value !== undefined ? !(/^(?:|0|false|no|off)$/i).test(value) : fallback;
|
|
47
47
|
};
|
|
48
48
|
/* provide the common options of all sub-commands */
|
|
49
|
-
const
|
|
49
|
+
const withCommonOptions = (command) => command
|
|
50
50
|
.option("-v, --verbose", "print verbose processing information to stderr", envDefaultFlag("verbose", false))
|
|
51
51
|
.option("-c, --config <yaml-file>", "YAML schema configuration file", envDefault("config"));
|
|
52
52
|
/* parse the command line */
|
|
53
53
|
const program = new Command();
|
|
54
54
|
program.name("specbook")
|
|
55
55
|
.description("Markdown-based Specification Format")
|
|
56
|
-
.version(
|
|
56
|
+
.version(version)
|
|
57
57
|
.action(() => {
|
|
58
58
|
program.help();
|
|
59
59
|
});
|
|
@@ -64,24 +64,24 @@ program.command("mcp")
|
|
|
64
64
|
verboseOf(opts)("starting MCP server on stdio");
|
|
65
65
|
await serveMcp(verboseOf(opts));
|
|
66
66
|
});
|
|
67
|
-
|
|
67
|
+
withCommonOptions(program.command("init"))
|
|
68
68
|
.description("initialize the configured specification artifact files below the base directory")
|
|
69
69
|
.option("-b, --basedir <directory>", "base directory of the specification Markdown files", envDefault("basedir", "."))
|
|
70
70
|
.action(async (opts) => {
|
|
71
71
|
const specbook = new SpecBook({ verbose: verboseOf(opts) });
|
|
72
72
|
const created = await specbook.init({ config: opts.config, basedir: opts.basedir });
|
|
73
|
-
|
|
73
|
+
await writeStdout(created.length > 0 ?
|
|
74
74
|
`initialized artifact file(s): ${created.join(", ")}\n` :
|
|
75
75
|
"no artifact files were created\n");
|
|
76
76
|
});
|
|
77
|
-
|
|
77
|
+
withCommonOptions(program.command("lint"))
|
|
78
78
|
.description("lint the specification Markdown files below the base directory")
|
|
79
79
|
.option("-b, --basedir <directory>", "base directory of the specification Markdown files", envDefault("basedir", "."))
|
|
80
80
|
.action(async (opts) => {
|
|
81
81
|
const specbook = new SpecBook({ verbose: verboseOf(opts) });
|
|
82
82
|
const result = await specbook.lint({ config: opts.config, basedir: opts.basedir });
|
|
83
83
|
for (const diagnostic of result.diagnostics)
|
|
84
|
-
|
|
84
|
+
await writeStdout(opts.verbose ?
|
|
85
85
|
renderDiagnosticVerbose(diagnostic, process.stdout.isTTY === true) :
|
|
86
86
|
`${renderDiagnostic(diagnostic)}\n`);
|
|
87
87
|
if (result.diagnostics.length > 0)
|
|
@@ -89,7 +89,7 @@ common(program.command("lint"))
|
|
|
89
89
|
else
|
|
90
90
|
verboseOf(opts)("specification valid");
|
|
91
91
|
});
|
|
92
|
-
|
|
92
|
+
withCommonOptions(program.command("export"))
|
|
93
93
|
.description("export the specification Markdown files below the base directory " +
|
|
94
94
|
"as JSON, JSON5, YAML, TOON, HTML, PDF, or normalized Markdown")
|
|
95
95
|
.option("-b, --basedir <directory>", "base directory of the specification Markdown files", envDefault("basedir", "."))
|
|
@@ -106,7 +106,7 @@ common(program.command("export"))
|
|
|
106
106
|
for (const { format, output } of outputs)
|
|
107
107
|
await writeOutput(output, buffers[distinct.indexOf(format)], verboseOf(opts));
|
|
108
108
|
});
|
|
109
|
-
|
|
109
|
+
withCommonOptions(program.command("describe"))
|
|
110
110
|
.description("describe the configured specification format as Markdown")
|
|
111
111
|
.option("-o, --output <markdown-file>", "output file (\"-\" for stdout)", envDefault("output", "-"))
|
|
112
112
|
.action(async (opts) => {
|
|
@@ -114,7 +114,7 @@ common(program.command("describe"))
|
|
|
114
114
|
const text = await specbook.describe({ config: opts.config });
|
|
115
115
|
await writeOutput(opts.output, text, verboseOf(opts));
|
|
116
116
|
});
|
|
117
|
-
|
|
117
|
+
withCommonOptions(program.command("import"))
|
|
118
118
|
.description("import foreign sources into the specification artifact files below the base directory")
|
|
119
119
|
.option("-b, --basedir <directory>", "base directory of the specification Markdown files", envDefault("basedir", "."))
|
|
120
120
|
.option("--provider <provider>", "AI provider (\"anthropic\", \"openai\", \"openrouter\" or \"ollama\")", envDefault("provider"))
|
|
@@ -124,11 +124,11 @@ common(program.command("import"))
|
|
|
124
124
|
const specbook = new SpecBook({ verbose: verboseOf(opts) });
|
|
125
125
|
const written = await specbook.import({ config: opts.config, basedir: opts.basedir,
|
|
126
126
|
inputs, provider: opts.provider, model: opts.model });
|
|
127
|
-
|
|
127
|
+
await writeStdout(written.length > 0 ?
|
|
128
128
|
`imported into artifact file(s): ${written.join(", ")}\n` :
|
|
129
129
|
"no artifact files were changed\n");
|
|
130
130
|
});
|
|
131
|
-
|
|
131
|
+
withCommonOptions(program.command("edit"))
|
|
132
132
|
.description("apply a free-text edit request to the specification artifact files below the base directory")
|
|
133
133
|
.option("-b, --basedir <directory>", "base directory of the specification Markdown files", envDefault("basedir", "."))
|
|
134
134
|
.option("--provider <provider>", "AI provider (\"anthropic\", \"openai\", \"openrouter\" or \"ollama\")", envDefault("provider"))
|
|
@@ -138,7 +138,7 @@ common(program.command("edit"))
|
|
|
138
138
|
const specbook = new SpecBook({ verbose: verboseOf(opts) });
|
|
139
139
|
const written = await specbook.edit({ config: opts.config, basedir: opts.basedir,
|
|
140
140
|
query, provider: opts.provider, model: opts.model });
|
|
141
|
-
|
|
141
|
+
await writeStdout(written.length > 0 ?
|
|
142
142
|
`edited artifact file(s): ${written.join(", ")}\n` :
|
|
143
143
|
"no artifact files were changed\n");
|
|
144
144
|
});
|
|
@@ -84,11 +84,13 @@ export const describeConfiguration = (config) => {
|
|
|
84
84
|
const sections = new Array();
|
|
85
85
|
for (const artifact of config) {
|
|
86
86
|
const lines = new Array();
|
|
87
|
-
lines.push(`### ${artifact.kind}: ${artifact.name} (${artifact.kind}-${artifact.id})`);
|
|
87
|
+
lines.push(`### ${artifact.kind}: ${artifact.name ?? ""} (${artifact.kind}-${artifact.id ?? ""})`);
|
|
88
88
|
lines.push("");
|
|
89
89
|
if (artifact.file !== undefined)
|
|
90
90
|
lines.push(`- file: \`${artifact.file}\``);
|
|
91
91
|
lines.push(`- description: ${collapse(artifact.desc)}`);
|
|
92
|
+
for (const prop of artifact.props ?? [])
|
|
93
|
+
lines.push(describeProperty(prop, ""));
|
|
92
94
|
for (const child of artifact.childs ?? [])
|
|
93
95
|
lines.push(describeObject(child, 2, ""));
|
|
94
96
|
sections.push(lines.join("\n"));
|
package/dst/specbook-cmd-edit.js
CHANGED
|
@@ -42,6 +42,9 @@ const instruction = (description, now) => textframe `
|
|
|
42
42
|
export const editSpecification = async (options) => {
|
|
43
43
|
const basedir = options.basedir ?? ".";
|
|
44
44
|
const files = options.config.map((artifact) => artifact.file).filter((file) => file !== undefined);
|
|
45
|
+
/* ensure a non-empty edit request was given */
|
|
46
|
+
if (options.query.trim() === "")
|
|
47
|
+
throw new Error("no edit request given");
|
|
45
48
|
/* gather the existing artifact files */
|
|
46
49
|
const existing = files
|
|
47
50
|
.filter((file) => fs.existsSync(path.join(basedir, file)))
|
|
@@ -61,7 +64,9 @@ export const editSpecification = async (options) => {
|
|
|
61
64
|
options.verbose(`ignoring unconfigured file "${block.name}"`);
|
|
62
65
|
continue;
|
|
63
66
|
}
|
|
64
|
-
|
|
67
|
+
const file = path.join(basedir, block.name);
|
|
68
|
+
fs.mkdirSync(path.dirname(file), { recursive: true });
|
|
69
|
+
fs.writeFileSync(file, block.content, "utf8");
|
|
65
70
|
options.verbose(`wrote artifact file "${block.name}"`);
|
|
66
71
|
written.push(block.name);
|
|
67
72
|
}
|
|
@@ -32,8 +32,9 @@ export const parseOutputSpec = (spec) => {
|
|
|
32
32
|
if (spec === "-")
|
|
33
33
|
return { format: "json", output: spec };
|
|
34
34
|
const ext = spec.match(/\.([a-zA-Z0-9]+)$/);
|
|
35
|
-
|
|
36
|
-
|
|
35
|
+
const format = ext !== null ? extensions[ext[1].toLowerCase()] : undefined;
|
|
36
|
+
if (format !== undefined)
|
|
37
|
+
return { format, output: spec };
|
|
37
38
|
throw new Error(`unable to infer export format from output "${spec}" ` +
|
|
38
39
|
"(use an explicit \"<format>:<filename>\" specification)");
|
|
39
40
|
};
|
|
@@ -46,6 +46,9 @@ const instruction = (description, now) => textframe `
|
|
|
46
46
|
export const importSpecification = async (options) => {
|
|
47
47
|
const basedir = options.basedir ?? ".";
|
|
48
48
|
const files = options.config.map((artifact) => artifact.file).filter((file) => file !== undefined);
|
|
49
|
+
/* ensure at least one foreign source was given */
|
|
50
|
+
if (options.inputs.length === 0)
|
|
51
|
+
throw new Error("no foreign sources given to import");
|
|
49
52
|
/* gather the existing artifact files and the foreign sources */
|
|
50
53
|
const existing = files
|
|
51
54
|
.filter((file) => fs.existsSync(path.join(basedir, file)))
|
|
@@ -65,7 +68,9 @@ export const importSpecification = async (options) => {
|
|
|
65
68
|
options.verbose(`ignoring unconfigured file "${block.name}"`);
|
|
66
69
|
continue;
|
|
67
70
|
}
|
|
68
|
-
|
|
71
|
+
const file = path.join(basedir, block.name);
|
|
72
|
+
fs.mkdirSync(path.dirname(file), { recursive: true });
|
|
73
|
+
fs.writeFileSync(file, block.content, "utf8");
|
|
69
74
|
options.verbose(`wrote artifact file "${block.name}"`);
|
|
70
75
|
written.push(block.name);
|
|
71
76
|
}
|
package/dst/specbook-cmd-init.js
CHANGED
|
@@ -21,14 +21,16 @@ export const initSpecification = (options) => {
|
|
|
21
21
|
options.verbose(`skipping existing artifact file "${artifact.file}"`);
|
|
22
22
|
continue;
|
|
23
23
|
}
|
|
24
|
-
const name =
|
|
24
|
+
const name = artifact.name ?? "";
|
|
25
|
+
const paren = artifact.id !== undefined ? ` (${artifact.id})` : "";
|
|
25
26
|
const text = "---\n" +
|
|
26
27
|
`Created: ${now}\n` +
|
|
27
28
|
`Modified: ${now}\n` +
|
|
28
29
|
"---\n" +
|
|
29
30
|
"\n" +
|
|
30
|
-
`# ${artifact.kind}: ${name}
|
|
31
|
+
`# ${artifact.kind}: ${name}${paren}\n` +
|
|
31
32
|
"\n";
|
|
33
|
+
fs.mkdirSync(path.dirname(file), { recursive: true });
|
|
32
34
|
fs.writeFileSync(file, text, "utf8");
|
|
33
35
|
options.verbose(`created artifact file "${artifact.file}"`);
|
|
34
36
|
created.push(artifact.file);
|
package/dst/specbook-cmd-lint.js
CHANGED
|
@@ -25,11 +25,13 @@ export const lint = (options) => {
|
|
|
25
25
|
config = loaded.config ?? undefined;
|
|
26
26
|
}
|
|
27
27
|
/* parse and validate the specification Markdown files */
|
|
28
|
-
const
|
|
28
|
+
const exists = fs.existsSync(options.basedir) && fs.statSync(options.basedir).isDirectory();
|
|
29
|
+
const files = exists ? scanMarkdown(options.basedir) : [];
|
|
29
30
|
options.verbose(`parsing ${files.length} specification file(s) below "${options.basedir}"`);
|
|
30
31
|
if (files.length === 0)
|
|
31
32
|
diagnostics.push({ file: options.basedir, line: 1, column: 1,
|
|
32
|
-
message: "no Markdown files found below base directory"
|
|
33
|
+
message: exists ? "no Markdown files found below base directory" :
|
|
34
|
+
"base directory does not exist" });
|
|
33
35
|
const sources = new Array();
|
|
34
36
|
for (const file of files) {
|
|
35
37
|
try {
|
package/dst/specbook-config.js
CHANGED
|
@@ -24,7 +24,17 @@ const lineColOfPath = (yaml, cst, path) => {
|
|
|
24
24
|
/* load and validate a YAML schema configuration file */
|
|
25
25
|
export const loadConfig = (file) => {
|
|
26
26
|
const diagnostics = [];
|
|
27
|
-
|
|
27
|
+
/* read the configuration file */
|
|
28
|
+
let yaml;
|
|
29
|
+
try {
|
|
30
|
+
yaml = fs.readFileSync(file, "utf8");
|
|
31
|
+
}
|
|
32
|
+
catch (err) {
|
|
33
|
+
diagnostics.push({ file, line: 1, column: 1,
|
|
34
|
+
message: "cannot read configuration file: " +
|
|
35
|
+
(err instanceof Error ? err.message : String(err)) });
|
|
36
|
+
return { config: null, diagnostics };
|
|
37
|
+
}
|
|
28
38
|
/* syntactically parse the YAML content */
|
|
29
39
|
let obj;
|
|
30
40
|
try {
|
|
@@ -37,17 +47,22 @@ export const loadConfig = (file) => {
|
|
|
37
47
|
line: e?.linePos?.[0]?.line ?? 1,
|
|
38
48
|
column: e?.linePos?.[0]?.col ?? 1,
|
|
39
49
|
message: "invalid YAML syntax: " +
|
|
40
|
-
(err instanceof Error ? err.message.replace(/:
|
|
50
|
+
(err instanceof Error ? err.message.replace(/:[\s\S]*$/, "") : String(err))
|
|
41
51
|
});
|
|
42
52
|
return { config: null, diagnostics };
|
|
43
53
|
}
|
|
44
|
-
/* semantically validate against the schema of the configuration
|
|
45
|
-
|
|
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
|
+
};
|
|
46
61
|
const result = v.safeParse(SchemaSpecification, obj);
|
|
47
62
|
if (!result.success) {
|
|
48
63
|
for (const issue of result.issues) {
|
|
49
64
|
const path = (issue.path ?? []).map((item) => item.key);
|
|
50
|
-
const pos =
|
|
65
|
+
const pos = posOfPath(path);
|
|
51
66
|
diagnostics.push({ file, line: pos.line, column: pos.column,
|
|
52
67
|
message: `invalid configuration: ${path.join(".")}: ${issue.message}` });
|
|
53
68
|
}
|
|
@@ -57,7 +72,7 @@ export const loadConfig = (file) => {
|
|
|
57
72
|
const checkFileField = (objects, path, depth) => {
|
|
58
73
|
objects.forEach((object, i) => {
|
|
59
74
|
if (depth > 1 && object.file !== undefined) {
|
|
60
|
-
const pos =
|
|
75
|
+
const pos = posOfPath([...path, i, "file"]);
|
|
61
76
|
diagnostics.push({ file, line: pos.line, column: pos.column,
|
|
62
77
|
message: `"file" field is only allowed on the first (artifact) level (found on level ${depth})` });
|
|
63
78
|
}
|
|
@@ -76,7 +91,7 @@ export const loadConfig = (file) => {
|
|
|
76
91
|
compileValueExpr(prop.value);
|
|
77
92
|
}
|
|
78
93
|
catch (err) {
|
|
79
|
-
const pos =
|
|
94
|
+
const pos = posOfPath([...path, i, "props", j, "value"]);
|
|
80
95
|
diagnostics.push({ file, line: pos.line, column: pos.column,
|
|
81
96
|
message: `invalid value expression "${prop.value}": ` +
|
|
82
97
|
(err instanceof Error ? err.message : String(err)) });
|
package/dst/specbook-diagram.js
CHANGED
|
@@ -4,6 +4,9 @@
|
|
|
4
4
|
** Licensed under Apache 2.0 <https://spdx.org/licenses/Apache-2.0>
|
|
5
5
|
*/
|
|
6
6
|
import { referenceRegex, buildLinkIndex, resolveUnique, resolveSet, anchorPaths } from "./specbook-link.js";
|
|
7
|
+
/* the single Wiki-style reference match (the non-global sibling of
|
|
8
|
+
the imported, global "referenceRegex") */
|
|
9
|
+
const referenceOnce = /\[\[([^[\]]+)\]\]/;
|
|
7
10
|
/* strip the inline code markup of a name or property value
|
|
8
11
|
(preserved in the AST for rendering) for labeling purposes */
|
|
9
12
|
const plainText = (text) => text.replace(/`/g, "");
|
|
@@ -27,7 +30,56 @@ const arityToken = (text) => /^[^\s[\]"]+$/.test(text) ? text : quoted(text);
|
|
|
27
30
|
collapsed, as a directive is a single line) */
|
|
28
31
|
const configValue = (value) => {
|
|
29
32
|
const text = String(value).replace(/\s+/g, " ").trim();
|
|
30
|
-
return
|
|
33
|
+
return /^[^\s"]+$/.test(text) ? text : quoted(text);
|
|
34
|
+
};
|
|
35
|
+
/* generate the Gradia spec text of a derived diagram, opened by a
|
|
36
|
+
"#type" directive and the configured "#config" directives so every
|
|
37
|
+
embedded spec is self-contained: one node statement per object (the
|
|
38
|
+
unique anchor path as the node id, the object name as the displayed
|
|
39
|
+
label) and one edge statement per derived edge */
|
|
40
|
+
const renderSpec = (diagram, type, center, nodes, edges, index, anchors) => {
|
|
41
|
+
const lines = [`#type ${type}`];
|
|
42
|
+
const config = diagram.config ?? {};
|
|
43
|
+
for (const key of Object.keys(config)) {
|
|
44
|
+
const value = config[key];
|
|
45
|
+
if (value !== undefined)
|
|
46
|
+
lines.push(`#config ${key} ${configValue(value)}`);
|
|
47
|
+
}
|
|
48
|
+
lines.push("");
|
|
49
|
+
for (const node of nodes) {
|
|
50
|
+
const anchor = anchors.get(node) ?? node.id;
|
|
51
|
+
const attrs = [`url: ${atom(`#${anchor}`)}`];
|
|
52
|
+
if (diagram.qualified === true && node.kind !== "")
|
|
53
|
+
attrs.push(`type: ${atom(node.kind)}`);
|
|
54
|
+
if (type === "hub" ? node === center : node.primary === true)
|
|
55
|
+
attrs.push("primary: true");
|
|
56
|
+
/* attach the values of the configured properties as Gradia
|
|
57
|
+
key/value attributes (a node lacking a property is fine, as
|
|
58
|
+
the node set can mix objects of different kinds), with every
|
|
59
|
+
"[[...]]" reference stripped to its target object name */
|
|
60
|
+
for (const key of diagram.properties ?? []) {
|
|
61
|
+
const value = node.properties.find((property) => plainKey(property.key) === key)?.value;
|
|
62
|
+
if (value !== undefined) {
|
|
63
|
+
const text = value.replace(referenceRegex, (_, reference) => {
|
|
64
|
+
const target = resolveUnique(index, reference.trim()).target;
|
|
65
|
+
return target !== undefined ? target.name : reference.trim();
|
|
66
|
+
});
|
|
67
|
+
attrs.push(`${atom(key)}: ${atom(plainText(text))}`);
|
|
68
|
+
}
|
|
69
|
+
}
|
|
70
|
+
lines.push(`${atom(anchor)}: ${atom(plainText(node.name))} [ ${attrs.join(", ")} ]`);
|
|
71
|
+
}
|
|
72
|
+
if (edges.length > 0) {
|
|
73
|
+
lines.push("");
|
|
74
|
+
for (const edge of edges) {
|
|
75
|
+
const op = "--" +
|
|
76
|
+
(edge.name !== undefined && edge.name !== "" ? `(${nameToken(edge.name)})--` : "") + ">" +
|
|
77
|
+
(edge.arity !== undefined && edge.arity !== "" ? `[${arityToken(edge.arity)}]` : "");
|
|
78
|
+
lines.push(`${atom(anchors.get(edge.source) ?? edge.source.id)} ${op} ` +
|
|
79
|
+
atom(anchors.get(edge.target) ?? edge.target.id));
|
|
80
|
+
}
|
|
81
|
+
}
|
|
82
|
+
return lines.join("\n") + "\n";
|
|
31
83
|
};
|
|
32
84
|
/* derive the Gradia spec of a single object from its "diagram:"
|
|
33
85
|
schema configuration (the returned spec is absent whenever the
|
|
@@ -69,7 +121,8 @@ const deriveDiagram = (object, diagram, index, anchors, parents) => {
|
|
|
69
121
|
walk(object);
|
|
70
122
|
}
|
|
71
123
|
const edgeObjects = diagram.edges !== undefined ? resolvePatterns(diagram.edges, "edges") : [];
|
|
72
|
-
|
|
124
|
+
const edgeSet = new Set(edgeObjects);
|
|
125
|
+
nodes = nodes.filter((node) => !edgeSet.has(node));
|
|
73
126
|
const nodeSet = new Set(nodes);
|
|
74
127
|
/* derive the edges: from the "[[...]]" references of the node
|
|
75
128
|
objects (per the "links" selection) and from the edge objects by
|
|
@@ -98,8 +151,8 @@ const deriveDiagram = (object, diagram, index, anchors, parents) => {
|
|
|
98
151
|
const value = diagram.edgeTarget !== undefined ?
|
|
99
152
|
edgeObject.properties.find((property) => plainKey(property.key) === diagram.edgeTarget)?.value :
|
|
100
153
|
edgeObject.properties.map((property) => property.value)
|
|
101
|
-
.find((v) =>
|
|
102
|
-
const reference = value?.match(
|
|
154
|
+
.find((v) => referenceOnce.test(v));
|
|
155
|
+
const reference = value?.match(referenceOnce)?.[1].trim();
|
|
103
156
|
const target = reference !== undefined ? resolveUnique(index, reference).target : undefined;
|
|
104
157
|
if (source === undefined) {
|
|
105
158
|
errors.push(`diagram edge object "${edgeObject.name}" has no parent object as source`);
|
|
@@ -124,15 +177,18 @@ const deriveDiagram = (object, diagram, index, anchors, parents) => {
|
|
|
124
177
|
edges.push({ source: parent, target: node });
|
|
125
178
|
}
|
|
126
179
|
}
|
|
127
|
-
else
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
180
|
+
else {
|
|
181
|
+
if (diagram.edges !== undefined)
|
|
182
|
+
errors.push("\"grid\" diagram cannot carry an \"edges\" configuration");
|
|
183
|
+
if (diagram.hierarchy === true)
|
|
184
|
+
errors.push("\"grid\" diagram cannot carry a \"hierarchy\" configuration");
|
|
185
|
+
}
|
|
131
186
|
/* deduplicate the edges (the same reference can occur in
|
|
132
187
|
multiple texts of the same node object) */
|
|
133
188
|
const seenEdges = new Set();
|
|
134
189
|
edges = edges.filter((edge) => {
|
|
135
|
-
const key = `${anchors.get(edge.source)
|
|
190
|
+
const key = `${anchors.get(edge.source) ?? edge.source.id}` +
|
|
191
|
+
`\u0000${anchors.get(edge.target) ?? edge.target.id}` +
|
|
136
192
|
`\u0000${edge.name ?? ""}\u0000${edge.arity ?? ""}`;
|
|
137
193
|
if (seenEdges.has(key))
|
|
138
194
|
return false;
|
|
@@ -144,7 +200,7 @@ const deriveDiagram = (object, diagram, index, anchors, parents) => {
|
|
|
144
200
|
resolved object of an explicit "[[...]]" reference otherwise */
|
|
145
201
|
let center = object;
|
|
146
202
|
if (type === "hub" && diagram.center !== undefined && diagram.center !== "self") {
|
|
147
|
-
const reference = diagram.center.match(
|
|
203
|
+
const reference = diagram.center.match(referenceOnce)?.[1].trim();
|
|
148
204
|
const resolved = reference !== undefined ? resolveUnique(index, reference) : undefined;
|
|
149
205
|
if (resolved?.target === undefined) {
|
|
150
206
|
errors.push(`${resolved?.ambiguous === true ? "ambiguous" : "unresolvable"}` +
|
|
@@ -190,53 +246,7 @@ const deriveDiagram = (object, diagram, index, anchors, parents) => {
|
|
|
190
246
|
diagram carries no information beyond the object itself */
|
|
191
247
|
if (diagram.collapse !== false && nodes.length === 1 && edges.length === 0)
|
|
192
248
|
return { errors };
|
|
193
|
-
|
|
194
|
-
the configured "#config" directives so every embedded spec is
|
|
195
|
-
self-contained: one node statement per object (the unique anchor
|
|
196
|
-
path as the node id, the object name as the displayed label)
|
|
197
|
-
and one edge statement per derived edge */
|
|
198
|
-
const lines = [`#type ${type}`];
|
|
199
|
-
const config = diagram.config ?? {};
|
|
200
|
-
for (const key of Object.keys(config)) {
|
|
201
|
-
const value = config[key];
|
|
202
|
-
if (value !== undefined)
|
|
203
|
-
lines.push(`#config ${key} ${configValue(value)}`);
|
|
204
|
-
}
|
|
205
|
-
lines.push("");
|
|
206
|
-
for (const node of nodes) {
|
|
207
|
-
const anchor = anchors.get(node) ?? node.id;
|
|
208
|
-
const attrs = [`url: ${atom(`#${anchor}`)}`];
|
|
209
|
-
if (diagram.qualified === true && node.kind !== "")
|
|
210
|
-
attrs.push(`type: ${atom(node.kind)}`);
|
|
211
|
-
if (type === "hub" ? node === center : node.primary === true)
|
|
212
|
-
attrs.push("primary: true");
|
|
213
|
-
/* attach the values of the configured properties as Gradia
|
|
214
|
-
key/value attributes (a node lacking a property is fine, as
|
|
215
|
-
the node set can mix objects of different kinds), with every
|
|
216
|
-
"[[...]]" reference stripped to its target object name */
|
|
217
|
-
for (const key of diagram.properties ?? []) {
|
|
218
|
-
const value = node.properties.find((property) => plainKey(property.key) === key)?.value;
|
|
219
|
-
if (value !== undefined) {
|
|
220
|
-
const text = value.replace(referenceRegex, (_, reference) => {
|
|
221
|
-
const target = resolveUnique(index, reference.trim()).target;
|
|
222
|
-
return target !== undefined ? target.name : reference.trim();
|
|
223
|
-
});
|
|
224
|
-
attrs.push(`${atom(key)}: ${atom(plainText(text))}`);
|
|
225
|
-
}
|
|
226
|
-
}
|
|
227
|
-
lines.push(`${atom(anchor)}: ${atom(plainText(node.name))} [ ${attrs.join(", ")} ]`);
|
|
228
|
-
}
|
|
229
|
-
if (edges.length > 0) {
|
|
230
|
-
lines.push("");
|
|
231
|
-
for (const edge of edges) {
|
|
232
|
-
const op = "--" +
|
|
233
|
-
(edge.name !== undefined && edge.name !== "" ? `(${nameToken(edge.name)})--` : "") + ">" +
|
|
234
|
-
(edge.arity !== undefined && edge.arity !== "" ? `[${arityToken(edge.arity)}]` : "");
|
|
235
|
-
lines.push(`${atom(anchors.get(edge.source) ?? edge.source.id)} ${op} ` +
|
|
236
|
-
atom(anchors.get(edge.target) ?? edge.target.id));
|
|
237
|
-
}
|
|
238
|
-
}
|
|
239
|
-
return { spec: lines.join("\n") + "\n", errors };
|
|
249
|
+
return { spec: renderSpec(diagram, type, center, nodes, edges, index, anchors), errors };
|
|
240
250
|
};
|
|
241
251
|
/* collect the objects whose schema configuration carries a "diagram:"
|
|
242
252
|
field (the schema resolution mirrors the semantic validation) */
|
|
@@ -25,10 +25,13 @@ export const renderAst = async (specification, format, config) => {
|
|
|
25
25
|
};
|
|
26
26
|
specification.artifacts.forEach((artifact, i) => artifact.objects.forEach((object, j) => walk(object, plain.artifacts[i].objects[j])));
|
|
27
27
|
}
|
|
28
|
+
/* serialize the plain specification into the target format
|
|
29
|
+
(JSON and JSON5 in compressed form, as they are intended
|
|
30
|
+
for machine consumption only) */
|
|
28
31
|
if (format === "json")
|
|
29
|
-
return Buffer.from(JSON.stringify(plain
|
|
32
|
+
return Buffer.from(JSON.stringify(plain) + "\n", "utf8");
|
|
30
33
|
else if (format === "json5")
|
|
31
|
-
return Buffer.from(JSON5.stringify(plain
|
|
34
|
+
return Buffer.from(JSON5.stringify(plain) + "\n", "utf8");
|
|
32
35
|
else if (format === "yaml")
|
|
33
36
|
return Buffer.from(stringifyYaml(plain), "utf8");
|
|
34
37
|
else
|
|
@@ -18,7 +18,6 @@ export type PaperSetup = {
|
|
|
18
18
|
right: number;
|
|
19
19
|
};
|
|
20
20
|
};
|
|
21
|
-
export declare const paperSizes: string[];
|
|
22
21
|
export declare const paperSizeDefault = "A4";
|
|
23
22
|
export declare const paperSetup: (paper: string) => PaperSetup;
|
|
24
23
|
export declare const paperLength: (setup: PaperSetup, value: number) => string;
|