@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 CHANGED
@@ -33,18 +33,46 @@ service with tools `specbook_<cmd>()`.
33
33
  Specification Format
34
34
  --------------------
35
35
 
36
- A specification consists of Markdown *artifact* files, each carrying a
37
- frontmatter with `Created:` and `Modified:` timestamps and a tree of
38
- *objects*. Every object has a mandatory *kind*, a mandatory *name*, an
39
- optional unique anchor *id*, optional *properties* (key/value pairs),
40
- an optional *description* statement with an optional rationale, and
41
- optional *child* objects.
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, with child objects nested as
61
- indented list items below their parent item:
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 childs of the object the heading is nested under.
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 `![<alt/>](<file/>)` 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, and `config` passes arbitrary Gradia
114
- rendering options (e.g. `grid-columns-max: 5`) through to the diagram.
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
- childs, as the `TIER` objects of the sample schema configuration
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>
@@ -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;
@@ -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, options.maxTableColumns, result.config));
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 */
@@ -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.length} bytes)`);
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 common = (command) => command
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("0.9.0")
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
- common(program.command("init"))
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
- process.stdout.write(created.length > 0 ?
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
- common(program.command("lint"))
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
- process.stdout.write(opts.verbose ?
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
- common(program.command("export"))
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, maxTableColumns: opts.maxTableColumns });
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
- common(program.command("describe"))
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
- common(program.command("import"))
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
- process.stdout.write(written.length > 0 ?
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
- common(program.command("edit"))
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
- process.stdout.write(written.length > 0 ?
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"));
@@ -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
- fs.writeFileSync(path.join(basedir, block.name), block.content, "utf8");
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, maxTableColumns?: number, config?: SchemaSpecification) => Promise<Buffer>;
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
- if (ext !== null && extensions[ext[1].toLowerCase()] !== undefined)
36
- return { format: extensions[ext[1].toLowerCase()], output: spec };
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, maxTableColumns = 4, config) => {
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, maxTableColumns, config, undefined, css);
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, maxTableColumns, config, tocPages, css), { ...documentTitle(specification), logo: documentLogo(specification) }, htmlOutline(specification, maxTableColumns), verbose, css, themeMapping(colors, "light"), paper);
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
- fs.writeFileSync(path.join(basedir, block.name), block.content, "utf8");
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
  }
@@ -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 = (artifact.name ?? "").toUpperCase();
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} (${artifact.kind}-${artifact.id})\n` +
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);
@@ -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 files = scanMarkdown(options.basedir);
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 {
@@ -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
- const yaml = fs.readFileSync(file, "utf8");
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(/:(?:.|\r?\n)*$/, "") : String(err))
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
- const cst = parseDocument(yaml);
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 = lineColOfPath(yaml, cst, path);
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 = lineColOfPath(yaml, cst, [...path, i, "file"]);
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 = lineColOfPath(yaml, cst, [...path, i, "props", j, "value"]);
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)) });