@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 +112 -203
- package/dst/specbook-api.js +7 -3
- package/dst/specbook-cmd-describe.js +2 -2
- package/dst/specbook-cmd-export.js +4 -1
- package/dst/specbook-cmd-lint.js +10 -2
- package/dst/specbook-cmd-preview.js +7 -10
- package/dst/specbook-config.d.ts +4 -0
- package/dst/specbook-config.js +37 -14
- package/dst/specbook-diagram.js +16 -10
- package/dst/specbook-export-ast.js +1 -1
- package/dst/specbook-export-common.js +34 -23
- package/dst/specbook-export-html.css +62 -0
- package/dst/specbook-export-html.d.ts +1 -1
- package/dst/specbook-export-html.js +145 -82
- package/dst/specbook-export-md.js +17 -15
- package/dst/specbook-export-pdf.js +11 -9
- package/dst/specbook-format-schema.d.ts +1 -1
- package/dst/specbook-format-schema.js +3 -1
- package/dst/specbook-format-spec.d.ts +1 -1
- package/dst/specbook-format-spec.js +3 -1
- package/dst/specbook-format.md +4 -4
- package/dst/specbook-format.yaml +240 -256
- package/dst/specbook-link.js +6 -6
- package/dst/specbook-parse-common.d.ts +1 -0
- package/dst/specbook-parse-common.js +5 -0
- package/dst/specbook-parse-semantic.js +31 -31
- package/dst/specbook-parse-syntax.js +21 -26
- package/dst/specbook-parse.d.ts +5 -0
- package/dst/specbook-parse.js +20 -1
- package/package.json +4 -4
package/README.md
CHANGED
|
@@ -6,6 +6,9 @@ SpecBook
|
|
|
6
6
|
|
|
7
7
|
**Markdown-based Specification Format**
|
|
8
8
|
|
|
9
|
+
[](https://github.com/rse)
|
|
10
|
+
[](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).
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
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
|
|
67
|
-
|
|
68
|
-
|
|
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,
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
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
|
|
83
|
-
|
|
84
|
-
|
|
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
|
|
357
|
-
and every change re-exports the
|
|
358
|
-
silent for one second. A failed
|
|
359
|
-
observe loop intact, so a
|
|
360
|
-
|
|
361
|
-
|
|
362
|
-
|
|
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
|
|
372
|
-
failed re-export keeps the previous HTML in place
|
|
373
|
-
|
|
374
|
-
|
|
375
|
-
|
|
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
|
|
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
|
|
422
|
-
|
|
296
|
+
### HTML Rendering
|
|
297
|
+
|
|
298
|
+

|
|
299
|
+

|
|
423
300
|
|
|
424
|
-
|
|
301
|
+
Example: Complex
|
|
302
|
+
----------------
|
|
425
303
|
|
|
426
|
-
|
|
304
|
+
Check out the *complex* specification of the Broadcast
|
|
305
|
+
application, based on **SpecBook**'s built-in "standard" schema:
|
|
427
306
|
|
|
428
|
-
###
|
|
307
|
+
### Sources
|
|
429
308
|
|
|
430
|
-
|
|
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
|
+

|
|
324
|
+

|
|
431
325
|
|
|
432
326
|
### PDF Rendering
|
|
433
327
|
|
|
434
|
-

|
|
328
|
+

|
|
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
|
-------
|
package/dst/specbook-api.js
CHANGED
|
@@ -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
|
-
|
|
116
|
-
basedir: options.basedir ?? ".", verbose })
|
|
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
|
|
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
|
-
`)
|
|
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
|
-
|
|
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";
|
package/dst/specbook-cmd-lint.js
CHANGED
|
@@ -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
|
-
|
|
33
|
-
|
|
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 */
|
package/dst/specbook-config.d.ts
CHANGED
|
@@ -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[];
|
package/dst/specbook-config.js
CHANGED
|
@@ -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 === "
|
|
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 =
|
|
53
|
+
const i = items.findIndex((existing) => identityOf(existing, list) === identityOf(item, list));
|
|
53
54
|
if (i < 0)
|
|
54
|
-
|
|
55
|
+
items.push(item);
|
|
55
56
|
else
|
|
56
|
-
|
|
57
|
+
items[i] = mergeConfig(items[i], item, list);
|
|
57
58
|
}
|
|
58
|
-
return
|
|
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
|
-
|
|
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.
|
|
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 ? "
|
|
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.
|
|
239
|
-
check(object.
|
|
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
|
|
286
|
-
conversions, as the merge mutates its target while the raw
|
|
287
|
-
have to stay intact for the path translation */
|
|
288
|
-
const merged = docs.map(({
|
|
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);
|