@rse/specbook 1.3.0 → 1.3.1

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