@rse/specbook 1.0.1 → 1.0.3

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
@@ -22,63 +22,63 @@ CLI with commands `specbook <xxx>`, and an MCP service with tools
22
22
 
23
23
  **SpecBook** provides the following distinct features:
24
24
 
25
- - **Hierarchical Object Model**:
26
- A specification is a tree of typed objects, each with a kind and
27
- name, an optional id, optional properties, optional description, and
28
- optional child objects. The allowed object hierarchy is defined per
29
- context by the YAML schema configuration.
30
-
31
- - **Versatile Object to Markdown Mapping**:
32
- Objects are authored as plain Markdown headings, property lists,
33
- and description prose. The objects can be mapped to nested sections
34
- ("complex" format) or compact bullet point lists ("concise" and
35
- "group" format).
36
-
37
- - **Wiki-Style Object Linking**:
38
- Objects can hierarchically reference each other through Wiki-style
39
- `[[xxx]]` references, which are resolved against the locally-unique
40
- ids and names of all objects. In the HTML and PDF exports they become
41
- navigable links onto precise anchors.
42
-
43
- - **Strict Validation with Custom Schema**:
44
- The YAML schema configuration strictly defines the allowed object
45
- kinds, hierarchies, and properties, whose values are constrained by an
46
- expression language (regex, enum, tags, list, and reference). Violations
47
- are reported as file- and line-precise diagnostics. A bundled standard
48
- schema configuration applies if no particular one is given.
49
-
50
- - **Object Model Diagram Visualisation**:
51
- Object kinds can declare "graph", "hub", or "grid" diagrams in the
52
- schema, whose nodes and edges are automatically derived from the
53
- object model and its references. The diagrams are rendered with the
54
- sibling project [Gradia](https://github.com/rse/gradia), which is
55
- specialized in rendering object models.
56
-
57
- - **CLI, MCP, and API Interface**:
58
- All commands are implemented once in the API class `SpecBook`, on
59
- which both the CLI and the MCP service are just thin wrappers.
60
- This way, humans, scripts, and AI agents use exactly the same
61
- functionality.
62
-
63
- - **AST Exports for AI/LLMs**:
64
- The parsed specification Abstract Syntax Tree (AST) can be exported in
65
- JSON, JSON5, YAML, or TOON format for machine consumption. Together
66
- with the `describe` command, which explains the models and formats,
67
- this enables LLMs to both read and write specifications.
68
-
69
- - **HTML Export for Developers**:
70
- The HTML export is a self-contained single document with a
71
- table-of-contents, full-text search, embedded images, and a light/dark
72
- theme toggle. It is intended for the day-to-day online reading during
73
- development. The HTML export can map all object kinds to nested
74
- sections or compact tables, or an automatic mixture which collapses
75
- only the deepest level into tables.
76
-
77
- - **PDF Export for Customers**:
78
- The PDF export prints the HTML rendering via Chromium and post-processes
79
- it with page numbers, headers/footers, and a hierarchical PDF outline.
80
- It is intended as a polished, paginated offline document for handing
81
- over to customers.
25
+ - **Hierarchical Object Model**:
26
+ A specification is a tree of typed objects, each with a kind and
27
+ name, an optional id, optional properties, optional description, and
28
+ optional child objects. The allowed object hierarchy is defined per
29
+ context by the YAML schema configuration.
30
+
31
+ - **Versatile Object to Markdown Mapping**:
32
+ Objects are authored as plain Markdown headings, property lists,
33
+ and description prose. The objects can be mapped to nested sections
34
+ ("complex" format) or compact bullet point lists ("concise" and
35
+ "group" format).
36
+
37
+ - **Wiki-Style Object Linking**:
38
+ Objects can hierarchically reference each other through Wiki-style
39
+ `[[xxx]]` references, which are resolved against the locally-unique
40
+ ids and names of all objects. In the HTML and PDF exports they become
41
+ navigable links onto precise anchors.
42
+
43
+ - **Strict Validation with Custom Schema**:
44
+ The YAML schema configuration strictly defines the allowed object
45
+ kinds, hierarchies, and properties, whose values are constrained by an
46
+ expression language (regex, enum, tags, list, and reference). Violations
47
+ are reported as file- and line-precise diagnostics. A bundled standard
48
+ schema configuration applies if no particular one is given.
49
+
50
+ - **Object Model Diagram Visualisation**:
51
+ Object kinds can declare "graph", "hub", or "grid" diagrams in the
52
+ schema, whose nodes and edges are automatically derived from the
53
+ object model and its references. The diagrams are rendered with the
54
+ sibling project [Gradia](https://github.com/rse/gradia), which is
55
+ specialized in rendering object models.
56
+
57
+ - **CLI, MCP, and API Interface**:
58
+ All commands are implemented once in the API class `SpecBook`, on
59
+ which both the CLI and the MCP service are just thin wrappers.
60
+ This way, humans, scripts, and AI agents use exactly the same
61
+ functionality.
62
+
63
+ - **AST Exports for AI/LLMs**:
64
+ The parsed specification Abstract Syntax Tree (AST) can be exported in
65
+ JSON, JSON5, YAML, or TOON format for machine consumption. Together
66
+ with the `describe` command, which explains the models and formats,
67
+ this enables LLMs to both read and write specifications.
68
+
69
+ - **HTML Export for Developers**:
70
+ The HTML export is a self-contained single document with a
71
+ table-of-contents, full-text search, embedded images, and a light/dark
72
+ theme toggle. It is intended for the day-to-day online reading during
73
+ development. The HTML export can map all object kinds to nested
74
+ sections or compact tables, or an automatic mixture which collapses
75
+ only the deepest level into tables.
76
+
77
+ - **PDF Export for Customers**:
78
+ The PDF export prints the HTML rendering via Chromium and post-processes
79
+ it with page numbers, headers/footers, and a hierarchical PDF outline.
80
+ It is intended as a polished, paginated offline document for handing
81
+ over to customers.
82
82
 
83
83
  Overview
84
84
  --------
@@ -97,84 +97,235 @@ $ npm install -g @rse/specbook
97
97
  Usage
98
98
  -----
99
99
 
100
- ### CLI
100
+ ### API
101
101
 
102
- ```bash
103
- $ specbook init [-v] [-c <yaml-file>] [-b <basedir>]
104
- $ specbook lint [-v] [-c <yaml-file>] [-b <basedir>]
105
- $ specbook export [-v] [-c <yaml-file>] [-b <basedir>] [-o [<format>:]<output-file>] [...]
106
- $ specbook describe [-v] [-c <yaml-file>] [-b <basedir>] [-e] [-f <format>] [-p <part>] [-o <markdown-file>]
107
- $ specbook mcp [-v]
102
+ ```ts
103
+ /* the SpecBook API, providing all commands the CLI and MCP service expose */
104
+ export class SpecBook {
105
+ constructor (options?: SpecBookOptions)
106
+
107
+ /* initialize the configured specification artifact files below the
108
+ base directory and return the list of the generated files */
109
+ init (options: {
110
+ config?: string, /* YAML schema configuration (default: bundled standard one) */
111
+ basedir?: string /* base directory (default: ".") */
112
+ }): Promise<string[]>
113
+
114
+ /* parse and validate the specification Markdown files below the base directory */
115
+ lint (options: {
116
+ config?: string,
117
+ basedir?: string
118
+ }): Promise<LintResult>
119
+
120
+ /* export the specification, parsing the input just once and
121
+ returning one buffer per requested format (strict: any diagnostic
122
+ rejects the export with an "Error") */
123
+ export (options: {
124
+ config?: string,
125
+ basedir?: string,
126
+ formats?: ExportFormat[] /* requested output formats (default: [ "json" ]) */
127
+ }): Promise<Buffer[]>
128
+
129
+ /* describe the SpecBook models and formats, optionally pointing to
130
+ the artifacts of the particular project */
131
+ describe (options: {
132
+ config?: string,
133
+ basedir?: string,
134
+ embed?: boolean, /* embed the schema configuration instead of referencing it */
135
+ format?: DescribeFormat, /* output format (default: "md") */
136
+ part?: DescribePart /* document part (default: "all") */
137
+ }): Promise<string>
138
+ }
139
+
140
+ /* the constructor options and the sink of the verbose messages,
141
+ receiving the emitting command and the message */
142
+ export interface SpecBookOptions {
143
+ verbose?: VerboseSink
144
+ }
145
+ export type VerboseSink = (cmd: string, msg: string) => void
146
+
147
+ /* the result of the "lint" command and its file- and line-precise diagnostics */
148
+ export interface LintResult {
149
+ specification: Spec
150
+ diagnostics: Diagnostic[]
151
+ config?: Schema
152
+ }
153
+ export interface Diagnostic {
154
+ file: string
155
+ line: number
156
+ column: number
157
+ message: string
158
+ }
159
+
160
+ /* the supported export formats and describe formats/parts */
161
+ export const formats: readonly [ "json", "json5", "yaml", "toon", "html", "pdf", "md" ]
162
+ export const describeFormats: readonly [ "md", "raw" ]
163
+ export const describeParts: readonly [ "all", "meta", "schema", "spec" ]
164
+ export type ExportFormat = typeof formats[number]
165
+ export type DescribeFormat = typeof describeFormats[number]
166
+ export type DescribePart = typeof describeParts[number]
167
+
168
+ /* the Abstract Syntax Tree (AST) of a parsed specification */
169
+ export type Spec = {
170
+ artifacts: SpecArtifact[]
171
+ }
172
+ export type SpecArtifact = {
173
+ created: Date
174
+ modified: Date
175
+ objects: SpecObject[]
176
+ }
177
+ export type SpecObject = {
178
+ kind: string
179
+ id: string
180
+ anchor?: string
181
+ paren?: string
182
+ name: string
183
+ primary?: boolean
184
+ description?: SpecDescription
185
+ properties: SpecProperty[]
186
+ childs: SpecObject[]
187
+ }
188
+ export type SpecDescription = {
189
+ description: string
190
+ rationale?: string
191
+ embedding?: string[]
192
+ }
193
+ export type SpecProperty = {
194
+ key: string
195
+ value: string
196
+ embedding?: string[]
197
+ }
198
+
199
+ /* the YAML schema configuration, where the nested "diagram" and
200
+ "format" structures are detailed in the "describe" output */
201
+ export type Schema = SchemaObject[]
202
+ export type SchemaObject = {
203
+ kind: string
204
+ name?: string
205
+ id?: string
206
+ file?: string
207
+ desc?: string
208
+ optional?: boolean
209
+ diagram?: SchemaDiagram
210
+ format?: SchemaFormat
211
+ props?: SchemaProperty[]
212
+ childs?: SchemaObject[]
213
+ }
214
+ export type SchemaProperty = {
215
+ name: string
216
+ desc?: string
217
+ value?: string
218
+ optional?: boolean
219
+ }
108
220
  ```
109
221
 
110
- The YAML schema configuration `-c`/`--config` (default: the bundled
111
- standard schema configuration `specbook-format.yaml`) determines the
112
- specification: exactly the artifact files its `file` fields reference
113
- are loaded and parsed; all other Markdown files below the base
114
- directory are ignored. A referenced
115
- file which is absent is reported, unless all of its artifacts are
116
- `optional`, and so is an artifact absent from its present file, unless
117
- it is `optional` itself. Both `lint` and `export` report all
118
- diagnostics and fail on any of them, so a partial or invalid
119
- specification is never exported.
120
-
121
- The base directory `-b`/`--basedir` (default: `.`) is the directory the
122
- referenced artifact files are resolved against, and generated
123
- specification Markdown files are placed inside it, too.
124
-
125
- The export output option `-o`/`--output` (default: `-` for stdout) can
126
- occur multiple times. The format (`json`, `json5`, `yaml`, `toon`,
127
- `html`, `pdf`, or `md`) is inferred from the filename extension, unless
128
- it is explicitly given as a `<format>:` prefix, and plain `-` (stdout)
129
- defaults to JSON.
130
-
131
- The `md` format normalizes the entire corpus into a *single* Markdown
132
- document, whose single frontmatter block carries the earliest `Created:`
133
- and the latest `Modified:` timestamp of all artifacts, so the
134
- per-artifact timestamps do not survive this round-trip.
135
-
136
- The `describe` command outputs the description of the SpecBook models
137
- and formats, extended by a *SpecBook Project Instantiation* section which
138
- points to the YAML schema configuration `-c`/`--config` and the base
139
- directory `-b`/`--basedir`. The schema configuration falls back onto the
140
- bundled standard one, embedded verbatim, as long as no particular
141
- configuration file `-c`/`--config` is given. With `-e`/`--embed` the
142
- given YAML schema configuration is embedded verbatim instead of just
143
- being referenced, so the resulting document describes the specification
144
- format entirely on its own.
145
-
146
- The `describe` document part `-p`/`--part` (default: `all`) reduces the
147
- output to a single part: `meta` for the description of the generic
148
- SpecBook models and formats, `schema` for the YAML schema configuration
149
- (the given one, referenced or embedded with `-e`/`--embed`, else the
150
- bundled standard one, embedded), or `spec` for the reference to
151
- the base directory. The output format `-f`/`--format` (default: `md`)
152
- switches from this rendered Markdown onto the raw original file content
153
- with `raw`, which is available for the file-backed parts `meta` and
154
- `schema` only.
155
-
156
- The default value of every CLI option `--xxx` can be overridden by a
157
- corresponding `SPECBOOK_XXX` environment variable (e.g.
158
- `SPECBOOK_BASEDIR`, `SPECBOOK_CONFIG`, `SPECBOOK_OUTPUT`,
159
- `SPECBOOK_VERBOSE`), while an explicitly supplied option always wins.
160
-
161
- ### API
222
+ Example:
162
223
 
163
224
  ```ts
164
- import { SpecBook, renderVerbose } from "@rse/specbook"
225
+ import { SpecBook } from "@rse/specbook"
165
226
 
166
227
  const specbook = new SpecBook({
167
- verbose: (cmd, msg) => console.error(`specbook: ${cmd}: ${renderVerbose(msg)}`)
228
+ verbose: (cmd, msg) => console.error(`specbook: ${cmd}: ${msg}`)
168
229
  })
169
230
  const result = await specbook.lint({
170
231
  basedir: "smp/broadcast"
171
232
  })
172
233
  ```
173
234
 
174
- ### MCP
235
+ ### CLI
236
+
237
+ Commands:
175
238
 
176
- Run `specbook mcp` to serve the tools `specbook_init`, `specbook_lint`,
177
- `specbook_export`, and `specbook_describe` over stdio.
239
+ ```bash
240
+ $ specbook init \
241
+ [-v|--verbose] \
242
+ [-c|--config <schema-yaml-file>] \
243
+ [-b|--basedir <spec-md-file-basedir>]
244
+
245
+ $ specbook lint \
246
+ [-v|--verbose] \
247
+ [-c|--config <schema-yaml-file>] \
248
+ [-b|--basedir <spec-md-file-basedir>]
249
+
250
+ $ specbook export \
251
+ [-v|--verbose] \
252
+ [-c|--config <schema-yaml-file>] \
253
+ [-b|--basedir <spec-md-file-basedir>] \
254
+ [-o|--output [<format>:]<output-file>] [...]
255
+
256
+ $ specbook describe \
257
+ [-v|--verbose] \
258
+ [-c|--config <schema-yaml-file>] \
259
+ [-b|--basedir <spec-md-file-basedir>] \
260
+ [-e|--embed] \
261
+ [-f|--format <format>] \
262
+ [-p|--part <part>] \
263
+ [-o|--output <output-file>]
264
+
265
+ $ specbook mcp \
266
+ [-v|--verbose]
267
+ ```
268
+
269
+ Options:
270
+
271
+ - `-v|--verbose`:
272
+ Enable verbose logging of processing information to `stderr`.
273
+
274
+ - `-c|--config <schema-yaml-file>`:
275
+ The YAML schema configuration (default: the bundled standard schema
276
+ configuration `specbook-format.yaml`) determines the specification:
277
+ exactly the artifact files its `file` fields reference are loaded and
278
+ parsed; all other Markdown files below the base directory are ignored.
279
+ A referenced file which is absent is reported, unless all of its
280
+ artifacts are `optional`, and so is an artifact absent from its present
281
+ file, unless it is `optional` itself. Both `lint` and `export` report
282
+ all diagnostics and fail on any of them, so a partial or invalid
283
+ specification is never exported.
284
+
285
+ - `-b|--basedir <spec-md-file-basedir>`:
286
+ The base directory (default: `.`) is the directory the referenced
287
+ artifact files are resolved against, and generated specification
288
+ Markdown files are placed inside it, too.
289
+
290
+ - `-o|--output [<format>:]<output-file>` (`export` only):
291
+ The output file (default: `-` for stdout) can be given multiple times.
292
+ The format (`json`, `json5`, `yaml`, `toon`, `html`, `pdf`, or `md`)
293
+ is inferred from the filename extension, unless it is explicitly given
294
+ as a `<format>:` prefix, and plain `-` (stdout) defaults to JSON.
295
+ The `md` export format normalizes the entire corpus into a *single*
296
+ Markdown document.
297
+
298
+ - `-o|--output <output-file>` (`describe` only):
299
+ The output file (default: `-` for stdout) receives the described
300
+ Markdown document.
301
+
302
+ - `-e|--embed` (`describe` only):
303
+ Embed the given YAML schema configuration verbatim instead of just
304
+ referencing it, so the resulting document describes the specification
305
+ format entirely on its own.
306
+
307
+ - `-f|--format <format>` (`describe` only):
308
+ The output format (default: `md`) switches from the rendered Markdown
309
+ onto the raw original file content with `raw`, which is available for
310
+ the file-backed parts `meta` and `schema` only.
311
+
312
+ - `-p|--part <part>` (`describe` only):
313
+ The document part (default: `all`) reduces the output to a single part:
314
+ `meta` for the description of the generic SpecBook models and formats,
315
+ `schema` for the YAML schema configuration (the given one, referenced
316
+ or embedded with `-e|--embed`, else the bundled standard one,
317
+ embedded), or `spec` for the reference to the base directory.
318
+
319
+ The default value of every CLI option `--xxx` can be overridden
320
+ by a corresponding `SPECBOOK_XXX` environment variable (e.g.
321
+ `SPECBOOK_BASEDIR`, `SPECBOOK_CONFIG`, `SPECBOOK_OUTPUT`,
322
+ `SPECBOOK_VERBOSE`), while an explicitly supplied option always wins.
323
+
324
+ Example:
325
+
326
+ ```bash
327
+ $ specbook lint -v -b smp/broadcast
328
+ ```
178
329
 
179
330
  Rendering Examples
180
331
  ------------------
@@ -2,8 +2,8 @@ import { renderDiagnostic, renderDiagnosticVerbose, type Diagnostic } from "./sp
2
2
  import { type LintResult } from "./specbook-cmd-lint.js";
3
3
  import { parseOutputSpec, formats, type ExportFormat } from "./specbook-cmd-export.js";
4
4
  import { describeFormats, describeParts, parseDescribeFormat, parseDescribePart, type DescribeFormat, type DescribePart } from "./specbook-cmd-describe.js";
5
- import { literal, renderVerbose } from "./specbook-verbose.js";
6
- export { literal, renderVerbose };
5
+ import { literal, renderVerbose, type Verbose, type VerboseLevel } from "./specbook-verbose.js";
6
+ export { literal, renderVerbose, type Verbose, type VerboseLevel };
7
7
  export { formats, parseOutputSpec, type ExportFormat };
8
8
  export { describeFormats, describeParts, parseDescribeFormat, parseDescribePart };
9
9
  export { type DescribeFormat, type DescribePart };
@@ -13,7 +13,7 @@ export type { Spec, SpecArtifact, SpecObject, SpecDescription, SpecProperty } fr
13
13
  export type { Schema, SchemaObject, SchemaProperty } from "./specbook-format-schema.js";
14
14
  export declare const version: string;
15
15
  export declare const standardConfig: string;
16
- export type VerboseSink = (cmd: string, msg: string) => void;
16
+ export type VerboseSink = (cmd: string, msg: string, level: VerboseLevel) => void;
17
17
  export interface SpecBookOptions {
18
18
  verbose?: VerboseSink;
19
19
  }
@@ -11,6 +11,7 @@ import { initSpecification } from "./specbook-cmd-init.js";
11
11
  import { lint } from "./specbook-cmd-lint.js";
12
12
  import { exportSpecification, parseOutputSpec, formats } from "./specbook-cmd-export.js";
13
13
  import { describeFormat, describeFormats, describeParts, parseDescribeFormat, parseDescribePart } from "./specbook-cmd-describe.js";
14
+ import { requireBrowser } from "./specbook-export-pdf.js";
14
15
  import { literal, renderVerbose } from "./specbook-verbose.js";
15
16
  /* re-export the central types for API consumers */
16
17
  export { literal, renderVerbose };
@@ -32,7 +33,7 @@ export class SpecBook {
32
33
  }
33
34
  /* bind the verbose sink to a particular command */
34
35
  verboseOf(cmd) {
35
- return (msg) => this.verbose(cmd, msg);
36
+ return (msg, level = "debug") => this.verbose(cmd, msg, level);
36
37
  }
37
38
  /* determine the file of the YAML schema configuration, falling
38
39
  back onto the bundled standard schema configuration */
@@ -68,6 +69,11 @@ export class SpecBook {
68
69
  as a partial or invalid specification must never be emitted) */
69
70
  async export(options) {
70
71
  const verbose = this.verboseOf("export");
72
+ const requested = options.formats ?? ["json"];
73
+ /* a missing browser is an environment problem, so let the PDF
74
+ export fail before the specification is even parsed */
75
+ if (requested.includes("pdf"))
76
+ await requireBrowser(verbose);
71
77
  const result = lint({ config: this.configFile(options.config),
72
78
  basedir: options.basedir ?? ".", verbose });
73
79
  if (result.diagnostics.length > 0)
@@ -76,7 +82,7 @@ export class SpecBook {
76
82
  if (result.specification.artifacts.length === 0)
77
83
  throw new Error("unexportable specification: no artifacts found");
78
84
  const buffers = new Array();
79
- for (const format of options.formats ?? ["json"])
85
+ for (const format of requested)
80
86
  buffers.push(await exportSpecification(result.specification, format, verbose, result.config));
81
87
  return buffers;
82
88
  }
@@ -12,9 +12,11 @@ import { serveMcp } from "./specbook-mcp.js";
12
12
  /* route verbose messages to stderr, keeping stdout reserved for the
13
13
  command outputs and the MCP protocol, and qualify every message with
14
14
  the tool name plus the scope path of the emitting command, where
15
- Chalk styles the output only if the terminal supports colors */
16
- const verboseOf = (opts, ...scope) => (cmd, msg) => {
17
- if (opts.verbose) {
15
+ Chalk styles the output only if the terminal supports colors; the
16
+ "notice" messages pass regardless of the verbose option, as they
17
+ report environment problems the user has to see */
18
+ const verboseOf = (opts, ...scope) => (cmd, msg, level) => {
19
+ if (opts.verbose || level === "notice") {
18
20
  const scopePath = [...scope, cmd].map((segment) => chalk.bold(segment)).join(": ");
19
21
  process.stderr.write(`specbook: ${scopePath}: ${renderVerbose(msg, chalk.blue)}\n`);
20
22
  }
@@ -38,7 +40,7 @@ const writeOutput = async (output, data, cmd, verbose) => {
38
40
  await writeStdout(data);
39
41
  else {
40
42
  await fs.promises.writeFile(output, data);
41
- verbose(cmd, `wrote "${literal(output)}" (${literal(Buffer.byteLength(data))} bytes)`);
43
+ verbose(cmd, `wrote "${literal(output)}" (${literal(Buffer.byteLength(data))} bytes)`, "debug");
42
44
  }
43
45
  };
44
46
  /* determine an option default value, overridable via a
@@ -71,7 +73,7 @@ program.name("specbook")
71
73
  withVerboseOption(program.command("mcp"))
72
74
  .description("run as MCP stdio server")
73
75
  .action(async (opts) => {
74
- verboseOf(opts)("mcp", "starting MCP server on stdio");
76
+ verboseOf(opts)("mcp", "starting MCP server on stdio", "debug");
75
77
  await serveMcp(verboseOf(opts, "mcp"));
76
78
  });
77
79
  /* the init command creates the configured artifact files */
@@ -97,7 +99,7 @@ withCommonOptions(program.command("lint"))
97
99
  if (result.diagnostics.length > 0)
98
100
  process.exitCode = 1;
99
101
  else
100
- verboseOf(opts)("lint", "specification valid");
102
+ verboseOf(opts)("lint", "specification valid", "debug");
101
103
  });
102
104
  /* the export command parses the input once and writes every output */
103
105
  withCommonOptions(program.command("export"))
@@ -143,5 +145,5 @@ catch (err) {
143
145
  let msg = err instanceof Error ? err.message : String(err);
144
146
  for (let cause = err instanceof Error ? err.cause : undefined; cause instanceof Error; cause = cause.cause)
145
147
  msg += `: ${cause.message}`;
146
- process.stderr.write(`specbook: ERROR: ${msg}\n`, () => process.exit(1));
148
+ process.stderr.write(`specbook: ${chalk.red("ERROR:")} ${renderVerbose(msg, chalk.blue)}\n`, () => process.exit(1));
147
149
  }
@@ -1,9 +1,10 @@
1
1
  import type { Spec } from "./specbook-format-spec.js";
2
2
  import type { Schema } from "./specbook-format-schema.js";
3
+ import type { Verbose } from "./specbook-verbose.js";
3
4
  export declare const formats: readonly ["json", "json5", "yaml", "toon", "html", "pdf", "md"];
4
5
  export type ExportFormat = typeof formats[number];
5
6
  export declare const parseOutputSpec: (spec: string) => {
6
7
  format: ExportFormat;
7
8
  output: string;
8
9
  };
9
- export declare const exportSpecification: (specification: Spec, format: ExportFormat, verbose: (msg: string) => void, config?: Schema) => Promise<Buffer>;
10
+ export declare const exportSpecification: (specification: Spec, format: ExportFormat, verbose: Verbose, config?: Schema) => Promise<Buffer>;
@@ -1,7 +1,8 @@
1
+ import { type Verbose } from "./specbook-verbose.js";
1
2
  import { type Schema } from "./specbook-format-schema.js";
2
3
  export interface InitOptions {
3
4
  config: Schema;
4
5
  basedir: string;
5
- verbose: (msg: string) => void;
6
+ verbose: Verbose;
6
7
  }
7
8
  export declare const initSpecification: (options: InitOptions) => string[];
@@ -1,10 +1,11 @@
1
+ import { type Verbose } from "./specbook-verbose.js";
1
2
  import { type Diagnostic } from "./specbook-diagnostic.js";
2
3
  import { type Spec } from "./specbook-format-spec.js";
3
4
  import { type Schema } from "./specbook-format-schema.js";
4
5
  export interface LintOptions {
5
6
  config: string;
6
7
  basedir: string;
7
- verbose: (msg: string) => void;
8
+ verbose: Verbose;
8
9
  }
9
10
  export interface LintResult {
10
11
  specification: Spec;
@@ -1,9 +1,12 @@
1
+ import type { LaunchOptions } from "playwright";
1
2
  import type { OutlineEntry } from "./specbook-export-html.js";
2
3
  import type { ThemeMapping } from "./specbook-theme.js";
4
+ import { type Verbose } from "./specbook-verbose.js";
3
5
  type Heading = {
4
6
  title: string;
5
7
  subtitle?: string;
6
8
  logo: string;
7
9
  };
8
- export declare const htmlToPdf: (renderHtmlPass: (tocPages?: Map<string, number>) => Promise<string>, heading: Heading, outline: OutlineEntry[], verbose: (msg: string) => void, css: string, theme: ThemeMapping, paper?: string) => Promise<Buffer>;
10
+ export declare const requireBrowser: (verbose: Verbose) => Promise<LaunchOptions>;
11
+ export declare const htmlToPdf: (renderHtmlPass: (tocPages?: Map<string, number>) => Promise<string>, heading: Heading, outline: OutlineEntry[], verbose: Verbose, css: string, theme: ThemeMapping, paper?: string) => Promise<Buffer>;
9
12
  export {};
@@ -3,7 +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 fs from "node:fs";
6
7
  import { escapeHtml, paperSetup, paperLength, paperSizeDefault } from "./specbook-export-common.js";
8
+ import { literal } from "./specbook-verbose.js";
7
9
  /* extract the per-anchor page numbers from a Chromium-generated PDF,
8
10
  which records the internal link targets as named PDF destinations */
9
11
  const anchorPages = async (pdf) => {
@@ -121,6 +123,48 @@ const drawBrandBar = async (doc, accent) => {
121
123
  page.drawRectangle({ x: 0, y: 0, width: 5.4,
122
124
  height: page.getHeight(), color: rgb(r, g, b) });
123
125
  };
126
+ /* the launch options of the Chromium-class browser printing the PDF,
127
+ resolved just once per process, as probing a system-installed Google
128
+ Chrome has to actually launch it and as the export preflight and the
129
+ renderer both ask for the very same browser */
130
+ let browserOptions;
131
+ /* resolve the browser printing the PDF: the downloaded Playwright
132
+ Chromium is a plain file check, while a system-installed Google
133
+ Chrome is only detectable by launching it */
134
+ const resolveBrowser = async (verbose) => {
135
+ const { chromium } = await import("playwright");
136
+ let executable = "";
137
+ try {
138
+ executable = chromium.executablePath();
139
+ }
140
+ catch {
141
+ /* Playwright knows no Chromium distribution at all */
142
+ }
143
+ if (executable !== "" && fs.existsSync(executable))
144
+ return {};
145
+ try {
146
+ const chrome = await chromium.launch({ channel: "chrome" });
147
+ await chrome.close();
148
+ verbose("Playwright Chromium unavailable -- falling back to the " +
149
+ "system-installed Google Chrome", "notice");
150
+ return { channel: "chrome" };
151
+ }
152
+ catch {
153
+ return undefined;
154
+ }
155
+ };
156
+ /* ensure a browser printing the PDF is available, so a missing browser
157
+ fails the export early and with an actionable remedy instead of deep
158
+ inside the rendering and with a Playwright-internal message */
159
+ export const requireBrowser = async (verbose) => {
160
+ browserOptions ??= resolveBrowser(verbose);
161
+ const options = await browserOptions;
162
+ if (options === undefined)
163
+ throw new Error("the PDF export requires a Chromium-class browser, but neither the " +
164
+ "Playwright Chromium nor a system-installed Google Chrome was found -- run " +
165
+ `"${literal("npx playwright install chromium")}" once to download the Playwright Chromium`);
166
+ return options;
167
+ };
124
168
  /* render a self-contained HTML document into a PDF via Playwright,
125
169
  re-rendering the HTML with the discovered ToC page numbers */
126
170
  export const htmlToPdf = async (renderHtmlPass, heading, outline, verbose, css, theme, paper = paperSizeDefault) => {
@@ -128,22 +172,8 @@ export const htmlToPdf = async (renderHtmlPass, heading, outline, verbose, css,
128
172
  /* the page geometry of the chosen paper size */
129
173
  const setup = paperSetup(paper);
130
174
  const margin = setup.margin;
131
- /* launch the Playwright Chromium browser, falling back to a
132
- system-installed Google Chrome if the Chromium download is missing */
133
- let browser;
134
- try {
135
- browser = await chromium.launch();
136
- }
137
- catch (err) {
138
- verbose("Playwright Chromium unavailable -- falling back to installed Google Chrome");
139
- try {
140
- browser = await chromium.launch({ channel: "chrome" });
141
- }
142
- catch {
143
- /* report the original, more instructive error */
144
- throw err;
145
- }
146
- }
175
+ /* launch the browser resolved for this process */
176
+ const browser = await chromium.launch(await requireBrowser(verbose));
147
177
  try {
148
178
  const page = await browser.newPage();
149
179
  /* render an HTML document into a paginated PDF */
@@ -7,7 +7,7 @@ import * as fs from "node:fs";
7
7
  import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
8
8
  import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js";
9
9
  import { z } from "zod";
10
- import { SpecBook, renderDiagnostic, formats, parseOutputSpec, describeFormats, describeParts, version } from "./specbook-api.js";
10
+ import { SpecBook, renderDiagnostic, renderVerbose, formats, parseOutputSpec, describeFormats, describeParts, version } from "./specbook-api.js";
11
11
  /* render an error with its cause chain into a tool error result */
12
12
  const errorResult = (err) => {
13
13
  let msg = err instanceof Error ? err.message : String(err);
@@ -15,7 +15,7 @@ const errorResult = (err) => {
15
15
  msg += `: ${cause.message}`;
16
16
  return {
17
17
  isError: true,
18
- content: [{ type: "text", text: `ERROR: ${msg}` }]
18
+ content: [{ type: "text", text: `ERROR: ${renderVerbose(msg)}` }]
19
19
  };
20
20
  };
21
21
  /* serve the SpecBook functionality as "specbook_<cmd>" MCP tools over stdio */
@@ -1,2 +1,4 @@
1
+ export type VerboseLevel = "debug" | "notice";
2
+ export type Verbose = (msg: string, level?: VerboseLevel) => void;
1
3
  export declare const literal: (value: string | number) => string;
2
4
  export declare const renderVerbose: (msg: string, style?: (value: string) => string) => string;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@rse/specbook",
3
- "version": "1.0.1",
3
+ "version": "1.0.3",
4
4
  "publishConfig": { "access": "public" },
5
5
  "description": "Markdown-based Specification Format (API, CLI and MCP service)",
6
6
  "license": "Apache-2.0",