@rse/specbook 1.3.0 → 1.3.1
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/dst/specbook-api.d.ts +13 -16
- package/dst/specbook-api.js +34 -18
- package/dst/specbook-cli.js +8 -6
- package/dst/specbook-cmd-describe.js +11 -7
- package/dst/specbook-cmd-export.js +6 -0
- package/dst/specbook-cmd-preview.d.ts +1 -0
- package/dst/specbook-cmd-preview.js +5 -4
- package/dst/specbook-config.js +11 -6
- package/dst/specbook-diagnostic.js +1 -1
- package/dst/specbook-diagram.js +74 -58
- package/dst/specbook-export-common.d.ts +2 -2
- package/dst/specbook-export-common.js +29 -18
- package/dst/specbook-export-html-search.js +10 -4
- package/dst/specbook-export-html.css +21 -21
- package/dst/specbook-export-html.js +102 -71
- package/dst/specbook-export-image.js +9 -1
- package/dst/specbook-export-md.js +3 -3
- package/dst/specbook-export-pdf.js +12 -6
- package/dst/specbook-ignore.js +35 -18
- package/dst/specbook-link.js +2 -2
- package/dst/specbook-mcp.js +5 -4
- package/dst/specbook-parse-common.js +6 -3
- package/dst/specbook-parse-semantic.js +8 -9
- package/dst/specbook-parse-syntax.js +13 -4
- package/dst/specbook-project.js +16 -3
- package/dst/specbook-theme.js +6 -2
- package/dst/specbook-verbose.js +3 -2
- package/package.json +4 -4
package/dst/specbook-api.d.ts
CHANGED
|
@@ -28,6 +28,17 @@ export interface ProjectOptions {
|
|
|
28
28
|
basedir?: string;
|
|
29
29
|
cwd?: string;
|
|
30
30
|
}
|
|
31
|
+
export interface ExportRequest extends ProjectOptions {
|
|
32
|
+
formats?: ExportFormat[];
|
|
33
|
+
realtime?: boolean;
|
|
34
|
+
omit?: string[];
|
|
35
|
+
gitignore?: boolean;
|
|
36
|
+
rebase?: (string | undefined)[];
|
|
37
|
+
}
|
|
38
|
+
export interface WatchRequest extends ExportRequest {
|
|
39
|
+
outputs?: string[];
|
|
40
|
+
onExport: (buffers: Buffer[]) => void | Promise<void>;
|
|
41
|
+
}
|
|
31
42
|
export declare class SpecBook {
|
|
32
43
|
private verbose;
|
|
33
44
|
constructor(options?: SpecBookOptions);
|
|
@@ -40,24 +51,10 @@ export declare class SpecBook {
|
|
|
40
51
|
gitignore?: boolean;
|
|
41
52
|
}): Promise<LintResult>;
|
|
42
53
|
private renderFormats;
|
|
43
|
-
export(options: ProjectOptions & {
|
|
44
|
-
formats?: ExportFormat[];
|
|
45
|
-
realtime?: boolean;
|
|
46
|
-
omit?: string[];
|
|
47
|
-
gitignore?: boolean;
|
|
48
|
-
rebase?: (string | undefined)[];
|
|
49
|
-
}): Promise<Buffer[]>;
|
|
50
54
|
private rebaseOf;
|
|
55
|
+
export(options: ExportRequest): Promise<Buffer[]>;
|
|
51
56
|
private observe;
|
|
52
|
-
watch(options:
|
|
53
|
-
formats?: ExportFormat[];
|
|
54
|
-
realtime?: boolean;
|
|
55
|
-
omit?: string[];
|
|
56
|
-
gitignore?: boolean;
|
|
57
|
-
outputs?: string[];
|
|
58
|
-
rebase?: (string | undefined)[];
|
|
59
|
-
onExport: (buffers: Buffer[]) => void | Promise<void>;
|
|
60
|
-
}): Promise<void>;
|
|
57
|
+
watch(options: WatchRequest): Promise<void>;
|
|
61
58
|
preview(options: ProjectOptions & {
|
|
62
59
|
addr?: string;
|
|
63
60
|
port?: number;
|
package/dst/specbook-api.js
CHANGED
|
@@ -144,6 +144,12 @@ export class SpecBook {
|
|
|
144
144
|
}
|
|
145
145
|
return buffers;
|
|
146
146
|
}
|
|
147
|
+
/* anchor the given relative re-basing directories
|
|
148
|
+
at a given working directory */
|
|
149
|
+
rebaseOf(options) {
|
|
150
|
+
const cwd = options.cwd;
|
|
151
|
+
return cwd === undefined ? options.rebase : options.rebase?.map((dir) => dir !== undefined ? path.resolve(cwd, dir) : dir);
|
|
152
|
+
}
|
|
147
153
|
/* export the specification Markdown files below the base directory
|
|
148
154
|
as JSON, JSON5, YAML, TOON, HTML, PDF, or normalized Markdown,
|
|
149
155
|
parsing the input just once and returning one buffer per
|
|
@@ -165,12 +171,6 @@ export class SpecBook {
|
|
|
165
171
|
basedir: project.basedir ?? ".", gitignore: options.gitignore === true, verbose });
|
|
166
172
|
return this.renderFormats(result, requested, verbose, { realtime: options.realtime === true, omit }, this.rebaseOf(options));
|
|
167
173
|
}
|
|
168
|
-
/* anchor the given relative re-basing directories
|
|
169
|
-
at a given working directory */
|
|
170
|
-
rebaseOf(options) {
|
|
171
|
-
const cwd = options.cwd;
|
|
172
|
-
return cwd === undefined ? options.rebase : options.rebase?.map((dir) => dir !== undefined ? path.resolve(cwd, dir) : dir);
|
|
173
|
-
}
|
|
174
174
|
/* keep an export in sync with its sources (the shared core of
|
|
175
175
|
"watch" and "preview"): every change of a configuration file, of an
|
|
176
176
|
artifact file, or of one of its embedded assets re-exports the
|
|
@@ -184,6 +184,11 @@ export class SpecBook {
|
|
|
184
184
|
const omit = parseOmit(options.omit);
|
|
185
185
|
const project = this.project(options, verbose);
|
|
186
186
|
const config = await this.configFiles(project.config);
|
|
187
|
+
/* a missing browser is an environment problem, so let the PDF
|
|
188
|
+
export fail before the specification is even parsed, but
|
|
189
|
+
after the usage problems (exactly like "export") */
|
|
190
|
+
if (options.formats.includes("pdf"))
|
|
191
|
+
await requireBrowser(verbose);
|
|
187
192
|
return watchSpecification(async () => {
|
|
188
193
|
/* the lint result carries the files to observe even for an
|
|
189
194
|
invalid specification, and the configuration files are
|
|
@@ -214,12 +219,8 @@ export class SpecBook {
|
|
|
214
219
|
files "onExport" writes, so an output which is itself an observed
|
|
215
220
|
source can be refused, and "rebase" and "omit" are the ones of "export" */
|
|
216
221
|
async watch(options) {
|
|
217
|
-
|
|
218
|
-
|
|
219
|
-
if (requested.includes("pdf"))
|
|
220
|
-
await requireBrowser(verbose);
|
|
221
|
-
return this.observe({ ...options, formats: requested,
|
|
222
|
-
realtime: options.realtime === true }, verbose);
|
|
222
|
+
return this.observe({ ...options, formats: options.formats ?? ["json"],
|
|
223
|
+
realtime: options.realtime === true }, this.verboseOf("export"));
|
|
223
224
|
}
|
|
224
225
|
/* serve the HTML export of the specification as a live preview on
|
|
225
226
|
"http://<addr>:<port>/": the export is kept in sync with its
|
|
@@ -229,11 +230,24 @@ export class SpecBook {
|
|
|
229
230
|
where "omit" is the one of "export" */
|
|
230
231
|
async preview(options) {
|
|
231
232
|
const verbose = this.verboseOf("preview");
|
|
233
|
+
/* an invalid omit aspect is a usage problem, so let the preview
|
|
234
|
+
fail before the server is even bound to its port */
|
|
235
|
+
parseOmit(options.omit);
|
|
232
236
|
const server = await servePreview({ addr: options.addr ?? previewAddr,
|
|
233
237
|
port: options.port ?? previewPort, verbose });
|
|
234
|
-
|
|
235
|
-
|
|
236
|
-
|
|
238
|
+
/* a preview which fails to start observing (an invalid project
|
|
239
|
+
configuration, an unmatched configuration pattern) releases
|
|
240
|
+
the already bound server again */
|
|
241
|
+
try {
|
|
242
|
+
return await this.observe({ config: options.config, basedir: options.basedir, cwd: options.cwd,
|
|
243
|
+
formats: ["html"], realtime: true, omit: options.omit, gitignore: options.gitignore,
|
|
244
|
+
onExport: ([html]) => server.update(html) }, verbose);
|
|
245
|
+
}
|
|
246
|
+
catch (err) {
|
|
247
|
+
/* a failing release must not mask the primary error */
|
|
248
|
+
await server.close().catch(() => { });
|
|
249
|
+
throw err;
|
|
250
|
+
}
|
|
237
251
|
}
|
|
238
252
|
/* describe the generic SpecBook models and formats as Markdown (or
|
|
239
253
|
as the raw original file content), either entirely or reduced to
|
|
@@ -247,10 +261,12 @@ export class SpecBook {
|
|
|
247
261
|
const project = this.project(options, verbose);
|
|
248
262
|
/* the schema-bearing parts require a schema configuration, so
|
|
249
263
|
they fall back onto the bundled standard one, which is always
|
|
250
|
-
embedded
|
|
264
|
+
embedded (also when named explicitly through "std"), as its
|
|
265
|
+
bundled file is no meaningful reference */
|
|
251
266
|
const given = project.config !== undefined;
|
|
252
|
-
const
|
|
253
|
-
|
|
267
|
+
const config = given || part === "all" || part === "schema" ?
|
|
268
|
+
await this.configFiles(project.config) : undefined;
|
|
269
|
+
const standard = config !== undefined && config.every((file) => file === standardConfig);
|
|
254
270
|
const embed = options.embed === true || standard;
|
|
255
271
|
const basedir = project.basedir ?? (part === "spec" ? "." : undefined);
|
|
256
272
|
const schema = config !== undefined ? this.requireConfig(config, verbose) : undefined;
|
package/dst/specbook-cli.js
CHANGED
|
@@ -110,7 +110,7 @@ const omitOf = (opts) => {
|
|
|
110
110
|
created, so they are inherited by all of them */
|
|
111
111
|
let commanderOut = "";
|
|
112
112
|
let commanderErr = "";
|
|
113
|
-
/*
|
|
113
|
+
/* set up the command line program */
|
|
114
114
|
const program = new Command();
|
|
115
115
|
program.name("specbook")
|
|
116
116
|
.description("Markdown-based Specification Format")
|
|
@@ -144,7 +144,8 @@ withCommonOptions(program.command("init"))
|
|
|
144
144
|
withGitignoreOption(withCommonOptions(program.command("lint")))
|
|
145
145
|
.description("lint the specification Markdown files below the base directory")
|
|
146
146
|
.action(async (opts) => {
|
|
147
|
-
const
|
|
147
|
+
const verbose = verboseOf(opts);
|
|
148
|
+
const specbook = new SpecBook({ verbose });
|
|
148
149
|
const result = await specbook.lint({ config: configOf(opts), basedir: opts.basedir,
|
|
149
150
|
gitignore: opts.gitignore });
|
|
150
151
|
const detailed = parseVerbosity(opts.verbose) > 0;
|
|
@@ -155,7 +156,7 @@ withGitignoreOption(withCommonOptions(program.command("lint")))
|
|
|
155
156
|
if (result.diagnostics.some((diagnostic) => diagnostic.severity === "error"))
|
|
156
157
|
process.exitCode = 1;
|
|
157
158
|
else
|
|
158
|
-
|
|
159
|
+
verbose("lint", "specification valid", "notice");
|
|
159
160
|
});
|
|
160
161
|
/* the export command parses the input once and writes every output */
|
|
161
162
|
withOmitOption(withGitignoreOption(withCommonOptions(program.command("export"))))
|
|
@@ -205,7 +206,7 @@ withOmitOption(withGitignoreOption(withCommonOptions(program.command("preview"))
|
|
|
205
206
|
.option("-a, --addr <ip-addr>", "IP address to listen on", envDefault("addr", previewAddr))
|
|
206
207
|
.option("-p, --port <tcp-port>", "TCP port to listen on", envDefault("port", String(previewPort)))
|
|
207
208
|
.action(async (opts) => {
|
|
208
|
-
const port = Number(opts.port);
|
|
209
|
+
const port = (/^\d+$/).test(opts.port) ? Number(opts.port) : NaN;
|
|
209
210
|
if (!Number.isInteger(port) || port < 1 || port > 65535)
|
|
210
211
|
throw new Error(`invalid TCP port "${opts.port}"`);
|
|
211
212
|
const specbook = new SpecBook({ verbose: verboseOf(opts) });
|
|
@@ -225,11 +226,12 @@ withBasedirOption(withConfigOption(withVerboseOption(program.command("describe")
|
|
|
225
226
|
.option("-p, --part <part>", `document part (${describeParts.join(", ")})`, envDefault("part", "all"))
|
|
226
227
|
.option("-o, --output <markdown-file>", "output file (\"-\" for stdout)", envDefault("output", "-"))
|
|
227
228
|
.action(async (opts) => {
|
|
228
|
-
const
|
|
229
|
+
const verbose = verboseOf(opts);
|
|
230
|
+
const specbook = new SpecBook({ verbose });
|
|
229
231
|
const text = await specbook.describe({ config: configOf(opts), basedir: opts.basedir,
|
|
230
232
|
embed: opts.embed, compress: parseCompressLevel(opts.compress),
|
|
231
233
|
format: parseDescribeFormat(opts.format), part: parseDescribePart(opts.part) });
|
|
232
|
-
await writeOutput(opts.output, text, "describe",
|
|
234
|
+
await writeOutput(opts.output, text, "describe", verbose);
|
|
233
235
|
});
|
|
234
236
|
/* run the command line program */
|
|
235
237
|
try {
|
|
@@ -11,10 +11,11 @@ export const describeFormats = ["md", "raw"];
|
|
|
11
11
|
export const describeParts = ["all", "meta", "schema", "spec"];
|
|
12
12
|
/* parse and validate an output format or document part specification */
|
|
13
13
|
const parseChoice = (choices, kind, value) => {
|
|
14
|
-
|
|
14
|
+
const choice = choices.find((known) => known === value);
|
|
15
|
+
if (choice === undefined)
|
|
15
16
|
throw new Error(`unknown describe ${kind} "${value}" ` +
|
|
16
17
|
`(supported: ${choices.join(", ")})`);
|
|
17
|
-
return
|
|
18
|
+
return choice;
|
|
18
19
|
};
|
|
19
20
|
export const parseDescribeFormat = (value) => parseChoice(describeFormats, "format", value);
|
|
20
21
|
export const parseDescribePart = (value) => parseChoice(describeParts, "part", value);
|
|
@@ -25,14 +26,17 @@ export const parseDescribePart = (value) => parseChoice(describeParts, "part", v
|
|
|
25
26
|
objects and properties (3) */
|
|
26
27
|
export const compressLevels = [0, 1, 2, 3];
|
|
27
28
|
/* parse and validate a compression level specification,
|
|
28
|
-
where a bare flag selects the plain re-emitting */
|
|
29
|
+
where a bare flag (or a boolean word) selects the plain re-emitting */
|
|
29
30
|
export const parseCompressLevel = (value) => {
|
|
30
|
-
const
|
|
31
|
-
|
|
32
|
-
|
|
31
|
+
const text = String(value);
|
|
32
|
+
const level = (/^(?:true|yes|on)$/i).test(text) ? 1 :
|
|
33
|
+
(/^(?:false|no|off)$/i).test(text) ? 0 :
|
|
34
|
+
(/^\d+$/).test(text) ? Number(text) : NaN;
|
|
35
|
+
const compress = compressLevels.find((n) => n === level);
|
|
36
|
+
if (compress === undefined)
|
|
33
37
|
throw new Error(`unknown compress level "${value}" ` +
|
|
34
38
|
`(supported: ${compressLevels.join(", ")})`);
|
|
35
|
-
return
|
|
39
|
+
return compress;
|
|
36
40
|
};
|
|
37
41
|
/* provide the build-time bundled description of the generic
|
|
38
42
|
SpecBook models and formats */
|
|
@@ -89,6 +89,7 @@ const snapshot = (files) => {
|
|
|
89
89
|
process alive afterwards */
|
|
90
90
|
export const watchSpecification = async (run, verbose) => {
|
|
91
91
|
/* perform the regular export before entering the observe loop */
|
|
92
|
+
const started = Date.now();
|
|
92
93
|
let observed = await run();
|
|
93
94
|
let times = snapshot(observed);
|
|
94
95
|
const watcher = watch(observed, { ignoreInitial: true });
|
|
@@ -128,6 +129,11 @@ export const watchSpecification = async (run, verbose) => {
|
|
|
128
129
|
}, watchDelay);
|
|
129
130
|
};
|
|
130
131
|
watcher.on("all", schedule);
|
|
132
|
+
/* a change during the initial export escaped both the not yet
|
|
133
|
+
established watcher and the snapshot taken after it, so its
|
|
134
|
+
modification time alone reveals it */
|
|
135
|
+
if (Array.from(times.values()).some((time) => time > started))
|
|
136
|
+
schedule();
|
|
131
137
|
/* compare the modification times regularly, so a change the
|
|
132
138
|
watcher never reported still reaches the export, and re-establish
|
|
133
139
|
the watchers on such a miss, as a watcher which lost a file never
|
|
@@ -61,9 +61,10 @@ export const servePreview = async (options) => {
|
|
|
61
61
|
options.verbose(`serving ${literal(url)}`, "none");
|
|
62
62
|
html = buffer;
|
|
63
63
|
options.verbose(`notifying ${literal(clients.size)} preview client(s)`);
|
|
64
|
-
for (const
|
|
65
|
-
if (
|
|
66
|
-
|
|
67
|
-
}
|
|
64
|
+
for (const socket of clients)
|
|
65
|
+
if (socket.readyState === socket.OPEN)
|
|
66
|
+
socket.send("RELOAD");
|
|
67
|
+
},
|
|
68
|
+
close: () => fastify.close()
|
|
68
69
|
};
|
|
69
70
|
};
|
package/dst/specbook-config.js
CHANGED
|
@@ -50,7 +50,8 @@ const mergeConfig = (target, source, list) => {
|
|
|
50
50
|
else if (Array.isArray(target) && Array.isArray(source)) {
|
|
51
51
|
const items = target;
|
|
52
52
|
for (const item of source) {
|
|
53
|
-
const
|
|
53
|
+
const ident = identityOf(item, list);
|
|
54
|
+
const i = items.findIndex((existing) => identityOf(existing, list) === ident);
|
|
54
55
|
if (i < 0)
|
|
55
56
|
items.push(item);
|
|
56
57
|
else
|
|
@@ -89,8 +90,11 @@ const posOfMergedPath = (docs, merged, path) => {
|
|
|
89
90
|
let own = raw;
|
|
90
91
|
let list;
|
|
91
92
|
for (const segment of path) {
|
|
92
|
-
|
|
93
|
-
|
|
93
|
+
let key = segment;
|
|
94
|
+
if (typeof segment === "number" && Array.isArray(own)) {
|
|
95
|
+
const ident = identityOf(at(node, segment), list);
|
|
96
|
+
key = own.findIndex((item) => identityOf(item, list) === ident);
|
|
97
|
+
}
|
|
94
98
|
const reaches = typeof key === "number" ?
|
|
95
99
|
Array.isArray(own) && key >= 0 :
|
|
96
100
|
isPlainObject(own) && key in own;
|
|
@@ -189,7 +193,8 @@ const checkAutomaton = (object, at, diagnose) => {
|
|
|
189
193
|
/* check the constraints of a structurally valid configuration which
|
|
190
194
|
are beyond its schema: sibling objects have to stay distinctly
|
|
191
195
|
resolvable, "file" fields are allowed on the first (artifact) level
|
|
192
|
-
only
|
|
196
|
+
only and have to be relative paths below the base directory,
|
|
197
|
+
property value and marker expressions have to be syntactically
|
|
193
198
|
valid, "referenced" and "coverage" entries have to be reference
|
|
194
199
|
expressions, an "automaton" has to name existing child kinds and
|
|
195
200
|
properties, and the names of non-artifact objects have to be valid
|
|
@@ -216,8 +221,8 @@ const checkConstraints = (config, posOfPath) => {
|
|
|
216
221
|
diagnose([...at, "file"], `"file" field is only allowed on the first (artifact) level (found on level ${depth})`);
|
|
217
222
|
/* an artifact file is placed below the base directory, so
|
|
218
223
|
it must neither be absolute nor escape that directory */
|
|
219
|
-
if (object.file !== undefined
|
|
220
|
-
&& ((/^[/\\]/).test(object.file) || (/(?:^|[/\\])\.\.(?:[/\\]|$)/).test(object.file)))
|
|
224
|
+
if (depth === 1 && object.file !== undefined
|
|
225
|
+
&& ((/^(?:[/\\]|[A-Za-z]:)/).test(object.file) || (/(?:^|[/\\])\.\.(?:[/\\]|$)/).test(object.file)))
|
|
221
226
|
diagnose([...at, "file"], `"file" field "${object.file}" is not a relative path below the base directory`);
|
|
222
227
|
checkProperties(object.props ?? [], at, diagnose);
|
|
223
228
|
checkAutomaton(object, at, diagnose);
|
|
@@ -19,7 +19,7 @@ export const renderDiagnosticVerbose = (diagnostic, colors = false) => {
|
|
|
19
19
|
code = fs.readFileSync(diagnostic.file, "utf8");
|
|
20
20
|
}
|
|
21
21
|
catch {
|
|
22
|
-
|
|
22
|
+
code = "";
|
|
23
23
|
}
|
|
24
24
|
if (code === "")
|
|
25
25
|
return `${renderDiagnostic(diagnostic)}\n`;
|
package/dst/specbook-diagram.js
CHANGED
|
@@ -75,7 +75,8 @@ const propValue = (parenProps, object, key) => propsOf(parenProps, object).find(
|
|
|
75
75
|
unique anchor path as the node id, the object name as the displayed
|
|
76
76
|
label, the container node as the parent) and one edge statement per
|
|
77
77
|
derived edge */
|
|
78
|
-
const renderSpec = (diagram, type, center, centerUrl, nodes, edges, nesting
|
|
78
|
+
const renderSpec = (ctx, diagram, type, center, centerUrl, nodes, edges, nesting) => {
|
|
79
|
+
const { index, anchors, positions, parenProps } = ctx;
|
|
79
80
|
const lines = [`#type ${type}`];
|
|
80
81
|
const config = { ...diagramPresets, ...diagram.config };
|
|
81
82
|
for (const [key, value] of Object.entries(config))
|
|
@@ -145,7 +146,8 @@ const renderSpec = (diagram, type, center, centerUrl, nodes, edges, nesting, ind
|
|
|
145
146
|
property key when "labeled", and for a "deep" diagram also of their
|
|
146
147
|
descendants, with the reference count as the arity), with every
|
|
147
148
|
target lifted to its nearest node */
|
|
148
|
-
const deriveReferenceEdges = (diagram, nodes, nodeSet
|
|
149
|
+
const deriveReferenceEdges = (ctx, diagram, nodes, nodeSet) => {
|
|
150
|
+
const { index, anchors, parents, parenProps } = ctx;
|
|
149
151
|
const edges = new Array();
|
|
150
152
|
/* lift an object to its nearest ancestor-or-self within the
|
|
151
153
|
node set, as a reference onto a descendant of a node object
|
|
@@ -180,9 +182,9 @@ const deriveReferenceEdges = (diagram, nodes, nodeSet, index, anchors, parents,
|
|
|
180
182
|
texts.push({ text: object.description.rationale });
|
|
181
183
|
}
|
|
182
184
|
for (const { text, name } of texts)
|
|
183
|
-
for (const m of text.matchAll(referenceRegex)) {
|
|
185
|
+
for (const m of plainText(text).matchAll(referenceRegex)) {
|
|
184
186
|
const target = lift(resolveUnique(index, m[1].trim(), object).target);
|
|
185
|
-
if (target !== undefined && target !== node
|
|
187
|
+
if (target !== undefined && target !== node) {
|
|
186
188
|
const key = `${anchors.get(target) ?? target.id}\u0000${name ?? ""}`;
|
|
187
189
|
const entry = counts.get(key) ?? { target, name, count: 0 };
|
|
188
190
|
entry.count++;
|
|
@@ -200,7 +202,8 @@ const deriveReferenceEdges = (diagram, nodes, nodeSet, index, anchors, parents,
|
|
|
200
202
|
convention (source: parent object, target: first reference in the
|
|
201
203
|
property values, name: object name, arity: "ARITY" property),
|
|
202
204
|
overridable via "edgeSource"/"edgeTarget"/"edgeArity" */
|
|
203
|
-
const deriveObjectEdges = (diagram, nodeSet, edgeObjects,
|
|
205
|
+
const deriveObjectEdges = (ctx, diagram, nodeSet, edgeObjects, errors) => {
|
|
206
|
+
const { index, parents, parenProps } = ctx;
|
|
204
207
|
const edges = new Array();
|
|
205
208
|
/* resolve the node an edge object references through a named
|
|
206
209
|
property, or through its first single-reference property when
|
|
@@ -210,7 +213,7 @@ const deriveObjectEdges = (diagram, nodeSet, edgeObjects, index, parents, parenP
|
|
|
210
213
|
propValue(parenProps, edgeObject, key) :
|
|
211
214
|
propsOf(parenProps, edgeObject).filter((property) => property.key !== diagram.edgeSource)
|
|
212
215
|
.map((property) => property.value).find((v) => referenceOnce.test(v));
|
|
213
|
-
const reference = value
|
|
216
|
+
const reference = value !== undefined ? plainText(value).match(referenceOnce)?.[1].trim() : undefined;
|
|
214
217
|
return reference !== undefined ? resolveUnique(index, reference, edgeObject).target : undefined;
|
|
215
218
|
};
|
|
216
219
|
for (const edgeObject of edgeObjects) {
|
|
@@ -239,16 +242,16 @@ const deriveObjectEdges = (diagram, nodeSet, edgeObjects, index, parents, parenP
|
|
|
239
242
|
objects, the object edges of the edge objects, and the containment
|
|
240
243
|
edges of the object hierarchy (a "grid" diagram is edge-less by
|
|
241
244
|
definition, so no edges are derived at all) */
|
|
242
|
-
const deriveEdges = (diagram, type, nodes, nodeSet, edgeObjects,
|
|
245
|
+
const deriveEdges = (ctx, diagram, type, nodes, nodeSet, edgeObjects, errors) => {
|
|
243
246
|
const edges = new Array();
|
|
244
247
|
if (type !== "grid") {
|
|
245
|
-
edges.push(...deriveReferenceEdges(diagram, nodes, nodeSet
|
|
246
|
-
edges.push(...deriveObjectEdges(diagram, nodeSet, edgeObjects,
|
|
248
|
+
edges.push(...deriveReferenceEdges(ctx, diagram, nodes, nodeSet));
|
|
249
|
+
edges.push(...deriveObjectEdges(ctx, diagram, nodeSet, edgeObjects, errors));
|
|
247
250
|
/* derive the containment edges from the object hierarchy, as
|
|
248
251
|
the nesting of the objects carries no "[[...]]" reference */
|
|
249
252
|
if (diagram.hierarchy === true)
|
|
250
253
|
for (const node of nodes) {
|
|
251
|
-
const parent = parents.get(node);
|
|
254
|
+
const parent = ctx.parents.get(node);
|
|
252
255
|
if (parent !== undefined && nodeSet.has(parent))
|
|
253
256
|
edges.push({ source: parent, target: node });
|
|
254
257
|
}
|
|
@@ -309,7 +312,8 @@ const resolvePatterns = (index, errors, value, field) => {
|
|
|
309
312
|
itself, e.g., is no specification object, yet a context
|
|
310
313
|
diagram places it in the middle) and injected into the node
|
|
311
314
|
set (the center is absent whenever the configuration is invalid) */
|
|
312
|
-
const deriveCenter = (object, diagram, nodes, nodeSet,
|
|
315
|
+
const deriveCenter = (ctx, object, diagram, nodes, nodeSet, errors) => {
|
|
316
|
+
const { index, anchors, parenProps } = ctx;
|
|
313
317
|
if (typeof diagram.center === "string" && diagram.center !== "self") {
|
|
314
318
|
const reference = diagram.center.match(referenceOnce)?.[1].trim();
|
|
315
319
|
const resolved = reference !== undefined ? resolveUnique(index, reference, object) : undefined;
|
|
@@ -370,7 +374,8 @@ const deriveCenter = (object, diagram, nodes, nodeSet, index, anchors, parenProp
|
|
|
370
374
|
Gradia renders as an input node plus an output "ghost" node),
|
|
371
375
|
named by the "labeled" property and/or by the mediating object
|
|
372
376
|
the "via" property references (as "via <name>") */
|
|
373
|
-
const deriveCenterEdges = (cfg, nodes, center,
|
|
377
|
+
const deriveCenterEdges = (ctx, cfg, nodes, center, errors) => {
|
|
378
|
+
const { index, parenProps } = ctx;
|
|
374
379
|
const edges = new Array();
|
|
375
380
|
for (const node of nodes) {
|
|
376
381
|
if (node === center)
|
|
@@ -409,7 +414,8 @@ const deriveCenterEdges = (cfg, nodes, center, index, parenProps, errors) => {
|
|
|
409
414
|
on its own in turn), else its parent object when part of the node
|
|
410
415
|
set, else a synthetic container per object kind; a node nested into
|
|
411
416
|
itself or into a cycle stays un-nested and is reported */
|
|
412
|
-
const deriveNesting = (nest, nodes, nodeSet,
|
|
417
|
+
const deriveNesting = (ctx, nest, nodes, nodeSet, errors) => {
|
|
418
|
+
const { index, parents, parenProps } = ctx;
|
|
413
419
|
const parentOf = new Map();
|
|
414
420
|
const byKind = new Map();
|
|
415
421
|
/* the iteration also visits the containers appended underway */
|
|
@@ -417,7 +423,7 @@ const deriveNesting = (nest, nodes, nodeSet, index, parents, parenProps, errors)
|
|
|
417
423
|
let container;
|
|
418
424
|
for (const key of nest.properties ?? []) {
|
|
419
425
|
const value = propValue(parenProps, node, key);
|
|
420
|
-
const refs = value !== undefined ? Array.from(value.matchAll(referenceRegex)) : [];
|
|
426
|
+
const refs = value !== undefined ? Array.from(plainText(value).matchAll(referenceRegex)) : [];
|
|
421
427
|
if (refs.length !== 1)
|
|
422
428
|
continue;
|
|
423
429
|
container = resolveUnique(index, refs[0][1].trim(), node).target;
|
|
@@ -444,7 +450,7 @@ const deriveNesting = (nest, nodes, nodeSet, index, parents, parenProps, errors)
|
|
|
444
450
|
while (ancestor !== undefined && ancestor !== node)
|
|
445
451
|
ancestor = parentOf.get(ancestor);
|
|
446
452
|
if (ancestor === node) {
|
|
447
|
-
errors.push({ reason: `node "${
|
|
453
|
+
errors.push({ reason: `node "${node.name}" is nested into itself` });
|
|
448
454
|
continue;
|
|
449
455
|
}
|
|
450
456
|
parentOf.set(node, container);
|
|
@@ -497,32 +503,56 @@ const liftEdges = (edges, parentOf, mode, anchors) => {
|
|
|
497
503
|
}
|
|
498
504
|
return Array.from(merged.values());
|
|
499
505
|
};
|
|
506
|
+
/* determine the node objects of a diagram: the objects matching its
|
|
507
|
+
"nodes" patterns, or by default the current object plus all
|
|
508
|
+
objects below it */
|
|
509
|
+
const deriveNodes = (index, object, diagram, errors) => {
|
|
510
|
+
if (diagram.nodes !== undefined)
|
|
511
|
+
return resolvePatterns(index, errors, diagram.nodes, "nodes");
|
|
512
|
+
const nodes = new Array();
|
|
513
|
+
const walk = (o) => {
|
|
514
|
+
nodes.push(o);
|
|
515
|
+
o.children.forEach(walk);
|
|
516
|
+
};
|
|
517
|
+
walk(object);
|
|
518
|
+
return nodes;
|
|
519
|
+
};
|
|
520
|
+
/* project a "hub" diagram onto its center object: only the edges
|
|
521
|
+
incident to the center and only the nodes connected through them
|
|
522
|
+
remain, as Gradia requires exactly this constrained topology, and
|
|
523
|
+
the occupied columns are the center plus the input column, when an
|
|
524
|
+
edge of another node targets the center, plus the output column,
|
|
525
|
+
when an edge originates from the center, where Gradia places the
|
|
526
|
+
target of its self-loop, too */
|
|
527
|
+
const projectHub = (nodes, edges, center) => {
|
|
528
|
+
const incident = edges.filter((edge) => edge.source === center || edge.target === center);
|
|
529
|
+
const connected = new Set([center]);
|
|
530
|
+
for (const edge of incident) {
|
|
531
|
+
connected.add(edge.source);
|
|
532
|
+
connected.add(edge.target);
|
|
533
|
+
}
|
|
534
|
+
const columns = 1 +
|
|
535
|
+
(incident.some((edge) => edge.target === center && edge.source !== center) ? 1 : 0) +
|
|
536
|
+
(incident.some((edge) => edge.source === center) ? 1 : 0);
|
|
537
|
+
return { nodes: nodes.filter((node) => connected.has(node)), edges: incident, columns };
|
|
538
|
+
};
|
|
500
539
|
/* derive the Gradia spec of a single object from its "diagram:"
|
|
501
540
|
schema configuration (the returned spec is absent whenever the
|
|
502
|
-
configured diagram situation is invalid
|
|
503
|
-
|
|
541
|
+
configured diagram situation is invalid, the node set is empty,
|
|
542
|
+
or the diagram collapses onto a single node) */
|
|
543
|
+
const deriveDiagram = (ctx, object, diagram) => {
|
|
544
|
+
const { index, anchors } = ctx;
|
|
504
545
|
const type = diagram.type ?? "graph";
|
|
505
546
|
const errors = new Array();
|
|
506
|
-
/* determine the node objects
|
|
507
|
-
all objects below it) and the edge objects (default: none),
|
|
547
|
+
/* determine the node objects and the edge objects (default: none),
|
|
508
548
|
with the edge objects never acting as nodes themselves */
|
|
509
|
-
let nodes;
|
|
510
|
-
if (diagram.nodes !== undefined)
|
|
511
|
-
nodes = resolvePatterns(index, errors, diagram.nodes, "nodes");
|
|
512
|
-
else {
|
|
513
|
-
nodes = new Array();
|
|
514
|
-
const walk = (o) => {
|
|
515
|
-
nodes.push(o);
|
|
516
|
-
o.children.forEach(walk);
|
|
517
|
-
};
|
|
518
|
-
walk(object);
|
|
519
|
-
}
|
|
549
|
+
let nodes = deriveNodes(index, object, diagram, errors);
|
|
520
550
|
const edgeObjects = diagram.edges !== undefined ? resolvePatterns(index, errors, diagram.edges, "edges") : [];
|
|
521
551
|
const edgeSet = new Set(edgeObjects);
|
|
522
552
|
nodes = nodes.filter((node) => !edgeSet.has(node));
|
|
523
553
|
const nodeSet = new Set(nodes);
|
|
524
554
|
/* derive the edges of the diagram */
|
|
525
|
-
let edges = deriveEdges(diagram, type, nodes, nodeSet, edgeObjects,
|
|
555
|
+
let edges = deriveEdges(ctx, diagram, type, nodes, nodeSet, edgeObjects, errors);
|
|
526
556
|
/* determine the center of a "hub" diagram (the only shape
|
|
527
557
|
carrying a center at all) */
|
|
528
558
|
if (type !== "hub") {
|
|
@@ -532,7 +562,7 @@ const deriveDiagram = (object, diagram, index, anchors, positions, parents, pare
|
|
|
532
562
|
errors.push({ reason: `"${type}" diagram cannot carry a "centerEdges" configuration` });
|
|
533
563
|
}
|
|
534
564
|
const derived = type === "hub" ?
|
|
535
|
-
deriveCenter(object, diagram, nodes, nodeSet,
|
|
565
|
+
deriveCenter(ctx, object, diagram, nodes, nodeSet, errors) : { center: object };
|
|
536
566
|
if (derived === undefined)
|
|
537
567
|
return { errors };
|
|
538
568
|
const { center, centerUrl } = derived;
|
|
@@ -541,31 +571,16 @@ const deriveDiagram = (object, diagram, index, anchors, positions, parents, pare
|
|
|
541
571
|
then deduplicate all edges, as a center edge of a real center
|
|
542
572
|
can coincide with a reference edge of a node onto it */
|
|
543
573
|
if (type === "hub" && diagram.centerEdges !== undefined)
|
|
544
|
-
edges.push(...deriveCenterEdges(diagram.centerEdges, nodes, center,
|
|
574
|
+
edges.push(...deriveCenterEdges(ctx, diagram.centerEdges, nodes, center, errors));
|
|
545
575
|
edges = dedupEdges(edges, anchors);
|
|
546
|
-
/* a "hub" diagram
|
|
547
|
-
|
|
548
|
-
connected through them remain, as Gradia requires exactly this
|
|
549
|
-
constrained topology (and the occupied columns are the center
|
|
550
|
-
plus the input column, when an edge of another node targets the
|
|
551
|
-
center, plus the output column, when an edge originates from the
|
|
552
|
-
center, where Gradia places the target of its self-loop, too) */
|
|
576
|
+
/* project a "hub" diagram onto its center object, which has
|
|
577
|
+
to be part of the node set */
|
|
553
578
|
let columns;
|
|
554
579
|
if (type === "hub") {
|
|
555
580
|
if (!nodeSet.has(center))
|
|
556
581
|
errors.push({ reason: `"hub" diagram center "${center.name}" is not part of the node set` });
|
|
557
|
-
else
|
|
558
|
-
|
|
559
|
-
const connected = new Set([center]);
|
|
560
|
-
for (const edge of edges) {
|
|
561
|
-
connected.add(edge.source);
|
|
562
|
-
connected.add(edge.target);
|
|
563
|
-
}
|
|
564
|
-
nodes = nodes.filter((node) => connected.has(node));
|
|
565
|
-
columns = 1 +
|
|
566
|
-
(edges.some((edge) => edge.target === center && edge.source !== center) ? 1 : 0) +
|
|
567
|
-
(edges.some((edge) => edge.source === center) ? 1 : 0);
|
|
568
|
-
}
|
|
582
|
+
else
|
|
583
|
+
({ nodes, edges, columns } = projectHub(nodes, edges, center));
|
|
569
584
|
}
|
|
570
585
|
/* nest the nodes into container nodes (except in a "hub" diagram,
|
|
571
586
|
where every container would need a primary node of its own),
|
|
@@ -576,7 +591,7 @@ const deriveDiagram = (object, diagram, index, anchors, positions, parents, pare
|
|
|
576
591
|
if (type === "hub")
|
|
577
592
|
errors.push({ reason: "\"hub\" diagram cannot carry a \"nest\" configuration" });
|
|
578
593
|
else {
|
|
579
|
-
nesting = deriveNesting(diagram.nest, nodes, nodeSet,
|
|
594
|
+
nesting = deriveNesting(ctx, diagram.nest, nodes, nodeSet, errors);
|
|
580
595
|
if (diagram.nest.crossing !== undefined && diagram.nest.crossing !== "nodes")
|
|
581
596
|
edges = liftEdges(edges, nesting.parentOf, diagram.nest.crossing, anchors);
|
|
582
597
|
edges = edges.filter((edge) => nesting.parentOf.get(edge.source) !== edge.target
|
|
@@ -604,7 +619,8 @@ const deriveDiagram = (object, diagram, index, anchors, positions, parents, pare
|
|
|
604
619
|
if (nodes.length === 0
|
|
605
620
|
|| (diagram.collapse !== false && nodes.length === 1 && edges.length === 0))
|
|
606
621
|
return { errors };
|
|
607
|
-
return { spec: renderSpec(diagram, type, center, centerUrl, nodes, edges, nesting
|
|
622
|
+
return { spec: renderSpec(ctx, diagram, type, center, centerUrl, nodes, edges, nesting),
|
|
623
|
+
config: { ...diagramPresets, ...diagram.config }, columns, errors };
|
|
608
624
|
};
|
|
609
625
|
/* the memoized diagram derivations, keyed by specification, as the
|
|
610
626
|
lint validation and every renderer of an export derive the very
|
|
@@ -628,11 +644,11 @@ export const specDiagrams = (specification, config) => {
|
|
|
628
644
|
/* re-derive the synthetic properties of the consumed parenthesized
|
|
629
645
|
name tokens, as the derivation sees the AST alone, while the
|
|
630
646
|
parsing context holding them is long gone by export time */
|
|
631
|
-
const
|
|
647
|
+
const ctx = { index, anchors, positions, parents, parenProps: collectParenProps(schemas) };
|
|
632
648
|
const results = new Map();
|
|
633
649
|
for (const [object, schema] of schemas)
|
|
634
650
|
if (schema.diagram !== undefined)
|
|
635
|
-
results.set(object, deriveDiagram(object, schema.diagram
|
|
651
|
+
results.set(object, deriveDiagram(ctx, object, schema.diagram));
|
|
636
652
|
derivations.set(specification, { config, results });
|
|
637
653
|
return results;
|
|
638
654
|
};
|
|
@@ -687,8 +703,8 @@ export const renderDiagrams = async (specification, config, verbose, omit = new
|
|
|
687
703
|
svg = await Gradia.render(result.spec, { format: "svg:embedded", config: result.config });
|
|
688
704
|
}
|
|
689
705
|
catch (err) {
|
|
690
|
-
verbose?.(`rendering diagram of ${object.kind} "${object.name}" failed: ` +
|
|
691
|
-
(err instanceof Error ? err.message : String(err))
|
|
706
|
+
verbose?.(`rendering diagram of ${object.kind} "${literal(plainText(object.name))}" failed: ` +
|
|
707
|
+
(err instanceof Error ? err.message : String(err)));
|
|
692
708
|
continue;
|
|
693
709
|
}
|
|
694
710
|
}
|
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import type { Spec, SpecObject } from "./specbook-format-spec.js";
|
|
2
|
-
import
|
|
2
|
+
import { embeddingThemes } from "./specbook-parse-common.js";
|
|
3
3
|
export declare const escapeHtml: (text: string) => string;
|
|
4
4
|
export declare const stylesheet: () => string;
|
|
5
5
|
export declare const searchScript: () => string;
|
|
@@ -15,7 +15,7 @@ export declare const isTitleObject: (object: SpecObject) => boolean;
|
|
|
15
15
|
export declare const titleObject: (specification: Spec) => SpecObject | undefined;
|
|
16
16
|
export declare const documentLang: (specification: Spec) => string | undefined;
|
|
17
17
|
export declare const documentCharset: (specification: Spec) => string | undefined;
|
|
18
|
-
export declare const documentThemeStyle: (specification: Spec) =>
|
|
18
|
+
export declare const documentThemeStyle: (specification: Spec) => (typeof embeddingThemes)[number] | undefined;
|
|
19
19
|
export declare const documentThemeTone: (specification: Spec) => string | undefined;
|
|
20
20
|
export type PaperSetup = {
|
|
21
21
|
unit: "mm" | "in";
|