@rse/specbook 0.9.2 → 0.9.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 +195 -14
- package/dst/specbook-api.d.ts +1 -1
- package/dst/specbook-api.js +11 -2
- package/dst/specbook-cli.js +15 -16
- package/dst/specbook-cmd-describe.js +3 -1
- package/dst/specbook-cmd-edit.js +6 -1
- package/dst/specbook-cmd-export.d.ts +1 -1
- package/dst/specbook-cmd-export.js +6 -5
- package/dst/specbook-cmd-import.js +6 -1
- package/dst/specbook-cmd-init.js +4 -2
- package/dst/specbook-cmd-lint.js +4 -2
- package/dst/specbook-config.js +22 -7
- package/dst/specbook-diagram.js +67 -43
- package/dst/specbook-export-ast.js +5 -2
- package/dst/specbook-export-common.d.ts +0 -1
- package/dst/specbook-export-common.js +32 -19
- package/dst/specbook-export-html-search.js +6 -4
- package/dst/specbook-export-html.css +21 -0
- package/dst/specbook-export-html.d.ts +2 -2
- package/dst/specbook-export-html.js +218 -70
- package/dst/specbook-export-md.js +10 -6
- package/dst/specbook-export-pdf.js +4 -3
- package/dst/specbook-llm.js +18 -28
- package/dst/specbook-mcp.js +4 -6
- package/dst/specbook-parse-semantic.js +73 -58
- package/dst/specbook-parse-syntax.js +17 -16
- package/dst/specbook-parse-value.js +7 -1
- package/dst/specbook-parse.js +4 -0
- package/dst/specbook-struct-schema.d.ts +7 -0
- package/dst/specbook-struct-schema.js +10 -3
- package/dst/specbook-theme.js +1 -1
- package/package.json +4 -4
package/README.md
CHANGED
|
@@ -33,18 +33,46 @@ service with tools `specbook_<cmd>()`.
|
|
|
33
33
|
Specification Format
|
|
34
34
|
--------------------
|
|
35
35
|
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
36
|
+
### Artifact Files
|
|
37
|
+
|
|
38
|
+
A specification consists of Markdown *artifact* files, which are
|
|
39
|
+
scanned recursively from a base directory. Every file has to start with
|
|
40
|
+
a frontmatter block carrying the `Created:` and `Modified:` timestamps
|
|
41
|
+
(format `yyyy-LL-dd HH:mm`):
|
|
42
|
+
|
|
43
|
+
```
|
|
44
|
+
---
|
|
45
|
+
Created: 2026-06-18 10:18
|
|
46
|
+
Modified: 2026-06-18 10:18
|
|
47
|
+
---
|
|
48
|
+
```
|
|
49
|
+
|
|
50
|
+
Every level 1 heading opens a new *artifact*, so usually each file
|
|
51
|
+
carries exactly one. Both ATX headings (`# <text/>`, `## <text/>`, ...)
|
|
52
|
+
and Setext headings (`<text/>` underlined with `===` or `---`) are
|
|
53
|
+
supported.
|
|
54
|
+
|
|
55
|
+
### Object Model
|
|
56
|
+
|
|
57
|
+
An artifact carries a tree of *objects*. Every object has a mandatory
|
|
58
|
+
*kind* (e.g. `ENTITY`), a mandatory *name*, a unique anchor *id*
|
|
59
|
+
(explicitly given or derived from the name), an optional *primary*
|
|
60
|
+
marker, optional *properties* (key/value pairs), an optional
|
|
61
|
+
*description* statement with an optional rationale, and optional *child*
|
|
62
|
+
objects. Which kinds, properties, and nestings are allowed is defined by
|
|
63
|
+
the schema configuration (see below); object kinds and property keys are
|
|
64
|
+
case-sensitive and have to be written exactly as configured. Backquotes
|
|
65
|
+
in names and property values are preserved as code markup for rendering,
|
|
66
|
+
but are ignored when matching names, references, and constraints.
|
|
42
67
|
|
|
43
68
|
Three concrete syntaxes exist:
|
|
44
69
|
|
|
45
70
|
### Complex Format
|
|
46
71
|
|
|
47
|
-
Usually used on object hierarchy levels 1-3 (`#`, `##`, `###`).
|
|
72
|
+
Usually used on object hierarchy levels 1-3 (`#`, `##`, `###`). A
|
|
73
|
+
heading opens the object, an optional unordered list carries its
|
|
74
|
+
properties, and the remaining content up to the next heading is its
|
|
75
|
+
description:
|
|
48
76
|
|
|
49
77
|
```
|
|
50
78
|
# <kind/>: <name/> (<id/>)
|
|
@@ -55,16 +83,38 @@ Usually used on object hierarchy levels 1-3 (`#`, `##`, `###`).
|
|
|
55
83
|
<statement/>, BECAUSE <rationale/>.
|
|
56
84
|
```
|
|
57
85
|
|
|
86
|
+
The heading level directly reflects the nesting level: a `##` object
|
|
87
|
+
becomes a child of the preceding `#` object, a `###` object a child of
|
|
88
|
+
the preceding `##` object, etc. Skipping a level (a heading without a
|
|
89
|
+
parent object on the level above) is an error.
|
|
90
|
+
|
|
58
91
|
### Concise Format
|
|
59
92
|
|
|
60
|
-
Usually used on level 4 and deeper
|
|
61
|
-
|
|
93
|
+
Usually used on level 4 and deeper. A single unordered list item carries
|
|
94
|
+
the entire object as `;`-separated segments -- the `<kind/>: <name/>`
|
|
95
|
+
head, the `<key/>: <value/>` properties, and the trailing description --
|
|
96
|
+
and may wrap over multiple (indented) lines:
|
|
62
97
|
|
|
63
98
|
```
|
|
64
99
|
- <kind/>: <name/>; <key/>: <value/>; [...];
|
|
65
100
|
<statement/>, BECAUSE <rationale/>.
|
|
66
101
|
```
|
|
67
102
|
|
|
103
|
+
A list item is recognized as a Concise Format object (instead of a
|
|
104
|
+
property of the enclosing object) by carrying at least one `;` on its
|
|
105
|
+
first line. Every segment of the form `<key/>: <value/>` becomes a
|
|
106
|
+
property (unless it contains a ` BECAUSE `), all remaining segments are
|
|
107
|
+
joined into the description. Child objects nest as indented list items
|
|
108
|
+
below their parent item, where key/value items become properties and
|
|
109
|
+
concise items become child objects of that parent:
|
|
110
|
+
|
|
111
|
+
```
|
|
112
|
+
- <kind/>: <name/>; <key/>: <value/>;
|
|
113
|
+
<statement/>, BECAUSE <rationale/>.
|
|
114
|
+
- <kind/>: <name/>; <key/>: <value/>;
|
|
115
|
+
<statement/>, BECAUSE <rationale/>.
|
|
116
|
+
```
|
|
117
|
+
|
|
68
118
|
### Grouped Format
|
|
69
119
|
|
|
70
120
|
A heading carrying just a *kind* and no `<kind/>: <name/>` pair opens a
|
|
@@ -81,13 +131,126 @@ with the (optionally backquoted) name directly:
|
|
|
81
131
|
|
|
82
132
|
The heading kind is singularized by stripping one trailing `S`, so both
|
|
83
133
|
`### STATES` and `### STATE` group objects of kind `STATE`. The grouped
|
|
84
|
-
objects become
|
|
134
|
+
objects become children of the object the heading is nested under. An
|
|
135
|
+
unquoted name must not contain a `:` (else the item would look like a
|
|
136
|
+
key/value pair) -- backquote it in this case.
|
|
85
137
|
|
|
86
138
|
When **SpecBook** itself generates specification Markdown, it emits
|
|
87
139
|
the Complex Format on levels 1-3 and the Concise Format from level 4
|
|
88
140
|
upwards. When **SpecBook** edits existing files, it mirrors the format
|
|
89
141
|
each existing object already uses.
|
|
90
142
|
|
|
143
|
+
### Names, Anchors, and Ids
|
|
144
|
+
|
|
145
|
+
In all formats, the `<name/>` may carry trailing decorations, in any
|
|
146
|
+
order:
|
|
147
|
+
|
|
148
|
+
- `{{<id/>}}`: the explicit Wiki-style anchor, setting the object id
|
|
149
|
+
(e.g. `## ENTITY: Attendee Browser {{attendee}}`).
|
|
150
|
+
|
|
151
|
+
- `(*)`: the *primary* marker, flagging the object as primary (e.g.
|
|
152
|
+
the primary attributes of an entity).
|
|
153
|
+
|
|
154
|
+
- `(<token/>)`: a parenthesized token with three possible roles: on
|
|
155
|
+
level 1 it becomes the artifact id (e.g. `# SPEC: Data Model (DM)`);
|
|
156
|
+
on deeper levels it is either accepted as the value of a still
|
|
157
|
+
missing configured property whose constraint it matches (e.g.
|
|
158
|
+
`### SCENARIO: Successful Token Login (Main)` satisfying an
|
|
159
|
+
`enum(Main,Alternative,Exceptional)` property), or else acts as the
|
|
160
|
+
implicit anchor id.
|
|
161
|
+
|
|
162
|
+
- `<a id="<id/>"></a>`: an HTML anchor at the very end of a heading,
|
|
163
|
+
as an alternative to `{{<id/>}}` (emitted by **SpecBook** itself
|
|
164
|
+
when exporting normalized Markdown).
|
|
165
|
+
|
|
166
|
+
When no explicit id is given, the id is derived by slugifying the name
|
|
167
|
+
(lowercased, with non-alphanumeric character runs dashed). An explicit
|
|
168
|
+
`{{<id/>}}` anchor always takes precedence over a `(<token/>)` id. When
|
|
169
|
+
the schema configures a fixed `id` for an object, it has to be
|
|
170
|
+
explicitly written in the input, via either `{{<id/>}}` or `(<id/>)`.
|
|
171
|
+
|
|
172
|
+
### Properties
|
|
173
|
+
|
|
174
|
+
In the Complex Format, each property is an unordered list item of the
|
|
175
|
+
form `<key/>: <value/>`, whose value may continue on the following
|
|
176
|
+
indented lines of the item (joined with spaces). In the Concise Format,
|
|
177
|
+
each property is a `<key/>: <value/>` segment. A property key may carry
|
|
178
|
+
a trailing parenthesized annotation (e.g. `WHEN (Context)`), which is
|
|
179
|
+
kept for rendering but ignored when matching the key against the schema.
|
|
180
|
+
|
|
181
|
+
A multi-token convenience exists for pattern-constrained properties:
|
|
182
|
+
when a property value fails its (regex or enum) constraint as a whole,
|
|
183
|
+
but consists of multiple whitespace-separated tokens of which one
|
|
184
|
+
matches the property's own constraint, that token is kept as the value
|
|
185
|
+
and the remaining tokens are distributed (case-insensitively) across the
|
|
186
|
+
other still unset pattern-constrained properties of the object. Tokens
|
|
187
|
+
assignable to no property are reported as errors.
|
|
188
|
+
|
|
189
|
+
### Descriptions and Rationales
|
|
190
|
+
|
|
191
|
+
The description of a Complex Format object is all block content below
|
|
192
|
+
its heading (up to the next heading) except the property list:
|
|
193
|
+
paragraphs, blockquotes, ordered lists (e.g. scenario steps), and
|
|
194
|
+
fenced code blocks. A fenced code block
|
|
195
|
+
of language `gradia` is skipped, as it is the derived diagram which
|
|
196
|
+
**SpecBook** itself emits into exported Markdown and which must not
|
|
197
|
+
become authored content on a re-parse. In the Concise Format, the
|
|
198
|
+
description is formed by the non-property segments of the item.
|
|
199
|
+
|
|
200
|
+
A description is split into its *statement* and *rationale* at the first
|
|
201
|
+
`, BECAUSE ` (or `, **BECAUSE** `) occurrence:
|
|
202
|
+
|
|
203
|
+
```
|
|
204
|
+
The event is created and configured but not visible to attendees,
|
|
205
|
+
BECAUSE an event needs a private setup phase.
|
|
206
|
+
```
|
|
207
|
+
|
|
208
|
+
### Wiki-Style References
|
|
209
|
+
|
|
210
|
+
A `[[<reference/>]]` anywhere in a name, property value, description, or
|
|
211
|
+
rationale is a Wiki-style reference to another object, which is checked
|
|
212
|
+
for unique resolvability and rendered as a hyperlink in the HTML/PDF
|
|
213
|
+
exports. A reference is a `.`-separated path of segments, where each
|
|
214
|
+
segment matches a single object as:
|
|
215
|
+
|
|
216
|
+
- `<id/>`: the object id or explicit anchor (e.g. `[[attendee]]`)
|
|
217
|
+
- `<name/>`: the object name (e.g. `[[Attendee Browser]]`)
|
|
218
|
+
- `<kind/>:<name-or-id/>`: kind-qualified (e.g. `[[ENTITY:Event]]`)
|
|
219
|
+
- `<kind/>:*` or `*`: wildcards (for match sets, see below)
|
|
220
|
+
|
|
221
|
+
Each part can be double-quoted to allow spaces, dots, and colons inside
|
|
222
|
+
it (e.g. `[["Vue.js"]]`, whose dot would otherwise be taken as a path
|
|
223
|
+
separator). A multi-segment path matches object chains connected by
|
|
224
|
+
direct parent-to-child steps, where the leading segments up to the root
|
|
225
|
+
may be omitted (e.g. `[[FV.relay]]` matches the object `relay` directly
|
|
226
|
+
below the artifact with id `FV`).
|
|
227
|
+
|
|
228
|
+
A reference has to resolve to exactly one object: a single-segment
|
|
229
|
+
reference tries the variants (1) id/anchor, (2) name, and (3)
|
|
230
|
+
`<kind/>:<name-or-id/>` in order, where the first variant yielding any
|
|
231
|
+
matches decides; several matches are an ambiguity and zero matches an
|
|
232
|
+
unresolvable reference, both reported by the linter. Wildcard references
|
|
233
|
+
resolve into match *sets* and are used in the schema configuration to
|
|
234
|
+
constrain reference-valued properties (e.g. `[[PERSONA:*]]` or
|
|
235
|
+
`[[DM.ENTITY:*]]`).
|
|
236
|
+
|
|
237
|
+
### Image Embeddings
|
|
238
|
+
|
|
239
|
+
A Markdown image `` inside a description or property
|
|
240
|
+
value embeds a local image file, resolved relative to the artifact file:
|
|
241
|
+
SVG files are inlined as-is; PNG/JPEG files are embedded as base64
|
|
242
|
+
`data:` URLs. URLs and other file types are left untouched.
|
|
243
|
+
|
|
244
|
+
### Normalization
|
|
245
|
+
|
|
246
|
+
The semantic phase validates the parsed objects against the schema
|
|
247
|
+
configuration (unknown kinds and properties, missing required properties
|
|
248
|
+
and child kinds, and violated value constraints are reported with
|
|
249
|
+
file/line-precise diagnostics) and then normalizes the specification:
|
|
250
|
+
artifacts, child objects, and properties are stably reordered along the
|
|
251
|
+
schema definition, so the exports always present them in their
|
|
252
|
+
configured canonical order.
|
|
253
|
+
|
|
91
254
|
Schema Configuration
|
|
92
255
|
--------------------
|
|
93
256
|
|
|
@@ -110,8 +273,11 @@ object itself plus all objects below it), `center` names the object a
|
|
|
110
273
|
`hub` is projected onto (default: `self`), `onlyConnected` drops the
|
|
111
274
|
edge-less nodes of a `graph`, `collapse` (default: `true`) omits a
|
|
112
275
|
degenerated diagram consisting of a single node only, `qualified` labels
|
|
113
|
-
every node with its object kind,
|
|
114
|
-
|
|
276
|
+
every node with its object kind, `properties` lists the property names
|
|
277
|
+
whose values are attached to the nodes as key/value annotations (with
|
|
278
|
+
every `[[xxx]]` reference stripped to its target object name), and
|
|
279
|
+
`config` passes arbitrary Gradia rendering options (e.g.
|
|
280
|
+
`grid-columns-max: 5`) through to the diagram.
|
|
115
281
|
|
|
116
282
|
The edges are derived from the `[[xxx]]` references of the node objects
|
|
117
283
|
-- from their property values only, or, with `links: all`, from their
|
|
@@ -119,7 +285,7 @@ descriptions as well. As the *nesting* of the objects carries no such
|
|
|
119
285
|
reference, `hierarchy: true` additionally derives a containment edge
|
|
120
286
|
from every node object to each of its child objects which is part of the
|
|
121
287
|
node set. This is what turns a parent object into the hub of its own
|
|
122
|
-
|
|
288
|
+
children, as the `TIER` objects of the sample schema configuration
|
|
123
289
|
demonstrate:
|
|
124
290
|
|
|
125
291
|
```yaml
|
|
@@ -133,6 +299,22 @@ demonstrate:
|
|
|
133
299
|
A `grid` is edge-less by definition and hence accepts neither `edges`
|
|
134
300
|
nor `hierarchy`.
|
|
135
301
|
|
|
302
|
+
An object kind can furthermore carry a `format` field, which controls
|
|
303
|
+
how the HTML/PDF export renders the child objects of every object of
|
|
304
|
+
that kind. Its `type` selects the rendering: `complex` (nested
|
|
305
|
+
sections), `concise` (compact per-kind tables), or the default `auto`,
|
|
306
|
+
which collapses only the deepest level into tables. Below a `concise`
|
|
307
|
+
object, unconfigured children implicitly stay `concise` and render as
|
|
308
|
+
sub-tables inside the description cells, while an explicitly configured
|
|
309
|
+
`type` is always honored, even a `complex` rendering pressed into a
|
|
310
|
+
cell. `maxTableColumns` (default: `4`) bounds the columns of the compact
|
|
311
|
+
tables below the object (wider groups chunk their properties into
|
|
312
|
+
embedded per-object tables), and `withUnusedProps` (default: `false`)
|
|
313
|
+
unconditionally renders the defined but unused properties of that kind,
|
|
314
|
+
as empty property table lines or table columns. Objects collapsed into
|
|
315
|
+
tables carry no headings and hence leave the table of contents and the
|
|
316
|
+
PDF outline.
|
|
317
|
+
|
|
136
318
|
Usage
|
|
137
319
|
-----
|
|
138
320
|
|
|
@@ -142,7 +324,6 @@ Usage
|
|
|
142
324
|
$ specbook init [-v] [-c <yaml-file>] [-b <basedir>]
|
|
143
325
|
$ specbook lint [-v] [-c <yaml-file>] [-b <basedir>]
|
|
144
326
|
$ specbook export [-v] [-c <yaml-file>] [-b <basedir>] [-o [<format>:]<output-file>] [...]
|
|
145
|
-
[--max-table-columns <count>]
|
|
146
327
|
$ specbook describe [-v] [-c <yaml-file>] [-o <markdown-file>]
|
|
147
328
|
$ specbook import [-v] [-c <yaml-file>] [-b <basedir>] <input-files...>
|
|
148
329
|
$ specbook edit [-v] [-c <yaml-file>] [-b <basedir>] <query>
|
package/dst/specbook-api.d.ts
CHANGED
|
@@ -6,6 +6,7 @@ export { renderDiagnostic, renderDiagnosticVerbose, type Diagnostic };
|
|
|
6
6
|
export { type LintResult };
|
|
7
7
|
export type { Specification, Artifact, Object, Description, Property } from "./specbook-struct-spec.js";
|
|
8
8
|
export type { SchemaSpecification, SchemaObject, SchemaProperty } from "./specbook-struct-schema.js";
|
|
9
|
+
export declare const version: string;
|
|
9
10
|
export interface SpecBookOptions {
|
|
10
11
|
verbose?: (msg: string) => void;
|
|
11
12
|
}
|
|
@@ -25,7 +26,6 @@ export declare class SpecBook {
|
|
|
25
26
|
config?: string;
|
|
26
27
|
basedir?: string;
|
|
27
28
|
formats?: ExportFormat[];
|
|
28
|
-
maxTableColumns?: number;
|
|
29
29
|
}): Promise<Buffer[]>;
|
|
30
30
|
describe(options: {
|
|
31
31
|
config?: string;
|
package/dst/specbook-api.js
CHANGED
|
@@ -3,6 +3,9 @@
|
|
|
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 * as fs from "node:fs";
|
|
7
|
+
import * as path from "node:path";
|
|
8
|
+
import { fileURLToPath } from "node:url";
|
|
6
9
|
import { loadConfig } from "./specbook-config.js";
|
|
7
10
|
import { renderDiagnostic, renderDiagnosticVerbose } from "./specbook-diagnostic.js";
|
|
8
11
|
import { initSpecification } from "./specbook-cmd-init.js";
|
|
@@ -14,6 +17,12 @@ import { editSpecification } from "./specbook-cmd-edit.js";
|
|
|
14
17
|
/* re-export the central types for API consumers */
|
|
15
18
|
export { formats, parseOutputSpec };
|
|
16
19
|
export { renderDiagnostic, renderDiagnosticVerbose };
|
|
20
|
+
/* the own version, taken from the package manifest, which resides one
|
|
21
|
+
level above both the source and the compiled module directory */
|
|
22
|
+
export const version = (() => {
|
|
23
|
+
const manifest = path.join(path.dirname(fileURLToPath(import.meta.url)), "..", "package.json");
|
|
24
|
+
return JSON.parse(fs.readFileSync(manifest, "utf8")).version;
|
|
25
|
+
})();
|
|
17
26
|
/* the SpecBook API */
|
|
18
27
|
export class SpecBook {
|
|
19
28
|
verbose;
|
|
@@ -53,9 +62,9 @@ export class SpecBook {
|
|
|
53
62
|
if (result.specification.artifacts.length === 0)
|
|
54
63
|
throw new Error("unexportable specification:\n" +
|
|
55
64
|
result.diagnostics.map(renderDiagnostic).join("\n"));
|
|
56
|
-
const buffers =
|
|
65
|
+
const buffers = new Array();
|
|
57
66
|
for (const format of options.formats ?? ["json"])
|
|
58
|
-
buffers.push(await exportSpecification(result.specification, format, this.verbose,
|
|
67
|
+
buffers.push(await exportSpecification(result.specification, format, this.verbose, result.config));
|
|
59
68
|
return buffers;
|
|
60
69
|
}
|
|
61
70
|
/* describe the configured specification format as Markdown */
|
package/dst/specbook-cli.js
CHANGED
|
@@ -6,7 +6,7 @@
|
|
|
6
6
|
*/
|
|
7
7
|
import * as fs from "node:fs";
|
|
8
8
|
import { Command } from "commander";
|
|
9
|
-
import { SpecBook, renderDiagnostic, renderDiagnosticVerbose, parseOutputSpec } from "./specbook-api.js";
|
|
9
|
+
import { SpecBook, renderDiagnostic, renderDiagnosticVerbose, parseOutputSpec, version } from "./specbook-api.js";
|
|
10
10
|
import { serveMcp } from "./specbook-mcp.js";
|
|
11
11
|
/* route verbose messages to stderr, keeping stdout reserved
|
|
12
12
|
for the command outputs and the MCP protocol */
|
|
@@ -33,7 +33,7 @@ const writeOutput = async (output, data, verbose) => {
|
|
|
33
33
|
await writeStdout(data);
|
|
34
34
|
else {
|
|
35
35
|
await fs.promises.writeFile(output, data);
|
|
36
|
-
verbose(`wrote "${output}" (${data
|
|
36
|
+
verbose(`wrote "${output}" (${Buffer.byteLength(data)} bytes)`);
|
|
37
37
|
}
|
|
38
38
|
};
|
|
39
39
|
/* determine an option default value, overridable via a
|
|
@@ -46,14 +46,14 @@ const envDefaultFlag = (name, fallback) => {
|
|
|
46
46
|
return value !== undefined ? !(/^(?:|0|false|no|off)$/i).test(value) : fallback;
|
|
47
47
|
};
|
|
48
48
|
/* provide the common options of all sub-commands */
|
|
49
|
-
const
|
|
49
|
+
const withCommonOptions = (command) => command
|
|
50
50
|
.option("-v, --verbose", "print verbose processing information to stderr", envDefaultFlag("verbose", false))
|
|
51
51
|
.option("-c, --config <yaml-file>", "YAML schema configuration file", envDefault("config"));
|
|
52
52
|
/* parse the command line */
|
|
53
53
|
const program = new Command();
|
|
54
54
|
program.name("specbook")
|
|
55
55
|
.description("Markdown-based Specification Format")
|
|
56
|
-
.version(
|
|
56
|
+
.version(version)
|
|
57
57
|
.action(() => {
|
|
58
58
|
program.help();
|
|
59
59
|
});
|
|
@@ -64,24 +64,24 @@ program.command("mcp")
|
|
|
64
64
|
verboseOf(opts)("starting MCP server on stdio");
|
|
65
65
|
await serveMcp(verboseOf(opts));
|
|
66
66
|
});
|
|
67
|
-
|
|
67
|
+
withCommonOptions(program.command("init"))
|
|
68
68
|
.description("initialize the configured specification artifact files below the base directory")
|
|
69
69
|
.option("-b, --basedir <directory>", "base directory of the specification Markdown files", envDefault("basedir", "."))
|
|
70
70
|
.action(async (opts) => {
|
|
71
71
|
const specbook = new SpecBook({ verbose: verboseOf(opts) });
|
|
72
72
|
const created = await specbook.init({ config: opts.config, basedir: opts.basedir });
|
|
73
|
-
|
|
73
|
+
await writeStdout(created.length > 0 ?
|
|
74
74
|
`initialized artifact file(s): ${created.join(", ")}\n` :
|
|
75
75
|
"no artifact files were created\n");
|
|
76
76
|
});
|
|
77
|
-
|
|
77
|
+
withCommonOptions(program.command("lint"))
|
|
78
78
|
.description("lint the specification Markdown files below the base directory")
|
|
79
79
|
.option("-b, --basedir <directory>", "base directory of the specification Markdown files", envDefault("basedir", "."))
|
|
80
80
|
.action(async (opts) => {
|
|
81
81
|
const specbook = new SpecBook({ verbose: verboseOf(opts) });
|
|
82
82
|
const result = await specbook.lint({ config: opts.config, basedir: opts.basedir });
|
|
83
83
|
for (const diagnostic of result.diagnostics)
|
|
84
|
-
|
|
84
|
+
await writeStdout(opts.verbose ?
|
|
85
85
|
renderDiagnosticVerbose(diagnostic, process.stdout.isTTY === true) :
|
|
86
86
|
`${renderDiagnostic(diagnostic)}\n`);
|
|
87
87
|
if (result.diagnostics.length > 0)
|
|
@@ -89,13 +89,12 @@ common(program.command("lint"))
|
|
|
89
89
|
else
|
|
90
90
|
verboseOf(opts)("specification valid");
|
|
91
91
|
});
|
|
92
|
-
|
|
92
|
+
withCommonOptions(program.command("export"))
|
|
93
93
|
.description("export the specification Markdown files below the base directory " +
|
|
94
94
|
"as JSON, JSON5, YAML, TOON, HTML, PDF, or normalized Markdown")
|
|
95
95
|
.option("-b, --basedir <directory>", "base directory of the specification Markdown files", envDefault("basedir", "."))
|
|
96
96
|
.option("-o, --output [<format>:]<output-file>", "output file (\"-\" for stdout, repeatable), with the format inferred " +
|
|
97
97
|
"from the filename extension unless explicitly prefixed", (value, previous) => previous.concat(value), [])
|
|
98
|
-
.option("--max-table-columns <count>", "maximum name/property/description columns for the compact table rendering", (value) => parseInt(value, 10), parseInt(envDefault("max-table-columns", "4") ?? "4", 10))
|
|
99
98
|
.action(async (opts) => {
|
|
100
99
|
const specbook = new SpecBook({ verbose: verboseOf(opts) });
|
|
101
100
|
const outputs = (opts.output.length > 0 ? opts.output : [envDefault("output") ?? "-"])
|
|
@@ -103,11 +102,11 @@ common(program.command("export"))
|
|
|
103
102
|
/* parse the input once and export each distinct format once */
|
|
104
103
|
const distinct = Array.from(new Set(outputs.map(({ format }) => format)));
|
|
105
104
|
const buffers = await specbook.export({ config: opts.config, basedir: opts.basedir,
|
|
106
|
-
formats: distinct
|
|
105
|
+
formats: distinct });
|
|
107
106
|
for (const { format, output } of outputs)
|
|
108
107
|
await writeOutput(output, buffers[distinct.indexOf(format)], verboseOf(opts));
|
|
109
108
|
});
|
|
110
|
-
|
|
109
|
+
withCommonOptions(program.command("describe"))
|
|
111
110
|
.description("describe the configured specification format as Markdown")
|
|
112
111
|
.option("-o, --output <markdown-file>", "output file (\"-\" for stdout)", envDefault("output", "-"))
|
|
113
112
|
.action(async (opts) => {
|
|
@@ -115,7 +114,7 @@ common(program.command("describe"))
|
|
|
115
114
|
const text = await specbook.describe({ config: opts.config });
|
|
116
115
|
await writeOutput(opts.output, text, verboseOf(opts));
|
|
117
116
|
});
|
|
118
|
-
|
|
117
|
+
withCommonOptions(program.command("import"))
|
|
119
118
|
.description("import foreign sources into the specification artifact files below the base directory")
|
|
120
119
|
.option("-b, --basedir <directory>", "base directory of the specification Markdown files", envDefault("basedir", "."))
|
|
121
120
|
.option("--provider <provider>", "AI provider (\"anthropic\", \"openai\", \"openrouter\" or \"ollama\")", envDefault("provider"))
|
|
@@ -125,11 +124,11 @@ common(program.command("import"))
|
|
|
125
124
|
const specbook = new SpecBook({ verbose: verboseOf(opts) });
|
|
126
125
|
const written = await specbook.import({ config: opts.config, basedir: opts.basedir,
|
|
127
126
|
inputs, provider: opts.provider, model: opts.model });
|
|
128
|
-
|
|
127
|
+
await writeStdout(written.length > 0 ?
|
|
129
128
|
`imported into artifact file(s): ${written.join(", ")}\n` :
|
|
130
129
|
"no artifact files were changed\n");
|
|
131
130
|
});
|
|
132
|
-
|
|
131
|
+
withCommonOptions(program.command("edit"))
|
|
133
132
|
.description("apply a free-text edit request to the specification artifact files below the base directory")
|
|
134
133
|
.option("-b, --basedir <directory>", "base directory of the specification Markdown files", envDefault("basedir", "."))
|
|
135
134
|
.option("--provider <provider>", "AI provider (\"anthropic\", \"openai\", \"openrouter\" or \"ollama\")", envDefault("provider"))
|
|
@@ -139,7 +138,7 @@ common(program.command("edit"))
|
|
|
139
138
|
const specbook = new SpecBook({ verbose: verboseOf(opts) });
|
|
140
139
|
const written = await specbook.edit({ config: opts.config, basedir: opts.basedir,
|
|
141
140
|
query, provider: opts.provider, model: opts.model });
|
|
142
|
-
|
|
141
|
+
await writeStdout(written.length > 0 ?
|
|
143
142
|
`edited artifact file(s): ${written.join(", ")}\n` :
|
|
144
143
|
"no artifact files were changed\n");
|
|
145
144
|
});
|
|
@@ -84,11 +84,13 @@ export const describeConfiguration = (config) => {
|
|
|
84
84
|
const sections = new Array();
|
|
85
85
|
for (const artifact of config) {
|
|
86
86
|
const lines = new Array();
|
|
87
|
-
lines.push(`### ${artifact.kind}: ${artifact.name} (${artifact.kind}-${artifact.id})`);
|
|
87
|
+
lines.push(`### ${artifact.kind}: ${artifact.name ?? ""} (${artifact.kind}-${artifact.id ?? ""})`);
|
|
88
88
|
lines.push("");
|
|
89
89
|
if (artifact.file !== undefined)
|
|
90
90
|
lines.push(`- file: \`${artifact.file}\``);
|
|
91
91
|
lines.push(`- description: ${collapse(artifact.desc)}`);
|
|
92
|
+
for (const prop of artifact.props ?? [])
|
|
93
|
+
lines.push(describeProperty(prop, ""));
|
|
92
94
|
for (const child of artifact.childs ?? [])
|
|
93
95
|
lines.push(describeObject(child, 2, ""));
|
|
94
96
|
sections.push(lines.join("\n"));
|
package/dst/specbook-cmd-edit.js
CHANGED
|
@@ -42,6 +42,9 @@ const instruction = (description, now) => textframe `
|
|
|
42
42
|
export const editSpecification = async (options) => {
|
|
43
43
|
const basedir = options.basedir ?? ".";
|
|
44
44
|
const files = options.config.map((artifact) => artifact.file).filter((file) => file !== undefined);
|
|
45
|
+
/* ensure a non-empty edit request was given */
|
|
46
|
+
if (options.query.trim() === "")
|
|
47
|
+
throw new Error("no edit request given");
|
|
45
48
|
/* gather the existing artifact files */
|
|
46
49
|
const existing = files
|
|
47
50
|
.filter((file) => fs.existsSync(path.join(basedir, file)))
|
|
@@ -61,7 +64,9 @@ export const editSpecification = async (options) => {
|
|
|
61
64
|
options.verbose(`ignoring unconfigured file "${block.name}"`);
|
|
62
65
|
continue;
|
|
63
66
|
}
|
|
64
|
-
|
|
67
|
+
const file = path.join(basedir, block.name);
|
|
68
|
+
fs.mkdirSync(path.dirname(file), { recursive: true });
|
|
69
|
+
fs.writeFileSync(file, block.content, "utf8");
|
|
65
70
|
options.verbose(`wrote artifact file "${block.name}"`);
|
|
66
71
|
written.push(block.name);
|
|
67
72
|
}
|
|
@@ -6,4 +6,4 @@ export declare const parseOutputSpec: (spec: string) => {
|
|
|
6
6
|
format: ExportFormat;
|
|
7
7
|
output: string;
|
|
8
8
|
};
|
|
9
|
-
export declare const exportSpecification: (specification: Specification, format: ExportFormat, verbose: (msg: string) => void,
|
|
9
|
+
export declare const exportSpecification: (specification: Specification, format: ExportFormat, verbose: (msg: string) => void, config?: SchemaSpecification) => Promise<Buffer>;
|
|
@@ -32,13 +32,14 @@ export const parseOutputSpec = (spec) => {
|
|
|
32
32
|
if (spec === "-")
|
|
33
33
|
return { format: "json", output: spec };
|
|
34
34
|
const ext = spec.match(/\.([a-zA-Z0-9]+)$/);
|
|
35
|
-
|
|
36
|
-
|
|
35
|
+
const format = ext !== null ? extensions[ext[1].toLowerCase()] : undefined;
|
|
36
|
+
if (format !== undefined)
|
|
37
|
+
return { format, output: spec };
|
|
37
38
|
throw new Error(`unable to infer export format from output "${spec}" ` +
|
|
38
39
|
"(use an explicit \"<format>:<filename>\" specification)");
|
|
39
40
|
};
|
|
40
41
|
/* export a specification into the requested format */
|
|
41
|
-
export const exportSpecification = async (specification, format, verbose,
|
|
42
|
+
export const exportSpecification = async (specification, format, verbose, config) => {
|
|
42
43
|
if (!formats.includes(format))
|
|
43
44
|
throw new Error(`unknown export format "${format}"`);
|
|
44
45
|
verbose(`exporting specification as "${format}"`);
|
|
@@ -62,7 +63,7 @@ export const exportSpecification = async (specification, format, verbose, maxTab
|
|
|
62
63
|
const css = themeStylesheet(colors) + await subsetStylesheet(charset) + paperStylesheet(paper);
|
|
63
64
|
if (format === "html") {
|
|
64
65
|
/* compress the rendered HTML (whitespace, comments, and inline CSS/JS) */
|
|
65
|
-
const html = await renderHtml(specification,
|
|
66
|
+
const html = await renderHtml(specification, config, undefined, css);
|
|
66
67
|
const minified = await minify(Buffer.from(html, "utf8"), {
|
|
67
68
|
collapseWhitespaces: "smart",
|
|
68
69
|
removeComments: true,
|
|
@@ -74,5 +75,5 @@ export const exportSpecification = async (specification, format, verbose, maxTab
|
|
|
74
75
|
else
|
|
75
76
|
/* the PDF export (like print in general) always uses the light
|
|
76
77
|
theme, so its decoration colors are the light mapping, too */
|
|
77
|
-
return htmlToPdf((tocPages) => renderHtml(specification,
|
|
78
|
+
return htmlToPdf((tocPages) => renderHtml(specification, config, tocPages, css), { ...documentTitle(specification), logo: documentLogo(specification) }, htmlOutline(specification, config), verbose, css, themeMapping(colors, "light"), paper);
|
|
78
79
|
};
|
|
@@ -46,6 +46,9 @@ const instruction = (description, now) => textframe `
|
|
|
46
46
|
export const importSpecification = async (options) => {
|
|
47
47
|
const basedir = options.basedir ?? ".";
|
|
48
48
|
const files = options.config.map((artifact) => artifact.file).filter((file) => file !== undefined);
|
|
49
|
+
/* ensure at least one foreign source was given */
|
|
50
|
+
if (options.inputs.length === 0)
|
|
51
|
+
throw new Error("no foreign sources given to import");
|
|
49
52
|
/* gather the existing artifact files and the foreign sources */
|
|
50
53
|
const existing = files
|
|
51
54
|
.filter((file) => fs.existsSync(path.join(basedir, file)))
|
|
@@ -65,7 +68,9 @@ export const importSpecification = async (options) => {
|
|
|
65
68
|
options.verbose(`ignoring unconfigured file "${block.name}"`);
|
|
66
69
|
continue;
|
|
67
70
|
}
|
|
68
|
-
|
|
71
|
+
const file = path.join(basedir, block.name);
|
|
72
|
+
fs.mkdirSync(path.dirname(file), { recursive: true });
|
|
73
|
+
fs.writeFileSync(file, block.content, "utf8");
|
|
69
74
|
options.verbose(`wrote artifact file "${block.name}"`);
|
|
70
75
|
written.push(block.name);
|
|
71
76
|
}
|
package/dst/specbook-cmd-init.js
CHANGED
|
@@ -21,14 +21,16 @@ export const initSpecification = (options) => {
|
|
|
21
21
|
options.verbose(`skipping existing artifact file "${artifact.file}"`);
|
|
22
22
|
continue;
|
|
23
23
|
}
|
|
24
|
-
const name =
|
|
24
|
+
const name = artifact.name ?? "";
|
|
25
|
+
const paren = artifact.id !== undefined ? ` (${artifact.id})` : "";
|
|
25
26
|
const text = "---\n" +
|
|
26
27
|
`Created: ${now}\n` +
|
|
27
28
|
`Modified: ${now}\n` +
|
|
28
29
|
"---\n" +
|
|
29
30
|
"\n" +
|
|
30
|
-
`# ${artifact.kind}: ${name}
|
|
31
|
+
`# ${artifact.kind}: ${name}${paren}\n` +
|
|
31
32
|
"\n";
|
|
33
|
+
fs.mkdirSync(path.dirname(file), { recursive: true });
|
|
32
34
|
fs.writeFileSync(file, text, "utf8");
|
|
33
35
|
options.verbose(`created artifact file "${artifact.file}"`);
|
|
34
36
|
created.push(artifact.file);
|
package/dst/specbook-cmd-lint.js
CHANGED
|
@@ -25,11 +25,13 @@ export const lint = (options) => {
|
|
|
25
25
|
config = loaded.config ?? undefined;
|
|
26
26
|
}
|
|
27
27
|
/* parse and validate the specification Markdown files */
|
|
28
|
-
const
|
|
28
|
+
const exists = fs.existsSync(options.basedir) && fs.statSync(options.basedir).isDirectory();
|
|
29
|
+
const files = exists ? scanMarkdown(options.basedir) : [];
|
|
29
30
|
options.verbose(`parsing ${files.length} specification file(s) below "${options.basedir}"`);
|
|
30
31
|
if (files.length === 0)
|
|
31
32
|
diagnostics.push({ file: options.basedir, line: 1, column: 1,
|
|
32
|
-
message: "no Markdown files found below base directory"
|
|
33
|
+
message: exists ? "no Markdown files found below base directory" :
|
|
34
|
+
"base directory does not exist" });
|
|
33
35
|
const sources = new Array();
|
|
34
36
|
for (const file of files) {
|
|
35
37
|
try {
|
package/dst/specbook-config.js
CHANGED
|
@@ -24,7 +24,17 @@ const lineColOfPath = (yaml, cst, path) => {
|
|
|
24
24
|
/* load and validate a YAML schema configuration file */
|
|
25
25
|
export const loadConfig = (file) => {
|
|
26
26
|
const diagnostics = [];
|
|
27
|
-
|
|
27
|
+
/* read the configuration file */
|
|
28
|
+
let yaml;
|
|
29
|
+
try {
|
|
30
|
+
yaml = fs.readFileSync(file, "utf8");
|
|
31
|
+
}
|
|
32
|
+
catch (err) {
|
|
33
|
+
diagnostics.push({ file, line: 1, column: 1,
|
|
34
|
+
message: "cannot read configuration file: " +
|
|
35
|
+
(err instanceof Error ? err.message : String(err)) });
|
|
36
|
+
return { config: null, diagnostics };
|
|
37
|
+
}
|
|
28
38
|
/* syntactically parse the YAML content */
|
|
29
39
|
let obj;
|
|
30
40
|
try {
|
|
@@ -37,17 +47,22 @@ export const loadConfig = (file) => {
|
|
|
37
47
|
line: e?.linePos?.[0]?.line ?? 1,
|
|
38
48
|
column: e?.linePos?.[0]?.col ?? 1,
|
|
39
49
|
message: "invalid YAML syntax: " +
|
|
40
|
-
(err instanceof Error ? err.message.replace(/:
|
|
50
|
+
(err instanceof Error ? err.message.replace(/:[\s\S]*$/, "") : String(err))
|
|
41
51
|
});
|
|
42
52
|
return { config: null, diagnostics };
|
|
43
53
|
}
|
|
44
|
-
/* semantically validate against the schema of the configuration
|
|
45
|
-
|
|
54
|
+
/* semantically validate against the schema of the configuration
|
|
55
|
+
(the CST is parsed lazily, as it is needed for positions only) */
|
|
56
|
+
let cst;
|
|
57
|
+
const posOfPath = (path) => {
|
|
58
|
+
cst ??= parseDocument(yaml);
|
|
59
|
+
return lineColOfPath(yaml, cst, path);
|
|
60
|
+
};
|
|
46
61
|
const result = v.safeParse(SchemaSpecification, obj);
|
|
47
62
|
if (!result.success) {
|
|
48
63
|
for (const issue of result.issues) {
|
|
49
64
|
const path = (issue.path ?? []).map((item) => item.key);
|
|
50
|
-
const pos =
|
|
65
|
+
const pos = posOfPath(path);
|
|
51
66
|
diagnostics.push({ file, line: pos.line, column: pos.column,
|
|
52
67
|
message: `invalid configuration: ${path.join(".")}: ${issue.message}` });
|
|
53
68
|
}
|
|
@@ -57,7 +72,7 @@ export const loadConfig = (file) => {
|
|
|
57
72
|
const checkFileField = (objects, path, depth) => {
|
|
58
73
|
objects.forEach((object, i) => {
|
|
59
74
|
if (depth > 1 && object.file !== undefined) {
|
|
60
|
-
const pos =
|
|
75
|
+
const pos = posOfPath([...path, i, "file"]);
|
|
61
76
|
diagnostics.push({ file, line: pos.line, column: pos.column,
|
|
62
77
|
message: `"file" field is only allowed on the first (artifact) level (found on level ${depth})` });
|
|
63
78
|
}
|
|
@@ -76,7 +91,7 @@ export const loadConfig = (file) => {
|
|
|
76
91
|
compileValueExpr(prop.value);
|
|
77
92
|
}
|
|
78
93
|
catch (err) {
|
|
79
|
-
const pos =
|
|
94
|
+
const pos = posOfPath([...path, i, "props", j, "value"]);
|
|
80
95
|
diagnostics.push({ file, line: pos.line, column: pos.column,
|
|
81
96
|
message: `invalid value expression "${prop.value}": ` +
|
|
82
97
|
(err instanceof Error ? err.message : String(err)) });
|