@rse/specbook 1.2.1 → 1.2.3
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 +6 -2
- package/dst/specbook-api.d.ts +1 -0
- package/dst/specbook-api.js +19 -5
- package/dst/specbook-cli.js +1 -1
- package/dst/specbook-cmd-describe.js +2 -2
- package/dst/specbook-cmd-lint.js +10 -2
- package/dst/specbook-cmd-preview.js +12 -9
- package/dst/specbook-config.d.ts +4 -0
- package/dst/specbook-config.js +48 -10
- package/dst/specbook-diagram.js +56 -37
- package/dst/specbook-export-common.js +33 -22
- package/dst/specbook-export-html.css +62 -0
- package/dst/specbook-export-html.d.ts +1 -0
- package/dst/specbook-export-html.js +178 -63
- package/dst/specbook-export-md.js +9 -7
- package/dst/specbook-export-pdf.js +22 -8
- package/dst/specbook-format-schema.js +2 -0
- package/dst/specbook-format-spec.js +2 -0
- package/dst/specbook-format.md +3 -2
- package/dst/specbook-format.yaml +200 -216
- package/dst/specbook-link.d.ts +1 -0
- package/dst/specbook-link.js +11 -4
- package/dst/specbook-parse-common.d.ts +1 -0
- package/dst/specbook-parse-common.js +5 -0
- package/dst/specbook-parse-semantic.d.ts +2 -1
- package/dst/specbook-parse-semantic.js +37 -25
- package/dst/specbook-parse-syntax.js +20 -23
- package/dst/specbook-parse.d.ts +5 -0
- package/dst/specbook-parse.js +20 -1
- package/package.json +3 -3
package/README.md
CHANGED
|
@@ -70,7 +70,8 @@ CLI with commands `specbook <xxx>`, and an MCP service with tools
|
|
|
70
70
|
- **HTML Export for Developers**:
|
|
71
71
|
The HTML export is a self-contained single document with a
|
|
72
72
|
table-of-contents (also as a slide-in side panel for quick navigation),
|
|
73
|
-
full-text search, embedded images,
|
|
73
|
+
full-text search, embedded images, a scroll progress meter (which
|
|
74
|
+
also scrolls back to the top on click), and a light/dark
|
|
74
75
|
theme toggle. It is intended for the day-to-day online reading during
|
|
75
76
|
development. The HTML export can map all object kinds to nested
|
|
76
77
|
sections or compact tables, or an automatic mixture which collapses
|
|
@@ -140,6 +141,7 @@ export class SpecBook {
|
|
|
140
141
|
basedir?: string,
|
|
141
142
|
formats?: ExportFormat[],
|
|
142
143
|
realtime?: boolean,
|
|
144
|
+
outputs?: string[], /* paths "onExport" writes, refused if observed */
|
|
143
145
|
onExport: (buffers: Buffer[]) => void | Promise<void>
|
|
144
146
|
}): Promise<void>
|
|
145
147
|
|
|
@@ -357,7 +359,9 @@ Options:
|
|
|
357
359
|
silent for one second. A failed re-export is reported and leaves the
|
|
358
360
|
observe loop intact, so a transiently invalid specification does not
|
|
359
361
|
end the watch. The outputs have to be regular files, as `-` (stdout)
|
|
360
|
-
cannot receive a repeated export
|
|
362
|
+
cannot receive a repeated export, and none of them may be an observed
|
|
363
|
+
source file itself, as its own write would re-trigger the observation
|
|
364
|
+
endlessly.
|
|
361
365
|
|
|
362
366
|
- `-a|--addr <ip-addr>`, `-p|--port <tcp-port>` (`preview` only):
|
|
363
367
|
The IP address (default: `127.0.0.1`) and TCP port (default: `12345`)
|
package/dst/specbook-api.d.ts
CHANGED
package/dst/specbook-api.js
CHANGED
|
@@ -7,7 +7,7 @@ import * as fs from "node:fs";
|
|
|
7
7
|
import * as path from "node:path";
|
|
8
8
|
import { fileURLToPath } from "node:url";
|
|
9
9
|
import { glob } from "glob";
|
|
10
|
-
import { loadConfig } from "./specbook-config.js";
|
|
10
|
+
import { loadConfig, configStatistics } from "./specbook-config.js";
|
|
11
11
|
import { renderDiagnostic, renderDiagnosticVerbose } from "./specbook-diagnostic.js";
|
|
12
12
|
import { initSpecification } from "./specbook-cmd-init.js";
|
|
13
13
|
import { lint } from "./specbook-cmd-lint.js";
|
|
@@ -69,6 +69,9 @@ export class SpecBook {
|
|
|
69
69
|
if (config === undefined)
|
|
70
70
|
throw new Error("invalid configuration:\n" +
|
|
71
71
|
diagnostics.map(renderDiagnostic).join("\n"));
|
|
72
|
+
const stats = configStatistics(config);
|
|
73
|
+
verbose(`loaded schema configuration specifying ${literal(stats.objects)} object kind(s) ` +
|
|
74
|
+
`and ${literal(stats.links)} link relationship(s)`);
|
|
72
75
|
return config;
|
|
73
76
|
}
|
|
74
77
|
/* initialize the configured specification artifact files
|
|
@@ -112,8 +115,9 @@ export class SpecBook {
|
|
|
112
115
|
export fail before the specification is even parsed */
|
|
113
116
|
if (requested.includes("pdf"))
|
|
114
117
|
await requireBrowser(verbose);
|
|
115
|
-
|
|
116
|
-
basedir: options.basedir ?? ".", verbose })
|
|
118
|
+
const result = lint({ config: await this.configFiles(options.config),
|
|
119
|
+
basedir: options.basedir ?? ".", verbose });
|
|
120
|
+
return this.renderFormats(result, requested, verbose, options.realtime === true);
|
|
117
121
|
}
|
|
118
122
|
/* keep an export in sync with its sources (the shared core of
|
|
119
123
|
"watch" and "preview"): every change of a configuration file, of an
|
|
@@ -132,6 +136,14 @@ export class SpecBook {
|
|
|
132
136
|
observed, too, so a failing export (even one due to an
|
|
133
137
|
invalid configuration) still keeps the observe loop fed */
|
|
134
138
|
const result = lint({ config, basedir: options.basedir ?? ".", verbose });
|
|
139
|
+
const files = [...config.map((file) => path.resolve(file)), ...result.files];
|
|
140
|
+
/* an output which is itself an observed source would re-trigger
|
|
141
|
+
the observation with its own write and hence feed an endless
|
|
142
|
+
re-export loop, so refuse it before it is ever written */
|
|
143
|
+
const collision = options.outputs?.find((output) => files.includes(path.resolve(output)));
|
|
144
|
+
if (collision !== undefined)
|
|
145
|
+
throw new Error(`the output "${collision}" is an observed source file, ` +
|
|
146
|
+
"which would re-trigger the observation endlessly");
|
|
135
147
|
try {
|
|
136
148
|
await options.onExport(await this.renderFormats(result, options.formats, verbose, options.realtime));
|
|
137
149
|
}
|
|
@@ -139,11 +151,13 @@ export class SpecBook {
|
|
|
139
151
|
verbose("export failed: " +
|
|
140
152
|
(err instanceof Error ? err.message : String(err)), "notice");
|
|
141
153
|
}
|
|
142
|
-
return
|
|
154
|
+
return files;
|
|
143
155
|
}, verbose);
|
|
144
156
|
}
|
|
145
157
|
/* export the specification like "export" and then keep the export
|
|
146
|
-
in sync with its sources (see "observe")
|
|
158
|
+
in sync with its sources (see "observe"), where "outputs" names the
|
|
159
|
+
files "onExport" writes, so an output which is itself an observed
|
|
160
|
+
source can be refused */
|
|
147
161
|
async watch(options) {
|
|
148
162
|
const verbose = this.verboseOf("export");
|
|
149
163
|
const requested = options.formats ?? ["json"];
|
package/dst/specbook-cli.js
CHANGED
|
@@ -148,7 +148,7 @@ withCommonOptions(program.command("export"))
|
|
|
148
148
|
};
|
|
149
149
|
if (opts.watch)
|
|
150
150
|
await specbook.watch({ config: configOf(opts), basedir: opts.basedir,
|
|
151
|
-
formats: distinct, onExport: write });
|
|
151
|
+
formats: distinct, outputs: outputs.map(({ output }) => output), onExport: write });
|
|
152
152
|
else
|
|
153
153
|
await write(await specbook.export({ config: configOf(opts), basedir: opts.basedir,
|
|
154
154
|
formats: distinct }));
|
|
@@ -78,13 +78,13 @@ const schemaSection = (config, schema, embed, standard, compress) => {
|
|
|
78
78
|
yaml = compressYaml(yaml, compress);
|
|
79
79
|
return textframe(`
|
|
80
80
|
The **SpecBook SCHEMA Model** is the following YAML schema configuration:
|
|
81
|
-
`) + "\n```yaml\n" + yaml.replace(/\n*$/, "\n") + "```\n
|
|
81
|
+
`) + "\n```yaml\n" + yaml.replace(/\n*$/, "\n") + "```\n";
|
|
82
82
|
};
|
|
83
83
|
/* render the reference to the specification Markdown files */
|
|
84
84
|
const specSection = (basedir) => textframe(`
|
|
85
85
|
The **SpecBook SPEC Model** is the set of Markdown specification files in directory:
|
|
86
86
|
@${basedir}
|
|
87
|
-
`)
|
|
87
|
+
`);
|
|
88
88
|
/* describe the generic SpecBook models and formats as Markdown,
|
|
89
89
|
optionally extended by the references to (or the embeddings of) the
|
|
90
90
|
artifacts of the particular project instantiation, and optionally
|
package/dst/specbook-cmd-lint.js
CHANGED
|
@@ -5,9 +5,9 @@
|
|
|
5
5
|
*/
|
|
6
6
|
import * as fs from "node:fs";
|
|
7
7
|
import * as path from "node:path";
|
|
8
|
-
import { loadConfig } from "./specbook-config.js";
|
|
8
|
+
import { loadConfig, configStatistics } from "./specbook-config.js";
|
|
9
9
|
import { literal } from "./specbook-verbose.js";
|
|
10
|
-
import { parseSpecification, resolveArtifact } from "./specbook-parse.js";
|
|
10
|
+
import { parseSpecification, specStatistics, resolveArtifact } from "./specbook-parse.js";
|
|
11
11
|
/* collect the distinct artifact files the schema configuration
|
|
12
12
|
references, in their declaration order, each mapped onto whether
|
|
13
13
|
all of its artifacts are optional and hence the file may be absent */
|
|
@@ -30,6 +30,11 @@ export const lint = (options) => {
|
|
|
30
30
|
const loaded = loadConfig(options.config);
|
|
31
31
|
diagnostics.push(...loaded.diagnostics);
|
|
32
32
|
const config = loaded.config;
|
|
33
|
+
if (config !== undefined) {
|
|
34
|
+
const stats = configStatistics(config);
|
|
35
|
+
options.verbose(`loaded schema configuration specifying ${literal(stats.objects)} object kind(s) ` +
|
|
36
|
+
`and ${literal(stats.links)} link relationship(s)`);
|
|
37
|
+
}
|
|
33
38
|
/* read the artifact files the configuration references, resolved
|
|
34
39
|
against the base directory and tolerating an absent file if all
|
|
35
40
|
its artifacts are optional */
|
|
@@ -64,6 +69,9 @@ export const lint = (options) => {
|
|
|
64
69
|
const result = parseSpecification(sources, config);
|
|
65
70
|
diagnostics.push(...result.diagnostics);
|
|
66
71
|
watched.push(...result.assets);
|
|
72
|
+
const stats = specStatistics(result.specification);
|
|
73
|
+
options.verbose(`parsed specification defining ${literal(stats.objects)} object(s) ` +
|
|
74
|
+
`and ${literal(stats.links)} link relationship(s)`);
|
|
67
75
|
/* report the non-optional artifacts absent from the specification,
|
|
68
76
|
against their loaded artifact file (an absent or unreadable file
|
|
69
77
|
is already reported above) or else against the configuration */
|
|
@@ -5,29 +5,32 @@
|
|
|
5
5
|
*/
|
|
6
6
|
import Fastify from "fastify";
|
|
7
7
|
import fastifyWebsocket from "@fastify/websocket";
|
|
8
|
+
import { renderPlaceholder } from "./specbook-export-html.js";
|
|
8
9
|
import { literal } from "./specbook-verbose.js";
|
|
9
10
|
/* the default listening address and port of the preview server */
|
|
10
11
|
export const previewAddr = "127.0.0.1";
|
|
11
12
|
export const previewPort = 12345;
|
|
12
13
|
/* serve the live HTML preview: a plain GET on "/" answers with the
|
|
13
|
-
most recent HTML export (or with 503 before the
|
|
14
|
-
one), while a WebSocket upgrade on "/" subscribes
|
|
15
|
-
"RELOAD" command every fresh export broadcasts */
|
|
14
|
+
most recent HTML export (or with the 503 placeholder page before the
|
|
15
|
+
first successful one), while a WebSocket upgrade on "/" subscribes
|
|
16
|
+
the client to the "RELOAD" command every fresh export broadcasts */
|
|
16
17
|
export const servePreview = async (options) => {
|
|
17
18
|
let html;
|
|
18
19
|
const clients = new Set();
|
|
19
20
|
const fastify = Fastify();
|
|
20
21
|
await fastify.register(fastifyWebsocket);
|
|
22
|
+
/* the placeholder page answering a GET before the first successful
|
|
23
|
+
export: it still carries the live preview client, so the browser
|
|
24
|
+
subscribes to the "RELOAD" command right away and replaces itself
|
|
25
|
+
with the document once the first export arrives */
|
|
26
|
+
const placeholder = renderPlaceholder("no specification export available yet");
|
|
21
27
|
/* answer a plain GET with the document and subscribe a WebSocket upgrade */
|
|
22
28
|
fastify.route({
|
|
23
29
|
method: "GET",
|
|
24
30
|
url: "/",
|
|
25
31
|
handler: (_request, reply) => {
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
.send("no specification export available yet\n");
|
|
29
|
-
return reply.type("text/html; charset=utf-8")
|
|
30
|
-
.header("cache-control", "no-store").send(html);
|
|
32
|
+
reply.type("text/html; charset=utf-8").header("cache-control", "no-store");
|
|
33
|
+
return html === undefined ? reply.code(503).send(placeholder) : reply.send(html);
|
|
31
34
|
},
|
|
32
35
|
wsHandler: (socket, request) => {
|
|
33
36
|
/* identify the client by its remote address and port */
|
|
@@ -53,7 +56,7 @@ export const servePreview = async (options) => {
|
|
|
53
56
|
options.verbose(`listening on ${literal(url)}`, "notice");
|
|
54
57
|
return {
|
|
55
58
|
update: (buffer) => {
|
|
56
|
-
/* the first export turns the 503
|
|
59
|
+
/* the first export turns the 503 placeholder into the document */
|
|
57
60
|
if (html === undefined)
|
|
58
61
|
options.verbose(`serving ${literal(url)}`, "notice");
|
|
59
62
|
html = buffer;
|
package/dst/specbook-config.d.ts
CHANGED
|
@@ -1,5 +1,9 @@
|
|
|
1
1
|
import { Schema } from "./specbook-format-schema.js";
|
|
2
2
|
import { type Diagnostic } from "./specbook-diagnostic.js";
|
|
3
|
+
export declare const configStatistics: (config: Schema) => {
|
|
4
|
+
objects: number;
|
|
5
|
+
links: number;
|
|
6
|
+
};
|
|
3
7
|
export declare const loadConfig: (files: string[]) => {
|
|
4
8
|
config?: Schema;
|
|
5
9
|
diagnostics: Diagnostic[];
|
package/dst/specbook-config.js
CHANGED
|
@@ -43,21 +43,37 @@ const identityOf = (item, list) => {
|
|
|
43
43
|
matched by their identity, so a matching element is merged into its
|
|
44
44
|
counterpart and an unmatched one is appended */
|
|
45
45
|
const mergeConfig = (target, source, list) => {
|
|
46
|
-
|
|
46
|
+
/* an empty or comment-only YAML document carries no content at
|
|
47
|
+
all, so it leaves the target intact instead of discarding it */
|
|
48
|
+
if (source === null || source === undefined)
|
|
49
|
+
return target;
|
|
50
|
+
else if (Array.isArray(target) && Array.isArray(source)) {
|
|
51
|
+
const items = target;
|
|
47
52
|
for (const item of source) {
|
|
48
|
-
const i =
|
|
53
|
+
const i = items.findIndex((existing) => identityOf(existing, list) === identityOf(item, list));
|
|
49
54
|
if (i < 0)
|
|
50
|
-
|
|
55
|
+
items.push(item);
|
|
51
56
|
else
|
|
52
|
-
|
|
57
|
+
items[i] = mergeConfig(items[i], item, list);
|
|
53
58
|
}
|
|
54
|
-
return
|
|
59
|
+
return items;
|
|
55
60
|
}
|
|
56
61
|
else if (isPlainObject(target) && isPlainObject(source))
|
|
57
62
|
return mergeWith(target, source, (t, s, key) => Array.isArray(t) && Array.isArray(s) ? mergeConfig(t, s, key) : undefined);
|
|
58
63
|
else
|
|
59
64
|
return source;
|
|
60
65
|
};
|
|
66
|
+
/* deep copy a converted YAML document, as the conversion resolves all
|
|
67
|
+
aliases of an anchor onto one shared object, which the in-place
|
|
68
|
+
merge otherwise would alter through every alias at once */
|
|
69
|
+
const unshared = (value) => {
|
|
70
|
+
if (Array.isArray(value))
|
|
71
|
+
return value.map((item) => unshared(item));
|
|
72
|
+
else if (isPlainObject(value))
|
|
73
|
+
return Object.fromEntries(Object.entries(value).map(([key, val]) => [key, unshared(val)]));
|
|
74
|
+
else
|
|
75
|
+
return value;
|
|
76
|
+
};
|
|
61
77
|
/* determine file and line/column of a path into the merged
|
|
62
78
|
configuration by translating the path into every document (from the
|
|
63
79
|
last to the first one, as a later document overrides an earlier one),
|
|
@@ -75,7 +91,10 @@ const posOfMergedPath = (docs, merged, path) => {
|
|
|
75
91
|
for (const segment of path) {
|
|
76
92
|
const key = typeof segment === "number" && Array.isArray(own) ?
|
|
77
93
|
own.findIndex((item) => identityOf(item, list) === identityOf(at(node, segment), list)) : segment;
|
|
78
|
-
|
|
94
|
+
const reaches = typeof key === "number" ?
|
|
95
|
+
Array.isArray(own) && key >= 0 :
|
|
96
|
+
isPlainObject(own) && key in own;
|
|
97
|
+
if (!reaches)
|
|
79
98
|
break;
|
|
80
99
|
translated.push(key);
|
|
81
100
|
node = at(node, segment);
|
|
@@ -227,6 +246,25 @@ const checkConstraints = (config, posOfPath) => {
|
|
|
227
246
|
check(config, [], 1);
|
|
228
247
|
return diagnostics;
|
|
229
248
|
};
|
|
249
|
+
/* the statistics of a schema configuration: the number of specified
|
|
250
|
+
object kinds (recursively) and of relationship kinds, i.e. the
|
|
251
|
+
reference-valued properties (whose expressions are valid, as the
|
|
252
|
+
configuration is validated before use) */
|
|
253
|
+
export const configStatistics = (config) => {
|
|
254
|
+
let objects = 0;
|
|
255
|
+
let links = 0;
|
|
256
|
+
const walk = (object) => {
|
|
257
|
+
objects++;
|
|
258
|
+
for (const prop of object.props ?? [])
|
|
259
|
+
if (prop.value !== undefined && admitsReferences(compileValueExpr(prop.value)))
|
|
260
|
+
links++;
|
|
261
|
+
for (const child of object.childs ?? [])
|
|
262
|
+
walk(child);
|
|
263
|
+
};
|
|
264
|
+
for (const object of config)
|
|
265
|
+
walk(object);
|
|
266
|
+
return { objects, links };
|
|
267
|
+
};
|
|
230
268
|
/* load and validate the YAML schema configuration, merged in order out
|
|
231
269
|
of its files (the later ones into the earlier ones), where every file
|
|
232
270
|
has to be readable and syntactically valid on its own, while the
|
|
@@ -267,10 +305,10 @@ export const loadConfig = (files) => {
|
|
|
267
305
|
}
|
|
268
306
|
if (diagnostics.length > 0)
|
|
269
307
|
return { diagnostics };
|
|
270
|
-
/* merge the documents in order, out of
|
|
271
|
-
merge mutates its target while the raw
|
|
272
|
-
for the path translation */
|
|
273
|
-
const merged = docs.map(({
|
|
308
|
+
/* merge the documents in order, out of unshared deep copies of
|
|
309
|
+
the conversions, as the merge mutates its target while the raw
|
|
310
|
+
ones have to stay intact for the path translation */
|
|
311
|
+
const merged = docs.map(({ raw }) => unshared(raw))
|
|
274
312
|
.reduce((target, source) => mergeConfig(target, source));
|
|
275
313
|
/* semantically validate the merged result against the schema of the configuration */
|
|
276
314
|
const posOfPath = (path) => posOfMergedPath(docs, merged, path);
|
package/dst/specbook-diagram.js
CHANGED
|
@@ -5,7 +5,7 @@
|
|
|
5
5
|
*/
|
|
6
6
|
import { Gradia } from "@rse/gradia";
|
|
7
7
|
import { referenceRegex, buildLinkIndex, resolveUnique, resolveSet, anchorPaths, expandReferences, plainText } from "./specbook-link.js";
|
|
8
|
-
import { collectSchemas } from "./specbook-parse-semantic.js";
|
|
8
|
+
import { collectSchemas, collectParenProps } from "./specbook-parse-semantic.js";
|
|
9
9
|
import { literal } from "./specbook-verbose.js";
|
|
10
10
|
/* the single Wiki-style reference match (the non-global sibling of
|
|
11
11
|
the imported, global "referenceRegex") */
|
|
@@ -29,12 +29,19 @@ const configValue = (value) => {
|
|
|
29
29
|
const text = String(value).replace(/\s+/g, " ").trim();
|
|
30
30
|
return /^[^\s"]+$/.test(text) ? text : quoted(text);
|
|
31
31
|
};
|
|
32
|
+
/* the properties of an object, the synthetic one included */
|
|
33
|
+
const propsOf = (parenProps, object) => {
|
|
34
|
+
const synthetic = parenProps.get(object);
|
|
35
|
+
return synthetic !== undefined ? [...object.properties, synthetic] : object.properties;
|
|
36
|
+
};
|
|
37
|
+
/* the value of a single property of an object, by its key */
|
|
38
|
+
const propValue = (parenProps, object, key) => propsOf(parenProps, object).find((property) => property.key === key)?.value;
|
|
32
39
|
/* generate the Gradia spec text of a derived diagram, opened by a
|
|
33
40
|
"#type" directive and the configured "#config" directives so every
|
|
34
41
|
embedded spec is self-contained: one node statement per object (the
|
|
35
42
|
unique anchor path as the node id, the object name as the displayed
|
|
36
43
|
label) and one edge statement per derived edge */
|
|
37
|
-
const renderSpec = (diagram, type, center, centerUrl, nodes, edges, index, anchors) => {
|
|
44
|
+
const renderSpec = (diagram, type, center, centerUrl, nodes, edges, index, anchors, parenProps) => {
|
|
38
45
|
const lines = [`#type ${type}`];
|
|
39
46
|
const config = diagram.config ?? {};
|
|
40
47
|
for (const [key, value] of Object.entries(config))
|
|
@@ -58,7 +65,7 @@ const renderSpec = (diagram, type, center, centerUrl, nodes, edges, index, ancho
|
|
|
58
65
|
the node set can mix objects of different kinds), with every
|
|
59
66
|
"[[...]]" reference stripped to its target object name */
|
|
60
67
|
for (const key of diagram.properties ?? []) {
|
|
61
|
-
const value =
|
|
68
|
+
const value = propValue(parenProps, node, key);
|
|
62
69
|
if (value !== undefined) {
|
|
63
70
|
const text = expandReferences(value, (reference) => resolveUnique(index, reference, node).target?.name ?? reference);
|
|
64
71
|
attrs.push(`${atom(key)}: ${atom(plainText(text))}`);
|
|
@@ -84,7 +91,7 @@ const renderSpec = (diagram, type, center, centerUrl, nodes, edges, index, ancho
|
|
|
84
91
|
property key when "labeled", and for a "deep" diagram also of their
|
|
85
92
|
descendants, with every target lifted to its nearest node and the
|
|
86
93
|
reference count as the arity) */
|
|
87
|
-
const deriveReferenceEdges = (diagram, nodes, nodeSet, index, anchors, parents) => {
|
|
94
|
+
const deriveReferenceEdges = (diagram, nodes, nodeSet, index, anchors, parents, parenProps) => {
|
|
88
95
|
const edges = new Array();
|
|
89
96
|
/* lift an object to its nearest ancestor-or-self within the
|
|
90
97
|
node set, as a "deep" diagram attributes the references of
|
|
@@ -109,7 +116,7 @@ const deriveReferenceEdges = (diagram, nodes, nodeSet, index, anchors, parents)
|
|
|
109
116
|
walk(node);
|
|
110
117
|
const counts = new Map();
|
|
111
118
|
for (const object of objects) {
|
|
112
|
-
const texts = object.
|
|
119
|
+
const texts = propsOf(parenProps, object).map((property) => ({
|
|
113
120
|
text: property.value,
|
|
114
121
|
name: diagram.labeled === true ? property.key.toLowerCase() : undefined
|
|
115
122
|
}));
|
|
@@ -141,15 +148,15 @@ const deriveReferenceEdges = (diagram, nodes, nodeSet, index, anchors, parents)
|
|
|
141
148
|
convention (source: parent object, target: first reference in the
|
|
142
149
|
property values, name: object name, arity: "ARITY" property),
|
|
143
150
|
overridable via "edgeSource"/"edgeTarget"/"edgeArity" */
|
|
144
|
-
const deriveObjectEdges = (diagram, nodeSet, edgeObjects, index, parents, errors) => {
|
|
151
|
+
const deriveObjectEdges = (diagram, nodeSet, edgeObjects, index, parents, parenProps, errors) => {
|
|
145
152
|
const edges = new Array();
|
|
146
153
|
/* resolve the node an edge object references through a named
|
|
147
154
|
property, or through its first single-reference property when
|
|
148
155
|
no name is configured (skipping the source property) */
|
|
149
156
|
const edgeNode = (edgeObject, key) => {
|
|
150
157
|
const value = key !== undefined ?
|
|
151
|
-
|
|
152
|
-
edgeObject.
|
|
158
|
+
propValue(parenProps, edgeObject, key) :
|
|
159
|
+
propsOf(parenProps, edgeObject).filter((property) => property.key !== diagram.edgeSource)
|
|
153
160
|
.map((property) => property.value).find((v) => referenceOnce.test(v));
|
|
154
161
|
const reference = value?.match(referenceOnce)?.[1].trim();
|
|
155
162
|
return reference !== undefined ? resolveUnique(index, reference, edgeObject).target : undefined;
|
|
@@ -170,7 +177,7 @@ const deriveObjectEdges = (diagram, nodeSet, edgeObjects, index, parents, errors
|
|
|
170
177
|
}
|
|
171
178
|
if (!nodeSet.has(source) || !nodeSet.has(target))
|
|
172
179
|
continue;
|
|
173
|
-
const arity =
|
|
180
|
+
const arity = propValue(parenProps, edgeObject, diagram.edgeArity ?? "ARITY");
|
|
174
181
|
edges.push({ source, target, name: plainText(edgeObject.name),
|
|
175
182
|
arity: arity !== undefined ? plainText(arity) : undefined });
|
|
176
183
|
}
|
|
@@ -180,11 +187,11 @@ const deriveObjectEdges = (diagram, nodeSet, edgeObjects, index, parents, errors
|
|
|
180
187
|
objects, the object edges of the edge objects, and the containment
|
|
181
188
|
edges of the object hierarchy (a "grid" diagram is edge-less by
|
|
182
189
|
definition, so no edges are derived at all) */
|
|
183
|
-
const deriveEdges = (diagram, type, nodes, nodeSet, edgeObjects, index, anchors, parents, errors) => {
|
|
190
|
+
const deriveEdges = (diagram, type, nodes, nodeSet, edgeObjects, index, anchors, parents, parenProps, errors) => {
|
|
184
191
|
const edges = new Array();
|
|
185
192
|
if (type !== "grid") {
|
|
186
|
-
edges.push(...deriveReferenceEdges(diagram, nodes, nodeSet, index, anchors, parents));
|
|
187
|
-
edges.push(...deriveObjectEdges(diagram, nodeSet, edgeObjects, index, parents, errors));
|
|
193
|
+
edges.push(...deriveReferenceEdges(diagram, nodes, nodeSet, index, anchors, parents, parenProps));
|
|
194
|
+
edges.push(...deriveObjectEdges(diagram, nodeSet, edgeObjects, index, parents, parenProps, errors));
|
|
188
195
|
/* derive the containment edges from the object hierarchy, as
|
|
189
196
|
the nesting of the objects carries no "[[...]]" reference */
|
|
190
197
|
if (diagram.hierarchy === true)
|
|
@@ -206,9 +213,12 @@ const deriveEdges = (diagram, type, nodes, nodeSet, edgeObjects, index, anchors,
|
|
|
206
213
|
if (diagram.onlyConnected === true)
|
|
207
214
|
errors.push({ reason: "\"grid\" diagram cannot carry an \"onlyConnected\" configuration" });
|
|
208
215
|
}
|
|
209
|
-
|
|
210
|
-
|
|
211
|
-
|
|
216
|
+
return edges;
|
|
217
|
+
};
|
|
218
|
+
/* deduplicate the edges (a containment, object, or center edge can
|
|
219
|
+
coincide with a reference edge, and several edge objects can
|
|
220
|
+
describe the very same edge) */
|
|
221
|
+
const dedupEdges = (edges, anchors) => {
|
|
212
222
|
const seenEdges = new Set();
|
|
213
223
|
return edges.filter((edge) => {
|
|
214
224
|
const key = `${anchors.get(edge.source) ?? edge.source.id}` +
|
|
@@ -253,7 +263,7 @@ const resolvePatterns = (index, errors, value, field) => {
|
|
|
253
263
|
itself, e.g., is no specification object, yet a context
|
|
254
264
|
diagram places it in the middle) and injected into the node
|
|
255
265
|
set (the center is absent whenever the configuration is invalid) */
|
|
256
|
-
const deriveCenter = (object, diagram, nodes, nodeSet, index, anchors, errors) => {
|
|
266
|
+
const deriveCenter = (object, diagram, nodes, nodeSet, index, anchors, parenProps, errors) => {
|
|
257
267
|
if (typeof diagram.center === "string" && diagram.center !== "self") {
|
|
258
268
|
const reference = diagram.center.match(referenceOnce)?.[1].trim();
|
|
259
269
|
const resolved = reference !== undefined ? resolveUnique(index, reference, object) : undefined;
|
|
@@ -283,7 +293,7 @@ const deriveCenter = (object, diagram, nodes, nodeSet, index, anchors, errors) =
|
|
|
283
293
|
}
|
|
284
294
|
centerUrl = `#${anchors.get(source) ?? source.id}`;
|
|
285
295
|
if (centerCfg.property !== undefined) {
|
|
286
|
-
const value =
|
|
296
|
+
const value = propValue(parenProps, source, centerCfg.property);
|
|
287
297
|
if (value === undefined) {
|
|
288
298
|
errors.push({ reason: `diagram "center" source lacks the property "${centerCfg.property}"` });
|
|
289
299
|
return undefined;
|
|
@@ -312,12 +322,12 @@ const deriveCenter = (object, diagram, nodes, nodeSet, index, anchors, errors) =
|
|
|
312
322
|
maps onto a node-to-center edge, the "outbound" value onto a
|
|
313
323
|
center-to-node edge, and the "both" value onto both (which
|
|
314
324
|
Gradia renders as an input node plus an output "ghost" node) */
|
|
315
|
-
const deriveCenterEdges = (cfg, nodes, center, index, errors) => {
|
|
325
|
+
const deriveCenterEdges = (cfg, nodes, center, index, parenProps, errors) => {
|
|
316
326
|
const edges = new Array();
|
|
317
327
|
for (const node of nodes) {
|
|
318
328
|
if (node === center)
|
|
319
329
|
continue;
|
|
320
|
-
const value =
|
|
330
|
+
const value = propValue(parenProps, node, cfg.property);
|
|
321
331
|
const inbound = cfg.inbound !== undefined && value === cfg.inbound;
|
|
322
332
|
const outbound = cfg.outbound !== undefined && value === cfg.outbound;
|
|
323
333
|
const both = cfg.both !== undefined && value === cfg.both;
|
|
@@ -328,7 +338,7 @@ const deriveCenterEdges = (cfg, nodes, center, index, errors) => {
|
|
|
328
338
|
continue;
|
|
329
339
|
}
|
|
330
340
|
const label = cfg.labeled !== undefined ?
|
|
331
|
-
|
|
341
|
+
propValue(parenProps, node, cfg.labeled) : undefined;
|
|
332
342
|
const name = label !== undefined ? plainText(expandReferences(label, (reference) => resolveUnique(index, reference, node).target?.name ?? reference)) : undefined;
|
|
333
343
|
if (inbound || both)
|
|
334
344
|
edges.push({ source: node, target: center, name });
|
|
@@ -340,7 +350,7 @@ const deriveCenterEdges = (cfg, nodes, center, index, errors) => {
|
|
|
340
350
|
/* derive the Gradia spec of a single object from its "diagram:"
|
|
341
351
|
schema configuration (the returned spec is absent whenever the
|
|
342
352
|
configured diagram situation is invalid) */
|
|
343
|
-
const deriveDiagram = (object, diagram, index, anchors, parents) => {
|
|
353
|
+
const deriveDiagram = (object, diagram, index, anchors, parents, parenProps) => {
|
|
344
354
|
const type = diagram.type ?? "graph";
|
|
345
355
|
const errors = new Array();
|
|
346
356
|
/* determine the node objects (default: the current object plus
|
|
@@ -361,8 +371,8 @@ const deriveDiagram = (object, diagram, index, anchors, parents) => {
|
|
|
361
371
|
const edgeSet = new Set(edgeObjects);
|
|
362
372
|
nodes = nodes.filter((node) => !edgeSet.has(node));
|
|
363
373
|
const nodeSet = new Set(nodes);
|
|
364
|
-
/* derive the
|
|
365
|
-
let edges = deriveEdges(diagram, type, nodes, nodeSet, edgeObjects, index, anchors, parents, errors);
|
|
374
|
+
/* derive the edges of the diagram */
|
|
375
|
+
let edges = deriveEdges(diagram, type, nodes, nodeSet, edgeObjects, index, anchors, parents, parenProps, errors);
|
|
366
376
|
/* determine the center of a "hub" diagram (the only shape
|
|
367
377
|
carrying a center at all) */
|
|
368
378
|
if (type !== "hub") {
|
|
@@ -372,14 +382,17 @@ const deriveDiagram = (object, diagram, index, anchors, parents) => {
|
|
|
372
382
|
errors.push({ reason: `"${type}" diagram cannot carry a "centerEdges" configuration` });
|
|
373
383
|
}
|
|
374
384
|
const derived = type === "hub" ?
|
|
375
|
-
deriveCenter(object, diagram, nodes, nodeSet, index, anchors, errors) : { center: object };
|
|
385
|
+
deriveCenter(object, diagram, nodes, nodeSet, index, anchors, parenProps, errors) : { center: object };
|
|
376
386
|
if (derived === undefined)
|
|
377
387
|
return { errors };
|
|
378
388
|
const { center, centerUrl } = derived;
|
|
379
389
|
/* synthesize the center edges of a "hub" diagram, as the nodes
|
|
380
|
-
carry no "[[...]]" reference to a synthetic center
|
|
390
|
+
carry no "[[...]]" reference to a synthetic center, and only
|
|
391
|
+
then deduplicate all edges, as a center edge of a real center
|
|
392
|
+
can coincide with a reference edge of a node onto it */
|
|
381
393
|
if (type === "hub" && diagram.centerEdges !== undefined)
|
|
382
|
-
edges.push(...deriveCenterEdges(diagram.centerEdges, nodes, center, index, errors));
|
|
394
|
+
edges.push(...deriveCenterEdges(diagram.centerEdges, nodes, center, index, parenProps, errors));
|
|
395
|
+
edges = dedupEdges(edges, anchors);
|
|
383
396
|
/* a "hub" diagram is the hub-projection onto its center object:
|
|
384
397
|
only the edges incident to the center and only the nodes
|
|
385
398
|
connected through them remain, with self-loops dropped, as
|
|
@@ -415,16 +428,17 @@ const deriveDiagram = (object, diagram, index, anchors, parents) => {
|
|
|
415
428
|
}
|
|
416
429
|
nodes = nodes.filter((node) => connected.has(node));
|
|
417
430
|
}
|
|
418
|
-
if (nodes.length === 0)
|
|
419
|
-
errors.push({ reason: "diagram yields no nodes" });
|
|
420
431
|
if (errors.length > 0)
|
|
421
432
|
return { errors };
|
|
422
|
-
/*
|
|
423
|
-
|
|
424
|
-
|
|
425
|
-
|
|
433
|
+
/* an empty node set silently omits the diagram, as a wildcard
|
|
434
|
+
"nodes" pattern legitimately matches nothing when an optional
|
|
435
|
+
kind is absent, and the "collapse" handling (enabled by default)
|
|
436
|
+
does the same for the degenerate diagram of a single node, as
|
|
437
|
+
such a diagram carries no information beyond the object itself */
|
|
438
|
+
if (nodes.length === 0
|
|
439
|
+
|| (diagram.collapse !== false && nodes.length === 1 && edges.length === 0))
|
|
426
440
|
return { errors };
|
|
427
|
-
return { spec: renderSpec(diagram, type, center, centerUrl, nodes, edges, index, anchors),
|
|
441
|
+
return { spec: renderSpec(diagram, type, center, centerUrl, nodes, edges, index, anchors, parenProps),
|
|
428
442
|
config: diagram.config, columns, errors };
|
|
429
443
|
};
|
|
430
444
|
/* the memoized diagram derivations, keyed by specification, as the
|
|
@@ -442,10 +456,15 @@ export const specDiagrams = (specification, config) => {
|
|
|
442
456
|
const parents = new Map();
|
|
443
457
|
for (const node of index)
|
|
444
458
|
parents.set(node.object, node.parent?.object);
|
|
459
|
+
const schemas = collectSchemas(specification, config);
|
|
460
|
+
/* re-derive the synthetic properties of the consumed parenthesized
|
|
461
|
+
name tokens, as the derivation sees the AST alone, while the
|
|
462
|
+
parsing context holding them is long gone by export time */
|
|
463
|
+
const parenProps = collectParenProps(schemas);
|
|
445
464
|
const results = new Map();
|
|
446
|
-
for (const [object, schema] of
|
|
465
|
+
for (const [object, schema] of schemas)
|
|
447
466
|
if (schema.diagram !== undefined)
|
|
448
|
-
results.set(object, deriveDiagram(object, schema.diagram, index, anchors, parents));
|
|
467
|
+
results.set(object, deriveDiagram(object, schema.diagram, index, anchors, parents, parenProps));
|
|
449
468
|
derivations.set(specification, { config, results });
|
|
450
469
|
return results;
|
|
451
470
|
};
|
|
@@ -500,11 +519,11 @@ export const validateDiagrams = (ctx, specification, config) => {
|
|
|
500
519
|
object itself and report it just once, as a wildcard
|
|
501
520
|
"edges" pattern lets every diagram walk the very same
|
|
502
521
|
edge objects again */
|
|
503
|
-
const meta = ctx.
|
|
522
|
+
const meta = ctx.metaOf(error.object ?? object);
|
|
504
523
|
const message = error.object !== undefined ?
|
|
505
524
|
`invalid diagram edge on ${error.object.kind} "${error.object.name}": ${error.reason}` :
|
|
506
525
|
`invalid diagram on ${object.kind} "${object.name}": ${error.reason}`;
|
|
507
|
-
const key = `${meta.file}
|
|
526
|
+
const key = `${meta.file}\u0000${meta.line}\u0000${message}`;
|
|
508
527
|
if (seen.has(key))
|
|
509
528
|
continue;
|
|
510
529
|
seen.add(key);
|