@rse/specbook 1.2.2 → 1.2.4

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
@@ -6,6 +6,9 @@ SpecBook
6
6
 
7
7
  **Markdown-based Specification Format**
8
8
 
9
+ [![github (author stars)](https://img.shields.io/github/stars/rse?logo=github&label=author%20stars&color=%233377aa)](https://github.com/rse)
10
+ [![github (author followers)](https://img.shields.io/github/followers/rse?label=author%20followers&logo=github&color=%234477aa)](https://github.com/rse)
11
+
9
12
  About
10
13
  -----
11
14
 
@@ -43,10 +46,15 @@ CLI with commands `specbook <xxx>`, and an MCP service with tools
43
46
  - **Strict Validation with Custom Schema**:
44
47
  The YAML schema configuration strictly defines the allowed object
45
48
  kinds, hierarchies, and properties, whose values are constrained by an
46
- expression language (regex, enum, tags, list, and reference). Violations
47
- are reported as file- and line-precise diagnostics, while the reference
48
- coverage an object kind demands is reported as a warning only. A bundled
49
- standard schema configuration applies if no particular one is given.
49
+ expression language (regex, enum, tags, list, and reference). Beyond the
50
+ plain values, it constrains the uniqueness and presence of a property
51
+ among the sibling objects and the shape of a reference-valued property
52
+ (local, symmetric, and/or acyclic), and it can declare the child objects
53
+ of an object kind a finite state machine, whose reachability, dead-ends,
54
+ and livelocks are then checked. Violations are reported as file- and
55
+ line-precise diagnostics, while the reference coverage an object kind
56
+ demands is reported as a warning only. A bundled standard schema
57
+ configuration applies if no particular one is given.
50
58
 
51
59
  - **Object Model Diagram Visualisation**:
52
60
  Object kinds can declare "graph", "hub", or "grid" diagrams in the
@@ -63,25 +71,39 @@ CLI with commands `specbook <xxx>`, and an MCP service with tools
63
71
 
64
72
  - **AST Exports for AI/LLMs**:
65
73
  The parsed specification Abstract Syntax Tree (AST) can be exported in
66
- JSON, JSON5, YAML, or TOON format for machine consumption. Together
67
- with the `describe` command, which explains the models and formats,
68
- this enables LLMs to both read and write specifications.
74
+ JSON, JSON5, YAML, or TOON format for machine consumption, with the
75
+ derived diagram of an object attached to it as a textual Gradia spec.
76
+ Together with the `describe` command, which explains the models and
77
+ formats, this enables LLMs to both read and write specifications.
69
78
 
70
79
  - **HTML Export for Developers**:
71
- The HTML export is a self-contained single document with a
72
- table-of-contents (also as a slide-in side panel for quick navigation),
73
- full-text search, embedded images, and a light/dark
74
- theme toggle. It is intended for the day-to-day online reading during
75
- development. The HTML export can map all object kinds to nested
76
- sections or compact tables, or an automatic mixture which collapses
77
- only the deepest level into tables. The HTML export even can be
78
- previewed in the browser with live updates.
80
+ The HTML export is a self-contained single document with a title page,
81
+ a table-of-contents (also as a slide-in side panel for quick navigation),
82
+ a diagram-of-contents, a fuzzy full-text search, embedded images, a
83
+ scroll progress meter (which also scrolls back to the top on click),
84
+ description popups (which show the schema and corpus description of
85
+ an object kind, property, or object instance on hover), and a
86
+ light/dark theme toggle. It is intended for the day-to-day online
87
+ reading during development. The HTML export can map all object kinds
88
+ to nested sections or compact tables, or an automatic mixture which
89
+ collapses only the deepest level into tables. The HTML export even can
90
+ be previewed in the browser with live updates.
79
91
 
80
92
  - **PDF Export for Customers**:
81
93
  The PDF export prints the HTML rendering via Chromium and post-processes
82
- it with page numbers, headers/footers, and a hierarchical PDF outline.
83
- It is intended as a polished, paginated offline document for handing
84
- over to customers.
94
+ it with page numbers, headers/footers, a brand bar, and a hierarchical
95
+ PDF outline. The paper size (A4, Letter, or Legal) drives the pagination
96
+ and scales the diagrams down to fit onto a single page. It is intended
97
+ as a polished, paginated offline document for handing over to customers.
98
+
99
+ - **Document Theming and Typography**:
100
+ The title object of the specification drives the entire document:
101
+ its title, subtitle, author, version, and logo fill the title page
102
+ (with an optional light/dark variant of the logo), its language
103
+ selects the smart typography quote style, its color tone seeds the
104
+ theme color spreads of both the light and the dark theme, and its
105
+ character set subsets the embedded fonts down to the actually needed
106
+ glyphs.
85
107
 
86
108
  Overview
87
109
  --------
@@ -100,170 +122,6 @@ $ npm install -g @rse/specbook
100
122
  Usage
101
123
  -----
102
124
 
103
- ### API
104
-
105
- ```ts
106
- /* the SpecBook API, providing all commands the CLI and MCP service expose */
107
- export class SpecBook {
108
- constructor (options?: SpecBookOptions)
109
-
110
- /* initialize the configured specification artifact files below the
111
- base directory and return the list of the generated files */
112
- init (options: {
113
- config?: string[], /* YAML schema configuration files or glob patterns, merged
114
- in order (default: bundled standard one, named "std") */
115
- basedir?: string /* base directory (default: ".") */
116
- }): Promise<string[]>
117
-
118
- /* parse and validate the specification Markdown files below the base directory */
119
- lint (options: {
120
- config?: string[],
121
- basedir?: string
122
- }): Promise<LintResult>
123
-
124
- /* export the specification, parsing the input just once and
125
- returning one buffer per requested format (strict: any error
126
- diagnostic rejects the export with an "Error", while the warnings
127
- are surfaced as "notice" verbose messages) */
128
- export (options: {
129
- config?: string[],
130
- basedir?: string,
131
- formats?: ExportFormat[], /* requested output formats (default: [ "json" ]) */
132
- realtime?: boolean /* inject the live preview script into the HTML */
133
- }): Promise<Buffer[]>
134
-
135
- /* export the specification like "export" and then keep it in sync by
136
- re-exporting it on every change of a specification file (a failed
137
- re-export is reported and leaves the observe loop intact) */
138
- watch (options: {
139
- config?: string[],
140
- basedir?: string,
141
- formats?: ExportFormat[],
142
- realtime?: boolean,
143
- outputs?: string[], /* paths "onExport" writes, refused if observed */
144
- onExport: (buffers: Buffer[]) => void | Promise<void>
145
- }): Promise<void>
146
-
147
- /* serve the HTML export as a live preview on "http://<addr>:<port>/",
148
- kept in sync like "watch" and updated in the browser on every change */
149
- preview (options: {
150
- config?: string[],
151
- basedir?: string,
152
- addr?: string, /* listening IP address (default: "127.0.0.1") */
153
- port?: number /* listening TCP port (default: 12345) */
154
- }): Promise<void>
155
-
156
- /* describe the SpecBook models and formats, optionally pointing to
157
- the artifacts of the particular project */
158
- describe (options: {
159
- config?: string[],
160
- basedir?: string,
161
- embed?: boolean, /* embed the schema configuration instead of referencing it */
162
- compress?: CompressLevel, /* compression level of the schema configuration (default: 1) */
163
- format?: DescribeFormat, /* output format (default: "md") */
164
- part?: DescribePart /* document part (default: "all") */
165
- }): Promise<string>
166
- }
167
-
168
- /* the constructor options and the sink of the verbose messages,
169
- receiving the emitting command and the message */
170
- export interface SpecBookOptions {
171
- verbose?: VerboseSink
172
- }
173
- export type VerboseSink = (cmd: string, msg: string) => void
174
-
175
- /* the result of the "lint" command and its file- and line-precise diagnostics */
176
- export interface LintResult {
177
- specification: Spec
178
- diagnostics: Diagnostic[]
179
- config?: Schema
180
- files: string[] /* the artifact files plus their embedded assets */
181
- }
182
- export interface Diagnostic {
183
- file: string
184
- line: number
185
- column: number
186
- severity: "error" | "warning"
187
- message: string
188
- }
189
-
190
- /* the supported export formats and describe formats/parts/compression levels */
191
- export const formats: readonly [ "json", "json5", "yaml", "toon", "html", "pdf", "md" ]
192
- export const describeFormats: readonly [ "md", "raw" ]
193
- export const describeParts: readonly [ "all", "meta", "schema", "spec" ]
194
- export const compressLevels: readonly [ 0, 1, 2, 3 ]
195
- export type ExportFormat = typeof formats[number]
196
- export type DescribeFormat = typeof describeFormats[number]
197
- export type DescribePart = typeof describeParts[number]
198
- export type CompressLevel = typeof compressLevels[number]
199
-
200
- /* the Abstract Syntax Tree (AST) of a parsed specification */
201
- export type Spec = {
202
- artifacts: SpecArtifact[]
203
- }
204
- export type SpecArtifact = {
205
- created: Date
206
- modified: Date
207
- objects: SpecObject[]
208
- }
209
- export type SpecObject = {
210
- kind: string
211
- id: string
212
- anchor?: string
213
- paren?: string
214
- name: string
215
- primary?: boolean
216
- description?: SpecDescription
217
- properties: SpecProperty[]
218
- childs: SpecObject[]
219
- }
220
- export type SpecDescription = {
221
- description: string
222
- rationale?: string
223
- embedding?: string[]
224
- }
225
- export type SpecProperty = {
226
- key: string
227
- value: string
228
- embedding?: string[]
229
- }
230
-
231
- /* the YAML schema configuration, where the nested "diagram" and
232
- "format" structures are detailed in the "describe" output */
233
- export type Schema = SchemaObject[]
234
- export type SchemaObject = {
235
- kind: string
236
- name?: string
237
- id?: string
238
- file?: string
239
- desc?: string
240
- optional?: boolean
241
- diagram?: SchemaDiagram
242
- format?: SchemaFormat
243
- props?: SchemaProperty[]
244
- childs?: SchemaObject[]
245
- }
246
- export type SchemaProperty = {
247
- name: string
248
- desc?: string
249
- value?: string
250
- optional?: boolean
251
- }
252
- ```
253
-
254
- Example:
255
-
256
- ```ts
257
- import { SpecBook } from "@rse/specbook"
258
-
259
- const specbook = new SpecBook({
260
- verbose: (cmd, msg) => console.error(`specbook: ${cmd}: ${msg}`)
261
- })
262
- const result = await specbook.lint({
263
- basedir: "smp/broadcast"
264
- })
265
- ```
266
-
267
125
  ### CLI
268
126
 
269
127
  Commands:
@@ -353,13 +211,14 @@ Options:
353
211
 
354
212
  - `-w|--watch` (`export` only):
355
213
  Keep the outputs in sync with their sources: after the regular export,
356
- the referenced artifact files and all assets they embed are observed,
357
- and every change re-exports the specification once the sources stayed
358
- silent for one second. A failed re-export is reported and leaves the
359
- observe loop intact, so a transiently invalid specification does not
360
- end the watch. The outputs have to be regular files, as `-` (stdout)
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
214
+ the YAML schema configuration files, the referenced artifact files, and
215
+ all assets they embed are observed, and every change re-exports the
216
+ specification once the sources stayed silent for one second. A failed
217
+ re-export is reported and leaves the observe loop intact, so a
218
+ transiently invalid specification (or configuration) does not end the
219
+ watch. The outputs have to be regular files, as `-` (stdout) cannot
220
+ receive a repeated export, and none of them may be an observed source
221
+ file itself, as its own write would re-trigger the observation
363
222
  endlessly.
364
223
 
365
224
  - `-a|--addr <ip-addr>`, `-p|--port <tcp-port>` (`preview` only):
@@ -368,11 +227,13 @@ Options:
368
227
  `http://<ip-addr>:<tcp-port>/`, kept in sync with its sources exactly
369
228
  like `export --watch`, and updated in the browser after every change
370
229
  through a WebSocket connection the served page keeps open, where the
371
- document is replaced in place, so the scroll position survives (a
372
- failed re-export keeps the previous HTML in place). A status icon
373
- left of the theme switcher shows the connection state: grey while
374
- connected, in the search highlight color while disconnected, and
375
- blinking for 2s after every update.
230
+ document is replaced in place, so the scroll position and the theme
231
+ choice survive (a failed re-export keeps the previous HTML in place,
232
+ and a request before the first successful export is answered with a
233
+ placeholder page which replaces itself with the document once that
234
+ export arrives). A status tab at the bottom of the brand bar shows
235
+ the connection state: its plug icon carries the search highlight
236
+ color while disconnected and blinks for 2s after every update.
376
237
 
377
238
  - `-o|--output <output-file>` (`describe` only):
378
239
  The output file (default: `-` for stdout) receives the described
@@ -412,26 +273,74 @@ explicitly supplied option always wins. As `-c|--config` is repeatable,
412
273
  `SPECBOOK_CONFIG` carries a list of patterns separated by the path
413
274
  delimiter of the platform (`:` on Unix, `;` on Windows).
414
275
 
415
- Example:
276
+ Example: Simple
277
+ ---------------
278
+
279
+ Check out the *simple* specification of simple
280
+ example data model:
281
+
282
+ ### Sources
283
+
284
+ - [Specification](smp/sample/sample.md)
285
+ - [Schema](smp/sample/sample.yaml)
286
+
287
+ ### Generation
416
288
 
417
289
  ```bash
418
- $ specbook lint -v -b smp/broadcast
290
+ $ specbook lint -v \
291
+ -b smp/sample \
292
+ -o smp/sample/sample.html \
293
+ -o smp/sample/sample.pdf
419
294
  ```
420
295
 
421
- Rendering Examples
422
- ------------------
296
+ ### HTML Rendering
297
+
298
+ ![screenshot-sample-light](etc/screenshot-sample-light.png)
299
+ ![screenshot-sample-dark](etc/screenshot-sample-dark.png)
423
300
 
424
- ### HTML Rendering (Light Theme)
301
+ Example: Complex
302
+ ----------------
425
303
 
426
- ![screenshot-light](etc/screenshot-light.png)
304
+ Check out the *complex* specification of the Broadcast
305
+ application, based on **SpecBook**'s built-in "standard" schema:
427
306
 
428
- ### HTML Rendering (Dark Theme)
307
+ ### Sources
429
308
 
430
- ![screenshot-dark](etc/screenshot-dark.png)
309
+ - [Specification](smp/broadcast/)
310
+ - [Schema](src/specbook-format.d/)
311
+
312
+ ### Generation
313
+
314
+ ```bash
315
+ $ specbook lint -v \
316
+ -b smp/broadcast \
317
+ -o smp/broadcast/broadcast.html \
318
+ -o smp/broadcast/broadcast.pdf
319
+ ```
320
+
321
+ ### HTML Rendering
322
+
323
+ ![screenshot-broadcast-light](etc/screenshot-broadcast-light.png)
324
+ ![screenshot-broadcast-dark](etc/screenshot-broadcast-dark.png)
431
325
 
432
326
  ### PDF Rendering
433
327
 
434
- ![screenshot-print](etc/screenshot-print.png)
328
+ ![screenshot-broadcast-print](etc/screenshot-broadcast-print.png)
329
+
330
+ See Also
331
+ --------
332
+
333
+ - [Gradia](https://github.com/rse/gradia)
334
+ - [ASE](https://ase.tools)
335
+
336
+ Support
337
+ -------
338
+
339
+ **SpecBook** is developed in the experience context of industrial Software
340
+ Engineering at the [*msg group*](https://www.msg.group) and in the
341
+ educational context of the *Software Engineering Academy (SEA)*. **SpecBook**
342
+ development is supported by *msg Research* and *Software Engineering
343
+ Academy (SEA)*.
435
344
 
436
345
  License
437
346
  -------
@@ -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
@@ -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
@@ -3,7 +3,10 @@
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 { watch } from "chokidar";
6
+ /* Chokidar is aliased in "package.json", as Nunjucks declares the
7
+ (unused) Chokidar 3 as an optional peer dependency and the current
8
+ Chokidar under its plain name would raise a peer warning on install */
9
+ import { watch } from "chokidar5";
7
10
  import { minify } from "@swc/html";
8
11
  import { documentTitle, documentLogo, documentCharset, documentThemeTone, subsetStylesheet, documentPaperSize, paperStylesheet, charsetCodepoints } from "./specbook-export-common.js";
9
12
  import { themeColors, themeStylesheet, themeMapping } from "./specbook-theme.js";
@@ -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 */
@@ -10,11 +10,6 @@ import { literal } from "./specbook-verbose.js";
10
10
  /* the default listening address and port of the preview server */
11
11
  export const previewAddr = "127.0.0.1";
12
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");
18
13
  /* serve the live HTML preview: a plain GET on "/" answers with the
19
14
  most recent HTML export (or with the 503 placeholder page before the
20
15
  first successful one), while a WebSocket upgrade on "/" subscribes
@@ -24,16 +19,18 @@ export const servePreview = async (options) => {
24
19
  const clients = new Set();
25
20
  const fastify = Fastify();
26
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");
27
27
  /* answer a plain GET with the document and subscribe a WebSocket upgrade */
28
28
  fastify.route({
29
29
  method: "GET",
30
30
  url: "/",
31
31
  handler: (_request, reply) => {
32
- if (html === undefined)
33
- return reply.code(503).type("text/html; charset=utf-8")
34
- .header("cache-control", "no-store").send(placeholder);
35
- return reply.type("text/html; charset=utf-8")
36
- .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);
37
34
  },
38
35
  wsHandler: (socket, request) => {
39
36
  /* identify the client by its remote address and port */
@@ -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[];
@@ -33,7 +33,7 @@ const identityOf = (item, list) => {
33
33
  const object = item;
34
34
  if (list === "props")
35
35
  return String(object.name ?? "");
36
- else if (list === "childs")
36
+ else if (list === "children")
37
37
  return String(object.kind ?? "");
38
38
  else
39
39
  return `${String(object.kind ?? "")}:${String(object.id ?? object.name ?? "")}`;
@@ -48,14 +48,15 @@ const mergeConfig = (target, source, list) => {
48
48
  if (source === null || source === undefined)
49
49
  return target;
50
50
  else if (Array.isArray(target) && Array.isArray(source)) {
51
+ const items = target;
51
52
  for (const item of source) {
52
- const i = target.findIndex((existing) => identityOf(existing, list) === identityOf(item, list));
53
+ const i = items.findIndex((existing) => identityOf(existing, list) === identityOf(item, list));
53
54
  if (i < 0)
54
- target.push(item);
55
+ items.push(item);
55
56
  else
56
- target[i] = mergeConfig(target[i], item, list);
57
+ items[i] = mergeConfig(items[i], item, list);
57
58
  }
58
- return target;
59
+ return items;
59
60
  }
60
61
  else if (isPlainObject(target) && isPlainObject(source))
61
62
  return mergeWith(target, source, (t, s, key) => Array.isArray(t) && Array.isArray(s) ? mergeConfig(t, s, key) : undefined);
@@ -90,7 +91,10 @@ const posOfMergedPath = (docs, merged, path) => {
90
91
  for (const segment of path) {
91
92
  const key = typeof segment === "number" && Array.isArray(own) ?
92
93
  own.findIndex((item) => identityOf(item, list) === identityOf(at(node, segment), list)) : segment;
93
- 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)
94
98
  break;
95
99
  translated.push(key);
96
100
  node = at(node, segment);
@@ -154,7 +158,7 @@ const checkAutomaton = (object, at, diagnose) => {
154
158
  if (object.automaton === undefined)
155
159
  return;
156
160
  const automaton = object.automaton;
157
- const childOf = (kind) => (object.childs ?? []).find((c) => c.kind === kind);
161
+ const childOf = (kind) => (object.children ?? []).find((c) => c.kind === kind);
158
162
  const propOf = (child, name) => (child?.props ?? []).find((p) => p.name === name);
159
163
  /* an invalid value expression is reported on its own (see
160
164
  "checkProperties"), so it must not cascade into a shape diagnostic */
@@ -203,7 +207,7 @@ const checkConstraints = (config, posOfPath) => {
203
207
  const seen = new Set();
204
208
  for (const [i, object] of objects.entries()) {
205
209
  const at = [...path, i];
206
- const ident = identityOf(object, depth > 1 ? "childs" : undefined);
210
+ const ident = identityOf(object, depth > 1 ? "children" : undefined);
207
211
  if (seen.has(ident))
208
212
  diagnose([...at, "kind"], `object "${ident}" collides with a preceding sibling object (the later one is unreachable)`);
209
213
  else
@@ -235,13 +239,32 @@ const checkConstraints = (config, posOfPath) => {
235
239
  (err instanceof Error ? err.message : String(err)));
236
240
  }
237
241
  }
238
- if (object.childs !== undefined)
239
- check(object.childs, [...at, "childs"], depth + 1);
242
+ if (object.children !== undefined)
243
+ check(object.children, [...at, "children"], depth + 1);
240
244
  }
241
245
  };
242
246
  check(config, [], 1);
243
247
  return diagnostics;
244
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.children ?? [])
262
+ walk(child);
263
+ };
264
+ for (const object of config)
265
+ walk(object);
266
+ return { objects, links };
267
+ };
245
268
  /* load and validate the YAML schema configuration, merged in order out
246
269
  of its files (the later ones into the earlier ones), where every file
247
270
  has to be readable and syntactically valid on its own, while the
@@ -282,10 +305,10 @@ export const loadConfig = (files) => {
282
305
  }
283
306
  if (diagnostics.length > 0)
284
307
  return { diagnostics };
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()))
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))
289
312
  .reduce((target, source) => mergeConfig(target, source));
290
313
  /* semantically validate the merged result against the schema of the configuration */
291
314
  const posOfPath = (path) => posOfMergedPath(docs, merged, path);