@rse/specbook 1.2.1 → 1.2.3

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