@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 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`)
@@ -47,6 +47,7 @@ export declare class SpecBook {
47
47
  basedir?: string;
48
48
  formats?: ExportFormat[];
49
49
  realtime?: boolean;
50
+ outputs?: string[];
50
51
  onExport: (buffers: Buffer[]) => void | Promise<void>;
51
52
  }): Promise<void>;
52
53
  preview(options: {
@@ -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 [...config.map((file) => path.resolve(file)), ...result.files];
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"];
@@ -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 first successful
14
- one), while a WebSocket upgrade on "/" subscribes the client to the
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/plain; charset=utf-8")
28
- .send("no specification export available yet\n");
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 answers into the document */
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;
@@ -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
- if (Array.isArray(target) && Array.isArray(source)) {
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 conversions, as the
271
- merge mutates its target while the raw ones have to stay intact
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);
@@ -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 = node.properties.find((property) => property.key === key)?.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.properties.map((property) => ({
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
- edgeObject.properties.find((property) => property.key === key)?.value :
152
- edgeObject.properties.filter((property) => property.key !== diagram.edgeSource)
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 = edgeObject.properties.find((property) => property.key === (diagram.edgeArity ?? "ARITY"))?.value;
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 = source.properties.find((property) => property.key === centerCfg.property)?.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 = node.properties.find((property) => property.key === cfg.property)?.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
- node.properties.find((property) => property.key === cfg.labeled)?.value : undefined;
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
- /* the "collapse" handling (enabled by default) silently omits a
423
- degenerate diagram, consisting of a single node only, as such a
424
- diagram carries no information beyond the object itself */
425
- if (diagram.collapse !== false && nodes.length === 1 && edges.length === 0)
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 collectSchemas(specification, config))
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
- (function () {
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 three
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
- for (let i = 0; i < 3; i++) {
211
+ let stable = false;
212
+ for (let i = 0; i < tocPasses && !stable; i++) {
208
213
  const next = await renderHtmlPass(await anchorPages(plain));
209
- if (next === html)
210
- break;
211
- html = next;
212
- plain = await renderPdf(html);
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,
@@ -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 (existing) sibling
420
- objects of the kind, so that `unique` plus `present` demand exactly one,
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`:
@@ -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;
@@ -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
- /* find the configured property whose value the trailing parenthesized
116
- name token of an object is accepted for: the first still missing
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
- return props.find((prop) => prop.value !== undefined && !object.properties.some((p) => p.key === prop.name)
123
- && directMatches(compileValueExpr(prop.value), paren));
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 of the (existing) siblings (all
128
- values, or only the values matching the regexp or enum expression
129
- of the flag) */
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
- const prop = parenProp(object, schemas.get(object)?.props ?? []);
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 = (nm[1] ?? nm[2]).trim();
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*(.*)$/);
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@rse/specbook",
3
- "version": "1.2.1",
3
+ "version": "1.2.2",
4
4
  "publishConfig": { "access": "public" },
5
5
  "description": "Markdown-based Specification Format (API, CLI and MCP service)",
6
6
  "license": "Apache-2.0",