@rse/specbook 1.2.1 → 1.2.2
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 -1
- package/dst/specbook-api.d.ts +1 -0
- package/dst/specbook-api.js +12 -2
- package/dst/specbook-cli.js +1 -1
- package/dst/specbook-cmd-preview.js +12 -6
- package/dst/specbook-config.js +20 -5
- package/dst/specbook-diagram.js +43 -30
- package/dst/specbook-export-html.d.ts +1 -0
- package/dst/specbook-export-html.js +70 -18
- package/dst/specbook-export-pdf.js +18 -6
- package/dst/specbook-format.md +3 -2
- package/dst/specbook-link.d.ts +1 -0
- package/dst/specbook-link.js +7 -0
- package/dst/specbook-parse-semantic.d.ts +2 -1
- package/dst/specbook-parse-semantic.js +28 -16
- package/dst/specbook-parse-syntax.js +4 -2
- package/package.json +1 -1
package/README.md
CHANGED
|
@@ -140,6 +140,7 @@ export class SpecBook {
|
|
|
140
140
|
basedir?: string,
|
|
141
141
|
formats?: ExportFormat[],
|
|
142
142
|
realtime?: boolean,
|
|
143
|
+
outputs?: string[], /* paths "onExport" writes, refused if observed */
|
|
143
144
|
onExport: (buffers: Buffer[]) => void | Promise<void>
|
|
144
145
|
}): Promise<void>
|
|
145
146
|
|
|
@@ -357,7 +358,9 @@ Options:
|
|
|
357
358
|
silent for one second. A failed re-export is reported and leaves the
|
|
358
359
|
observe loop intact, so a transiently invalid specification does not
|
|
359
360
|
end the watch. The outputs have to be regular files, as `-` (stdout)
|
|
360
|
-
cannot receive a repeated export
|
|
361
|
+
cannot receive a repeated export, and none of them may be an observed
|
|
362
|
+
source file itself, as its own write would re-trigger the observation
|
|
363
|
+
endlessly.
|
|
361
364
|
|
|
362
365
|
- `-a|--addr <ip-addr>`, `-p|--port <tcp-port>` (`preview` only):
|
|
363
366
|
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
|
@@ -132,6 +132,14 @@ export class SpecBook {
|
|
|
132
132
|
observed, too, so a failing export (even one due to an
|
|
133
133
|
invalid configuration) still keeps the observe loop fed */
|
|
134
134
|
const result = lint({ config, basedir: options.basedir ?? ".", verbose });
|
|
135
|
+
const files = [...config.map((file) => path.resolve(file)), ...result.files];
|
|
136
|
+
/* an output which is itself an observed source would re-trigger
|
|
137
|
+
the observation with its own write and hence feed an endless
|
|
138
|
+
re-export loop, so refuse it before it is ever written */
|
|
139
|
+
const collision = options.outputs?.find((output) => files.includes(path.resolve(output)));
|
|
140
|
+
if (collision !== undefined)
|
|
141
|
+
throw new Error(`the output "${collision}" is an observed source file, ` +
|
|
142
|
+
"which would re-trigger the observation endlessly");
|
|
135
143
|
try {
|
|
136
144
|
await options.onExport(await this.renderFormats(result, options.formats, verbose, options.realtime));
|
|
137
145
|
}
|
|
@@ -139,11 +147,13 @@ export class SpecBook {
|
|
|
139
147
|
verbose("export failed: " +
|
|
140
148
|
(err instanceof Error ? err.message : String(err)), "notice");
|
|
141
149
|
}
|
|
142
|
-
return
|
|
150
|
+
return files;
|
|
143
151
|
}, verbose);
|
|
144
152
|
}
|
|
145
153
|
/* export the specification like "export" and then keep the export
|
|
146
|
-
in sync with its sources (see "observe")
|
|
154
|
+
in sync with its sources (see "observe"), where "outputs" names the
|
|
155
|
+
files "onExport" writes, so an output which is itself an observed
|
|
156
|
+
source can be refused */
|
|
147
157
|
async watch(options) {
|
|
148
158
|
const verbose = this.verboseOf("export");
|
|
149
159
|
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 }));
|
|
@@ -5,14 +5,20 @@
|
|
|
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;
|
|
13
|
+
/* the placeholder page answering a GET before the first successful
|
|
14
|
+
export: it still carries the live preview client, so the browser
|
|
15
|
+
subscribes to the "RELOAD" command right away and replaces itself
|
|
16
|
+
with the document once the first export arrives */
|
|
17
|
+
const placeholder = renderPlaceholder("no specification export available yet");
|
|
12
18
|
/* 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 */
|
|
19
|
+
most recent HTML export (or with the 503 placeholder page before the
|
|
20
|
+
first successful one), while a WebSocket upgrade on "/" subscribes
|
|
21
|
+
the client to the "RELOAD" command every fresh export broadcasts */
|
|
16
22
|
export const servePreview = async (options) => {
|
|
17
23
|
let html;
|
|
18
24
|
const clients = new Set();
|
|
@@ -24,8 +30,8 @@ export const servePreview = async (options) => {
|
|
|
24
30
|
url: "/",
|
|
25
31
|
handler: (_request, reply) => {
|
|
26
32
|
if (html === undefined)
|
|
27
|
-
return reply.code(503).type("text/
|
|
28
|
-
.
|
|
33
|
+
return reply.code(503).type("text/html; charset=utf-8")
|
|
34
|
+
.header("cache-control", "no-store").send(placeholder);
|
|
29
35
|
return reply.type("text/html; charset=utf-8")
|
|
30
36
|
.header("cache-control", "no-store").send(html);
|
|
31
37
|
},
|
|
@@ -53,7 +59,7 @@ export const servePreview = async (options) => {
|
|
|
53
59
|
options.verbose(`listening on ${literal(url)}`, "notice");
|
|
54
60
|
return {
|
|
55
61
|
update: (buffer) => {
|
|
56
|
-
/* the first export turns the 503
|
|
62
|
+
/* the first export turns the 503 placeholder into the document */
|
|
57
63
|
if (html === undefined)
|
|
58
64
|
options.verbose(`serving ${literal(url)}`, "notice");
|
|
59
65
|
html = buffer;
|
package/dst/specbook-config.js
CHANGED
|
@@ -43,7 +43,11 @@ 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)) {
|
|
47
51
|
for (const item of source) {
|
|
48
52
|
const i = target.findIndex((existing) => identityOf(existing, list) === identityOf(item, list));
|
|
49
53
|
if (i < 0)
|
|
@@ -58,6 +62,17 @@ const mergeConfig = (target, source, list) => {
|
|
|
58
62
|
else
|
|
59
63
|
return source;
|
|
60
64
|
};
|
|
65
|
+
/* deep copy a converted YAML document, as the conversion resolves all
|
|
66
|
+
aliases of an anchor onto one shared object, which the in-place
|
|
67
|
+
merge otherwise would alter through every alias at once */
|
|
68
|
+
const unshared = (value) => {
|
|
69
|
+
if (Array.isArray(value))
|
|
70
|
+
return value.map((item) => unshared(item));
|
|
71
|
+
else if (isPlainObject(value))
|
|
72
|
+
return Object.fromEntries(Object.entries(value).map(([key, val]) => [key, unshared(val)]));
|
|
73
|
+
else
|
|
74
|
+
return value;
|
|
75
|
+
};
|
|
61
76
|
/* determine file and line/column of a path into the merged
|
|
62
77
|
configuration by translating the path into every document (from the
|
|
63
78
|
last to the first one, as a later document overrides an earlier one),
|
|
@@ -267,10 +282,10 @@ export const loadConfig = (files) => {
|
|
|
267
282
|
}
|
|
268
283
|
if (diagnostics.length > 0)
|
|
269
284
|
return { diagnostics };
|
|
270
|
-
/* merge the documents in order, out of fresh
|
|
271
|
-
merge mutates its target while the raw ones
|
|
272
|
-
for the path translation */
|
|
273
|
-
const merged = docs.map(({ doc }) => doc.toJS())
|
|
285
|
+
/* merge the documents in order, out of fresh and unshared
|
|
286
|
+
conversions, as the merge mutates its target while the raw ones
|
|
287
|
+
have to stay intact for the path translation */
|
|
288
|
+
const merged = docs.map(({ doc }) => unshared(doc.toJS()))
|
|
274
289
|
.reduce((target, source) => mergeConfig(target, source));
|
|
275
290
|
/* semantically validate the merged result against the schema of the configuration */
|
|
276
291
|
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)
|
|
@@ -253,7 +260,7 @@ const resolvePatterns = (index, errors, value, field) => {
|
|
|
253
260
|
itself, e.g., is no specification object, yet a context
|
|
254
261
|
diagram places it in the middle) and injected into the node
|
|
255
262
|
set (the center is absent whenever the configuration is invalid) */
|
|
256
|
-
const deriveCenter = (object, diagram, nodes, nodeSet, index, anchors, errors) => {
|
|
263
|
+
const deriveCenter = (object, diagram, nodes, nodeSet, index, anchors, parenProps, errors) => {
|
|
257
264
|
if (typeof diagram.center === "string" && diagram.center !== "self") {
|
|
258
265
|
const reference = diagram.center.match(referenceOnce)?.[1].trim();
|
|
259
266
|
const resolved = reference !== undefined ? resolveUnique(index, reference, object) : undefined;
|
|
@@ -283,7 +290,7 @@ const deriveCenter = (object, diagram, nodes, nodeSet, index, anchors, errors) =
|
|
|
283
290
|
}
|
|
284
291
|
centerUrl = `#${anchors.get(source) ?? source.id}`;
|
|
285
292
|
if (centerCfg.property !== undefined) {
|
|
286
|
-
const value =
|
|
293
|
+
const value = propValue(parenProps, source, centerCfg.property);
|
|
287
294
|
if (value === undefined) {
|
|
288
295
|
errors.push({ reason: `diagram "center" source lacks the property "${centerCfg.property}"` });
|
|
289
296
|
return undefined;
|
|
@@ -312,12 +319,12 @@ const deriveCenter = (object, diagram, nodes, nodeSet, index, anchors, errors) =
|
|
|
312
319
|
maps onto a node-to-center edge, the "outbound" value onto a
|
|
313
320
|
center-to-node edge, and the "both" value onto both (which
|
|
314
321
|
Gradia renders as an input node plus an output "ghost" node) */
|
|
315
|
-
const deriveCenterEdges = (cfg, nodes, center, index, errors) => {
|
|
322
|
+
const deriveCenterEdges = (cfg, nodes, center, index, parenProps, errors) => {
|
|
316
323
|
const edges = new Array();
|
|
317
324
|
for (const node of nodes) {
|
|
318
325
|
if (node === center)
|
|
319
326
|
continue;
|
|
320
|
-
const value =
|
|
327
|
+
const value = propValue(parenProps, node, cfg.property);
|
|
321
328
|
const inbound = cfg.inbound !== undefined && value === cfg.inbound;
|
|
322
329
|
const outbound = cfg.outbound !== undefined && value === cfg.outbound;
|
|
323
330
|
const both = cfg.both !== undefined && value === cfg.both;
|
|
@@ -328,7 +335,7 @@ const deriveCenterEdges = (cfg, nodes, center, index, errors) => {
|
|
|
328
335
|
continue;
|
|
329
336
|
}
|
|
330
337
|
const label = cfg.labeled !== undefined ?
|
|
331
|
-
|
|
338
|
+
propValue(parenProps, node, cfg.labeled) : undefined;
|
|
332
339
|
const name = label !== undefined ? plainText(expandReferences(label, (reference) => resolveUnique(index, reference, node).target?.name ?? reference)) : undefined;
|
|
333
340
|
if (inbound || both)
|
|
334
341
|
edges.push({ source: node, target: center, name });
|
|
@@ -340,7 +347,7 @@ const deriveCenterEdges = (cfg, nodes, center, index, errors) => {
|
|
|
340
347
|
/* derive the Gradia spec of a single object from its "diagram:"
|
|
341
348
|
schema configuration (the returned spec is absent whenever the
|
|
342
349
|
configured diagram situation is invalid) */
|
|
343
|
-
const deriveDiagram = (object, diagram, index, anchors, parents) => {
|
|
350
|
+
const deriveDiagram = (object, diagram, index, anchors, parents, parenProps) => {
|
|
344
351
|
const type = diagram.type ?? "graph";
|
|
345
352
|
const errors = new Array();
|
|
346
353
|
/* determine the node objects (default: the current object plus
|
|
@@ -362,7 +369,7 @@ const deriveDiagram = (object, diagram, index, anchors, parents) => {
|
|
|
362
369
|
nodes = nodes.filter((node) => !edgeSet.has(node));
|
|
363
370
|
const nodeSet = new Set(nodes);
|
|
364
371
|
/* derive the (deduplicated) edges of the diagram */
|
|
365
|
-
let edges = deriveEdges(diagram, type, nodes, nodeSet, edgeObjects, index, anchors, parents, errors);
|
|
372
|
+
let edges = deriveEdges(diagram, type, nodes, nodeSet, edgeObjects, index, anchors, parents, parenProps, errors);
|
|
366
373
|
/* determine the center of a "hub" diagram (the only shape
|
|
367
374
|
carrying a center at all) */
|
|
368
375
|
if (type !== "hub") {
|
|
@@ -372,14 +379,14 @@ const deriveDiagram = (object, diagram, index, anchors, parents) => {
|
|
|
372
379
|
errors.push({ reason: `"${type}" diagram cannot carry a "centerEdges" configuration` });
|
|
373
380
|
}
|
|
374
381
|
const derived = type === "hub" ?
|
|
375
|
-
deriveCenter(object, diagram, nodes, nodeSet, index, anchors, errors) : { center: object };
|
|
382
|
+
deriveCenter(object, diagram, nodes, nodeSet, index, anchors, parenProps, errors) : { center: object };
|
|
376
383
|
if (derived === undefined)
|
|
377
384
|
return { errors };
|
|
378
385
|
const { center, centerUrl } = derived;
|
|
379
386
|
/* synthesize the center edges of a "hub" diagram, as the nodes
|
|
380
387
|
carry no "[[...]]" reference to a synthetic center */
|
|
381
388
|
if (type === "hub" && diagram.centerEdges !== undefined)
|
|
382
|
-
edges.push(...deriveCenterEdges(diagram.centerEdges, nodes, center, index, errors));
|
|
389
|
+
edges.push(...deriveCenterEdges(diagram.centerEdges, nodes, center, index, parenProps, errors));
|
|
383
390
|
/* a "hub" diagram is the hub-projection onto its center object:
|
|
384
391
|
only the edges incident to the center and only the nodes
|
|
385
392
|
connected through them remain, with self-loops dropped, as
|
|
@@ -415,16 +422,17 @@ const deriveDiagram = (object, diagram, index, anchors, parents) => {
|
|
|
415
422
|
}
|
|
416
423
|
nodes = nodes.filter((node) => connected.has(node));
|
|
417
424
|
}
|
|
418
|
-
if (nodes.length === 0)
|
|
419
|
-
errors.push({ reason: "diagram yields no nodes" });
|
|
420
425
|
if (errors.length > 0)
|
|
421
426
|
return { errors };
|
|
422
|
-
/*
|
|
423
|
-
|
|
424
|
-
|
|
425
|
-
|
|
427
|
+
/* an empty node set silently omits the diagram, as a wildcard
|
|
428
|
+
"nodes" pattern legitimately matches nothing when an optional
|
|
429
|
+
kind is absent, and the "collapse" handling (enabled by default)
|
|
430
|
+
does the same for the degenerate diagram of a single node, as
|
|
431
|
+
such a diagram carries no information beyond the object itself */
|
|
432
|
+
if (nodes.length === 0
|
|
433
|
+
|| (diagram.collapse !== false && nodes.length === 1 && edges.length === 0))
|
|
426
434
|
return { errors };
|
|
427
|
-
return { spec: renderSpec(diagram, type, center, centerUrl, nodes, edges, index, anchors),
|
|
435
|
+
return { spec: renderSpec(diagram, type, center, centerUrl, nodes, edges, index, anchors, parenProps),
|
|
428
436
|
config: diagram.config, columns, errors };
|
|
429
437
|
};
|
|
430
438
|
/* the memoized diagram derivations, keyed by specification, as the
|
|
@@ -442,10 +450,15 @@ export const specDiagrams = (specification, config) => {
|
|
|
442
450
|
const parents = new Map();
|
|
443
451
|
for (const node of index)
|
|
444
452
|
parents.set(node.object, node.parent?.object);
|
|
453
|
+
const schemas = collectSchemas(specification, config);
|
|
454
|
+
/* re-derive the synthetic properties of the consumed parenthesized
|
|
455
|
+
name tokens, as the derivation sees the AST alone, while the
|
|
456
|
+
parsing context holding them is long gone by export time */
|
|
457
|
+
const parenProps = collectParenProps(schemas);
|
|
445
458
|
const results = new Map();
|
|
446
|
-
for (const [object, schema] of
|
|
459
|
+
for (const [object, schema] of schemas)
|
|
447
460
|
if (schema.diagram !== undefined)
|
|
448
|
-
results.set(object, deriveDiagram(object, schema.diagram, index, anchors, parents));
|
|
461
|
+
results.set(object, deriveDiagram(object, schema.diagram, index, anchors, parents, parenProps));
|
|
449
462
|
derivations.set(specification, { config, results });
|
|
450
463
|
return results;
|
|
451
464
|
};
|
|
@@ -8,4 +8,5 @@ export type OutlineEntry = {
|
|
|
8
8
|
};
|
|
9
9
|
export declare const htmlOutline: (specification: Spec, config?: Schema) => OutlineEntry[];
|
|
10
10
|
export declare const titlePageObject: (specification: Spec) => SpecObject | undefined;
|
|
11
|
+
export declare const renderPlaceholder: (message: string) => string;
|
|
11
12
|
export declare const renderHtml: (specification: Spec, config?: Schema, tocPages?: Map<string, number>, css?: string, realtime?: boolean, verbose?: Verbose) => Promise<string>;
|
|
@@ -29,24 +29,7 @@ const templates = {
|
|
|
29
29
|
{{ Document.css }}
|
|
30
30
|
</style>
|
|
31
31
|
<script>
|
|
32
|
-
|
|
33
|
-
let style = null
|
|
34
|
-
try { style = localStorage.getItem("specbook-theme") }
|
|
35
|
-
catch { /* an inaccessible storage just means no stored choice */ }
|
|
36
|
-
if (style === null) {
|
|
37
|
-
const m = document.documentElement.className.match(/theme-(light|dark)/)
|
|
38
|
-
style = m !== null ? m[1] : null
|
|
39
|
-
}
|
|
40
|
-
if (style === null)
|
|
41
|
-
style = window.matchMedia("(prefers-color-scheme: dark)").matches ? "dark" : "light"
|
|
42
|
-
document.documentElement.className = "theme-" + style
|
|
43
|
-
})()
|
|
44
|
-
function themeSwitch () {
|
|
45
|
-
const style = document.documentElement.className === "theme-dark" ? "light" : "dark"
|
|
46
|
-
document.documentElement.className = "theme-" + style
|
|
47
|
-
try { localStorage.setItem("specbook-theme", style) }
|
|
48
|
-
catch { /* an inaccessible storage just loses the choice */ }
|
|
49
|
-
}
|
|
32
|
+
{{ Document.themescript }}
|
|
50
33
|
</script>
|
|
51
34
|
</head>
|
|
52
35
|
<body>
|
|
@@ -72,6 +55,26 @@ const templates = {
|
|
|
72
55
|
</body>
|
|
73
56
|
</html>
|
|
74
57
|
`,
|
|
58
|
+
/* <Placeholder/> */
|
|
59
|
+
"Placeholder": textframe `
|
|
60
|
+
<!DOCTYPE html>
|
|
61
|
+
<html>
|
|
62
|
+
<head>
|
|
63
|
+
<meta charset="utf-8"/>
|
|
64
|
+
<title>{{ Placeholder.title }}</title>
|
|
65
|
+
<style>
|
|
66
|
+
{{ Placeholder.css }}
|
|
67
|
+
</style>
|
|
68
|
+
<script>
|
|
69
|
+
{{ Placeholder.themescript }}
|
|
70
|
+
</script>
|
|
71
|
+
</head>
|
|
72
|
+
<body>
|
|
73
|
+
<div class="placeholder">{{ Placeholder.message }}</div>
|
|
74
|
+
<script class="realtime">{{ Placeholder.realtime }}</script>
|
|
75
|
+
</body>
|
|
76
|
+
</html>
|
|
77
|
+
`,
|
|
75
78
|
/* <TitlePage/> */
|
|
76
79
|
"TitlePage": textframe `
|
|
77
80
|
<div class="titlepage" id="titlepage">
|
|
@@ -213,6 +216,41 @@ const templates = {
|
|
|
213
216
|
</table>
|
|
214
217
|
`
|
|
215
218
|
};
|
|
219
|
+
/* the client-side script of the color theme: it applies the stored
|
|
220
|
+
choice (or, without one, the document default and finally the system
|
|
221
|
+
preference) before the first paint and toggles it on demand. The
|
|
222
|
+
live preview placeholder page carries it, too, as an in-place
|
|
223
|
+
document update never re-executes a head script */
|
|
224
|
+
const themeScript = textframe `
|
|
225
|
+
(function () {
|
|
226
|
+
let style = null
|
|
227
|
+
try { style = localStorage.getItem("specbook-theme") }
|
|
228
|
+
catch { /* an inaccessible storage just means no stored choice */ }
|
|
229
|
+
if (style === null) {
|
|
230
|
+
const m = document.documentElement.className.match(/theme-(light|dark)/)
|
|
231
|
+
style = m !== null ? m[1] : null
|
|
232
|
+
}
|
|
233
|
+
if (style === null)
|
|
234
|
+
style = window.matchMedia("(prefers-color-scheme: dark)").matches ? "dark" : "light"
|
|
235
|
+
document.documentElement.className = "theme-" + style
|
|
236
|
+
})()
|
|
237
|
+
function themeSwitch () {
|
|
238
|
+
const style = document.documentElement.className === "theme-dark" ? "light" : "dark"
|
|
239
|
+
document.documentElement.className = "theme-" + style
|
|
240
|
+
try { localStorage.setItem("specbook-theme", style) }
|
|
241
|
+
catch { /* an inaccessible storage just loses the choice */ }
|
|
242
|
+
}
|
|
243
|
+
`;
|
|
244
|
+
/* the minimal stylesheet of the live preview placeholder page: it also
|
|
245
|
+
provides the <style> element the in-place document update swaps with
|
|
246
|
+
the real stylesheet, so it carries the centered message only */
|
|
247
|
+
const placeholderStylesheet = textframe `
|
|
248
|
+
html.theme-light body { color: #333333; background-color: #ffffff }
|
|
249
|
+
html.theme-dark body { color: #cccccc; background-color: #1a1a1a }
|
|
250
|
+
body { font-family: sans-serif; margin: 0 }
|
|
251
|
+
div.placeholder { position: absolute; top: 50%; left: 0; right: 0;
|
|
252
|
+
transform: translateY(-50%); text-align: center; opacity: 0.6 }
|
|
253
|
+
`;
|
|
216
254
|
/* the client-side script of the live preview: it connects back to the
|
|
217
255
|
own page over WebSocket (the URL scheme "http"/"https" replaced with
|
|
218
256
|
"ws"/"wss"), re-connects every second after a lost connection, and
|
|
@@ -1179,6 +1217,19 @@ const makeLinker = (index) => (text, compact) => expandReferences(text, (referen
|
|
|
1179
1217
|
`<a href="#${escapeHtml(anchorOf(target))}" class="link-compact"${specAttr(target)}>${target.name}</a>` :
|
|
1180
1218
|
`<a href="#${escapeHtml(anchorOf(target))}" class="link-full">${full}</a>`;
|
|
1181
1219
|
});
|
|
1220
|
+
/* render the placeholder page of the live preview, served instead of
|
|
1221
|
+
the document before the first successful export: it carries the very
|
|
1222
|
+
same client-side script as the regular export, so the page recovers
|
|
1223
|
+
on its own -- as the usual in-place document update -- once the
|
|
1224
|
+
specification becomes exportable, plus a <style> element and the
|
|
1225
|
+
color theme script the update expects to find in the head */
|
|
1226
|
+
export const renderPlaceholder = (message) => render("Placeholder", { Placeholder: {
|
|
1227
|
+
title: "SpecBook",
|
|
1228
|
+
css: safe(placeholderStylesheet),
|
|
1229
|
+
themescript: safe(themeScript),
|
|
1230
|
+
realtime: safe(realtimeScript),
|
|
1231
|
+
message
|
|
1232
|
+
} });
|
|
1182
1233
|
/* render the entire specification into a self-contained HTML document,
|
|
1183
1234
|
with the build-time pre-assembled stylesheet embedded inline, the
|
|
1184
1235
|
artifact timestamps aggregated into min(Created)/max(Modified),
|
|
@@ -1239,6 +1290,7 @@ export const renderHtml = async (specification, config, tocPages, css, realtime
|
|
|
1239
1290
|
lang,
|
|
1240
1291
|
theme: documentThemeStyle(specification)?.toLowerCase(),
|
|
1241
1292
|
css: safe(css ?? stylesheet()),
|
|
1293
|
+
themescript: safe(themeScript),
|
|
1242
1294
|
titlepage: title !== undefined ?
|
|
1243
1295
|
safe(renderTitlePage(title, formatDate(created), formatDate(modified))) : "",
|
|
1244
1296
|
search: safe(searchScript()),
|
|
@@ -168,6 +168,10 @@ export const requireBrowser = async (verbose) => {
|
|
|
168
168
|
`"${literal("npx playwright install chromium")}" once to download the Playwright Chromium`);
|
|
169
169
|
return options;
|
|
170
170
|
};
|
|
171
|
+
/* the bound of the ToC page number fixpoint iteration, as the page
|
|
172
|
+
number column itself shifts the pagination and hence the iteration
|
|
173
|
+
can oscillate instead of ever reaching a stable rendering */
|
|
174
|
+
const tocPasses = 3;
|
|
171
175
|
/* render a self-contained HTML document into a PDF via Playwright,
|
|
172
176
|
re-rendering the HTML with the discovered ToC page numbers */
|
|
173
177
|
export const htmlToPdf = async (renderHtmlPass, heading, outline, titlePage, verbose, css, theme, paper = paperSizeDefault) => {
|
|
@@ -198,19 +202,27 @@ export const htmlToPdf = async (renderHtmlPass, heading, outline, titlePage, ver
|
|
|
198
202
|
};
|
|
199
203
|
/* determine the ToC page numbers via a fixpoint iteration:
|
|
200
204
|
paginate undecorated, extract the per-anchor pages, and
|
|
201
|
-
re-render until the HTML is stable (bounded to
|
|
205
|
+
re-render until the HTML is stable (bounded to "tocPasses"
|
|
202
206
|
passes), as the ToC page number column itself can shift
|
|
203
207
|
the pagination */
|
|
204
208
|
verbose("determining ToC page numbers");
|
|
205
209
|
let html = await renderHtmlPass();
|
|
206
210
|
let plain = await renderPdf(html);
|
|
207
|
-
|
|
211
|
+
let stable = false;
|
|
212
|
+
for (let i = 0; i < tocPasses && !stable; i++) {
|
|
208
213
|
const next = await renderHtmlPass(await anchorPages(plain));
|
|
209
|
-
|
|
210
|
-
|
|
211
|
-
|
|
212
|
-
|
|
214
|
+
stable = next === html;
|
|
215
|
+
if (!stable) {
|
|
216
|
+
html = next;
|
|
217
|
+
plain = await renderPdf(html);
|
|
218
|
+
}
|
|
213
219
|
}
|
|
220
|
+
/* report an unconverged iteration, as the ToC then carries the
|
|
221
|
+
page numbers of a pagination other than the final one and
|
|
222
|
+
would otherwise ship silently wrong page numbers */
|
|
223
|
+
if (!stable)
|
|
224
|
+
verbose(`the ToC page numbers did not stabilize within ${literal(tocPasses)} ` +
|
|
225
|
+
"passes -- they can be off by a page", "notice");
|
|
214
226
|
/* render the final document, decorated with header/footer */
|
|
215
227
|
const decorated = await renderPdf(html, {
|
|
216
228
|
displayHeaderFooter: true,
|
package/dst/specbook-format.md
CHANGED
|
@@ -416,8 +416,9 @@ type SchemaGradiaConfig = Partial<{
|
|
|
416
416
|
|
|
417
417
|
- `SchemaProperty.present?: boolean | string`:
|
|
418
418
|
whether some value (`true`) or a value matching a regexp or enum
|
|
419
|
-
expression has to occur on at least one of the
|
|
420
|
-
|
|
419
|
+
expression has to occur on at least one sibling object of the kind,
|
|
420
|
+
so that `unique` plus `present` demand exactly one and `present` on
|
|
421
|
+
an optional kind demands at least one object of that kind,
|
|
421
422
|
BECAUSE some markers, like an initial state, are not just single but indispensable
|
|
422
423
|
|
|
423
424
|
- `SchemaProperty.local?: boolean`:
|
package/dst/specbook-link.d.ts
CHANGED
|
@@ -8,6 +8,7 @@ interface LinkNode {
|
|
|
8
8
|
name: string;
|
|
9
9
|
}
|
|
10
10
|
export type LinkIndex = LinkNode[];
|
|
11
|
+
export declare const assignId: (index: LinkIndex, object: SpecObject, id: string) => void;
|
|
11
12
|
export declare const chainOf: (index: LinkIndex, object: SpecObject) => SpecObject[];
|
|
12
13
|
export interface LinkTarget {
|
|
13
14
|
target?: SpecObject;
|
package/dst/specbook-link.js
CHANGED
|
@@ -34,6 +34,13 @@ const lookup = (index) => {
|
|
|
34
34
|
}
|
|
35
35
|
return result;
|
|
36
36
|
};
|
|
37
|
+
/* assign the id of an indexed object, dropping the lookup structures
|
|
38
|
+
of its index (and with them the memoized resolutions), as they are
|
|
39
|
+
keyed on the ids as of their build and would freeze the old one */
|
|
40
|
+
export const assignId = (index, object, id) => {
|
|
41
|
+
object.id = id;
|
|
42
|
+
lookups.delete(index);
|
|
43
|
+
};
|
|
37
44
|
/* the ancestor-or-self chain of an object, root first
|
|
38
45
|
(empty for an object unknown to the index) */
|
|
39
46
|
export const chainOf = (index, object) => {
|
|
@@ -1,7 +1,8 @@
|
|
|
1
|
-
import { type Spec, type SpecObject } from "./specbook-format-spec.js";
|
|
1
|
+
import { type Spec, type SpecObject, type SpecProperty } from "./specbook-format-spec.js";
|
|
2
2
|
import { type Schema, type SchemaObject } from "./specbook-format-schema.js";
|
|
3
3
|
import { ParseContext } from "./specbook-parse-common.js";
|
|
4
4
|
export declare const resolveArtifact: (config: Schema, object: SpecObject) => SchemaObject | undefined;
|
|
5
5
|
export declare const collectSchemas: (specification: Spec, config: Schema) => Map<SpecObject, SchemaObject>;
|
|
6
|
+
export declare const collectParenProps: (schemas: Map<SpecObject, SchemaObject>) => Map<SpecObject, SpecProperty>;
|
|
6
7
|
export declare const validate: (ctx: ParseContext, specification: Spec, config: Schema) => void;
|
|
7
8
|
export declare const validateReferences: (ctx: ParseContext, specification: Spec) => void;
|
|
@@ -3,7 +3,7 @@
|
|
|
3
3
|
** Copyright (c) 2026 Dr. Ralf S. Engelschall <rse@engelschall.com>
|
|
4
4
|
** Licensed under Apache 2.0 <https://spdx.org/licenses/Apache-2.0>
|
|
5
5
|
*/
|
|
6
|
-
import { referenceRegex, resolveUnique, resolveSet, chainOf, plainText } from "./specbook-link.js";
|
|
6
|
+
import { referenceRegex, resolveUnique, resolveSet, chainOf, plainText, assignId } from "./specbook-link.js";
|
|
7
7
|
import { compileValueExpr, splitItems, anchored } from "./specbook-parse-value.js";
|
|
8
8
|
/* the syntax of a value consisting of exactly one Wiki-style reference */
|
|
9
9
|
const singleReferenceRegex = /^\[\[([^[\]]+)\]\]$/;
|
|
@@ -112,21 +112,23 @@ const checkPropValue = (ctx, object, prop, expr, property, meta) => {
|
|
|
112
112
|
`property "${prop.name}" value "${property.value}" is not a member of "${prop.value}"` :
|
|
113
113
|
`property "${prop.name}" value "${property.value}" does not match pattern "${prop.value}"`);
|
|
114
114
|
};
|
|
115
|
-
/*
|
|
116
|
-
|
|
115
|
+
/* derive the synthetic property the trailing parenthesized name token
|
|
116
|
+
of an object is consumed as: the first still missing configured
|
|
117
117
|
property whose pattern the token matches */
|
|
118
118
|
const parenProp = (object, props) => {
|
|
119
119
|
const paren = object.paren;
|
|
120
120
|
if (paren === undefined)
|
|
121
121
|
return undefined;
|
|
122
|
-
|
|
123
|
-
&& directMatches(compileValueExpr(
|
|
122
|
+
const prop = props.find((p) => p.value !== undefined && !object.properties.some((q) => q.key === p.name)
|
|
123
|
+
&& directMatches(compileValueExpr(p.value), paren));
|
|
124
|
+
return prop !== undefined ? { key: prop.name, value: paren } : undefined;
|
|
124
125
|
};
|
|
125
126
|
/* check the property values flagged unique for distinctness among
|
|
126
127
|
the sibling objects of the same kind and the ones flagged present
|
|
127
|
-
for occurring on at least one
|
|
128
|
-
values
|
|
129
|
-
|
|
128
|
+
for occurring on at least one sibling (all values, or only the
|
|
129
|
+
values matching the regexp or enum expression of the flag), where
|
|
130
|
+
a kind without any sibling fails a present flag, too, but only for
|
|
131
|
+
an optional kind, as the missing-kind check reports a required one */
|
|
130
132
|
const checkSiblingFlags = (ctx, object, childs, meta) => {
|
|
131
133
|
const marker = (flag) => flag === undefined || flag === false ? undefined :
|
|
132
134
|
{ filter: typeof flag === "string" ? compileValueExpr(flag) : undefined };
|
|
@@ -157,7 +159,7 @@ const checkSiblingFlags = (ctx, object, childs, meta) => {
|
|
|
157
159
|
`already used by preceding ${sibling.kind} "${first.name}"`);
|
|
158
160
|
}
|
|
159
161
|
}
|
|
160
|
-
if (present !== undefined && siblings.length > 0 && !found)
|
|
162
|
+
if (present !== undefined && (siblings.length > 0 || child.optional === true) && !found)
|
|
161
163
|
ctx.diagnose(meta.file, meta.line, `no ${child.kind} below ${object.kind} "${object.name}" carries property "${prop.name}"` +
|
|
162
164
|
(typeof prop.present === "string" ? ` with a value matching "${prop.present}"` : ""));
|
|
163
165
|
}
|
|
@@ -255,6 +257,19 @@ export const collectSchemas = (specification, config) => {
|
|
|
255
257
|
}
|
|
256
258
|
return schemas;
|
|
257
259
|
};
|
|
260
|
+
/* derive the synthetic properties supplied by the consumed parenthesized
|
|
261
|
+
name tokens of a schema mapping, so a consumer without a parsing
|
|
262
|
+
context -- the diagram derivation of an export -- sees exactly the
|
|
263
|
+
properties the validation sees */
|
|
264
|
+
export const collectParenProps = (schemas) => {
|
|
265
|
+
const parenProps = new Map();
|
|
266
|
+
for (const [object, schema] of schemas) {
|
|
267
|
+
const property = parenProp(object, schema.props ?? []);
|
|
268
|
+
if (property !== undefined)
|
|
269
|
+
parenProps.set(object, property);
|
|
270
|
+
}
|
|
271
|
+
return parenProps;
|
|
272
|
+
};
|
|
258
273
|
/* report the objects colliding on kind and id among their siblings
|
|
259
274
|
(recursively from the artifacts down), as such objects derive
|
|
260
275
|
identical anchor paths and hence cannot be addressed distinctly */
|
|
@@ -469,19 +484,16 @@ export const validate = (ctx, specification, config) => {
|
|
|
469
484
|
precedence), which has to happen before any property check, as
|
|
470
485
|
those resolve references by id across all artifacts */
|
|
471
486
|
const schemas = collectSchemas(specification, config);
|
|
487
|
+
for (const [object, property] of collectParenProps(schemas))
|
|
488
|
+
ctx.parenProps.set(object, property);
|
|
472
489
|
const promote = (object) => {
|
|
473
490
|
/* an object below an unresolved artifact or an unknown kind
|
|
474
491
|
carries no schema and hence no property able to consume the
|
|
475
492
|
token, so the token still acts as the id and a single
|
|
476
493
|
unresolved heading no longer cascades into unresolvable
|
|
477
494
|
references all over the corpus */
|
|
478
|
-
if (object.paren !== undefined)
|
|
479
|
-
|
|
480
|
-
if (prop !== undefined)
|
|
481
|
-
ctx.parenProps.set(object, { key: prop.name, value: object.paren });
|
|
482
|
-
else if (object.anchor === undefined)
|
|
483
|
-
object.id = object.paren;
|
|
484
|
-
}
|
|
495
|
+
if (object.paren !== undefined && object.anchor === undefined && !ctx.parenProps.has(object))
|
|
496
|
+
assignId(ctx.linkIndex, object, object.paren);
|
|
485
497
|
object.childs.forEach(promote);
|
|
486
498
|
};
|
|
487
499
|
for (const artifact of specification.artifacts)
|
|
@@ -201,13 +201,15 @@ const parseConcise = (ctx, item, parent, file, line, group) => {
|
|
|
201
201
|
let kind;
|
|
202
202
|
let name;
|
|
203
203
|
if (group !== undefined) {
|
|
204
|
-
const nm = head.match(/^`([^`]+)
|
|
204
|
+
const nm = head.match(/^`([^`]+)`\s*([^`]*)$|^([^`:]+)$/);
|
|
205
205
|
if (nm === null) {
|
|
206
206
|
ctx.diagnose(file, line, `unrecognized ${group} item "${text}"`);
|
|
207
207
|
return;
|
|
208
208
|
}
|
|
209
209
|
kind = group;
|
|
210
|
-
name
|
|
210
|
+
/* a backquoted name keeps its trailing decorations outside the
|
|
211
|
+
backquotes, so they are re-joined for the marker detection below */
|
|
212
|
+
name = (nm[1] !== undefined ? `${nm[1]} ${nm[2]}` : nm[3]).trim();
|
|
211
213
|
}
|
|
212
214
|
else {
|
|
213
215
|
const km = head.match(/^([^:]+):\s*(.*)$/);
|