@rse/specbook 1.0.3 → 1.1.0

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.
Files changed (40) hide show
  1. package/README.md +106 -19
  2. package/dst/specbook-api.d.ts +29 -9
  3. package/dst/specbook-api.js +108 -33
  4. package/dst/specbook-cli.js +84 -25
  5. package/dst/specbook-cmd-describe.d.ts +7 -1
  6. package/dst/specbook-cmd-describe.js +56 -11
  7. package/dst/specbook-cmd-export.d.ts +2 -1
  8. package/dst/specbook-cmd-export.js +46 -3
  9. package/dst/specbook-cmd-lint.d.ts +2 -1
  10. package/dst/specbook-cmd-lint.js +12 -8
  11. package/dst/specbook-cmd-preview.d.ts +12 -0
  12. package/dst/specbook-cmd-preview.js +57 -0
  13. package/dst/specbook-config.d.ts +1 -1
  14. package/dst/specbook-config.js +187 -37
  15. package/dst/specbook-diagnostic.d.ts +2 -0
  16. package/dst/specbook-diagnostic.js +4 -2
  17. package/dst/specbook-diagram.d.ts +1 -0
  18. package/dst/specbook-diagram.js +0 -0
  19. package/dst/specbook-export-ast.js +4 -2
  20. package/dst/specbook-export-common.js +5 -3
  21. package/dst/specbook-export-html-search.js +118 -30
  22. package/dst/specbook-export-html.css +1140 -233
  23. package/dst/specbook-export-html.d.ts +1 -1
  24. package/dst/specbook-export-html.js +764 -120
  25. package/dst/specbook-export-md.js +4 -2
  26. package/dst/specbook-format-schema.d.ts +33 -1
  27. package/dst/specbook-format-schema.js +34 -2
  28. package/dst/specbook-format.md +193 -25
  29. package/dst/specbook-format.yaml +2291 -299
  30. package/dst/specbook-link.d.ts +2 -1
  31. package/dst/specbook-link.js +43 -3
  32. package/dst/specbook-mcp.js +19 -10
  33. package/dst/specbook-parse-common.d.ts +4 -2
  34. package/dst/specbook-parse-common.js +6 -3
  35. package/dst/specbook-parse-semantic.js +290 -30
  36. package/dst/specbook-parse-syntax.js +3 -1
  37. package/dst/specbook-parse-value.d.ts +1 -0
  38. package/dst/specbook-parse-value.js +17 -3
  39. package/dst/specbook-parse.js +1 -1
  40. package/package.json +14 -6
@@ -5,9 +5,10 @@
5
5
  ** Licensed under Apache 2.0 <https://spdx.org/licenses/Apache-2.0>
6
6
  */
7
7
  import * as fs from "node:fs";
8
- import { Command } from "commander";
8
+ import * as path from "node:path";
9
+ import { Command, CommanderError } from "commander";
9
10
  import chalk from "chalk";
10
- import { SpecBook, renderDiagnostic, renderDiagnosticVerbose, renderVerbose, literal, parseOutputSpec, describeFormats, describeParts, parseDescribeFormat, parseDescribePart, version } from "./specbook-api.js";
11
+ import { SpecBook, renderDiagnostic, renderDiagnosticVerbose, renderVerbose, literal, parseOutputSpec, previewAddr, previewPort, describeFormats, describeParts, parseDescribeFormat, parseDescribePart, parseCompressLevel, version } from "./specbook-api.js";
11
12
  import { serveMcp } from "./specbook-mcp.js";
12
13
  /* route verbose messages to stderr, keeping stdout reserved for the
13
14
  command outputs and the MCP protocol, and qualify every message with
@@ -21,12 +22,12 @@ const verboseOf = (opts, ...scope) => (cmd, msg, level) => {
21
22
  process.stderr.write(`specbook: ${scopePath}: ${renderVerbose(msg, chalk.blue)}\n`);
22
23
  }
23
24
  };
24
- /* write a buffer to stdout, awaiting the write callback which only
25
- fires once the data has been flushed to the underlying pipe, so a
26
- subsequent "process.exit" cannot truncate the output */
27
- const writeStdout = (data) => {
25
+ /* write a buffer to a standard stream, awaiting the write callback
26
+ which only fires once the data has been flushed to the underlying
27
+ pipe, so a subsequent "process.exit" cannot truncate the output */
28
+ const writeStream = (stream, data) => {
28
29
  return new Promise((resolve, reject) => {
29
- process.stdout.write(data, (err) => {
30
+ stream.write(data, (err) => {
30
31
  if (err)
31
32
  reject(err);
32
33
  else
@@ -34,6 +35,8 @@ const writeStdout = (data) => {
34
35
  });
35
36
  });
36
37
  };
38
+ const writeStdout = (data) => writeStream(process.stdout, data);
39
+ const writeStderr = (data) => writeStream(process.stderr, data);
37
40
  /* write a command result to the output file or stdout */
38
41
  const writeOutput = async (output, data, cmd, verbose) => {
39
42
  if (output === "-")
@@ -55,17 +58,36 @@ const envDefaultFlag = (name, fallback) => {
55
58
  /* provide the verbose option of all sub-commands */
56
59
  const withVerboseOption = (command) => command
57
60
  .option("-v, --verbose", "print verbose processing information to stderr", envDefaultFlag("verbose", false));
61
+ /* provide the repeatable schema configuration option, whose files or
62
+ glob patterns are merged in order (with "std" naming the bundled
63
+ standard one), and determine its value, where the environment default
64
+ carries a path-delimiter-separated list of patterns */
65
+ const withConfigOption = (command, fallback) => command
66
+ .option("-c, --config <yaml-file>", "YAML schema configuration file or glob pattern " +
67
+ "(repeatable, merged in order, \"std\" for the bundled standard schema configuration; " +
68
+ `default: ${fallback})`, (value, previous) => previous.concat(value), new Array());
69
+ const configOf = (opts) => opts.config.length > 0 ? opts.config : envDefault("config")?.split(path.delimiter);
58
70
  /* provide the common options of the specification processing sub-commands,
59
71
  for which the YAML schema configuration falls back onto the standard one */
60
- const withCommonOptions = (command) => withVerboseOption(command)
61
- .option("-c, --config <yaml-file>", "YAML schema configuration file " +
62
- "(default: the bundled standard schema configuration)", envDefault("config"))
72
+ const withCommonOptions = (command) => withConfigOption(withVerboseOption(command), "the bundled standard schema configuration")
63
73
  .option("-b, --basedir <directory>", "base directory of the specification Markdown files", envDefault("basedir", "."));
74
+ /* the help, version, and usage-error output Commander produces, which
75
+ is collected instead of written directly, as Commander writes it
76
+ synchronously and then terminates the process, truncating a piped
77
+ stream; the settings are established before the sub-commands are
78
+ created, so they are inherited by all of them */
79
+ let commanderOut = "";
80
+ let commanderErr = "";
64
81
  /* parse the command line */
65
82
  const program = new Command();
66
83
  program.name("specbook")
67
84
  .description("Markdown-based Specification Format")
68
85
  .version(version)
86
+ .configureOutput({
87
+ writeOut: (str) => { commanderOut += str; },
88
+ writeErr: (str) => { commanderErr += str; }
89
+ })
90
+ .exitOverride()
69
91
  .action(() => {
70
92
  program.help();
71
93
  });
@@ -81,22 +103,22 @@ withCommonOptions(program.command("init"))
81
103
  .description("initialize the configured specification artifact files below the base directory")
82
104
  .action(async (opts) => {
83
105
  const specbook = new SpecBook({ verbose: verboseOf(opts) });
84
- const created = await specbook.init({ config: opts.config, basedir: opts.basedir });
106
+ const created = await specbook.init({ config: configOf(opts), basedir: opts.basedir });
85
107
  await writeStdout(created.length > 0 ?
86
108
  `initialized artifact file(s): ${created.join(", ")}\n` :
87
109
  "no artifact files were created\n");
88
110
  });
89
- /* the lint command reports all diagnostics and fails on any of them */
111
+ /* the lint command reports all diagnostics and fails on any error */
90
112
  withCommonOptions(program.command("lint"))
91
113
  .description("lint the specification Markdown files below the base directory")
92
114
  .action(async (opts) => {
93
115
  const specbook = new SpecBook({ verbose: verboseOf(opts) });
94
- const result = await specbook.lint({ config: opts.config, basedir: opts.basedir });
116
+ const result = await specbook.lint({ config: configOf(opts), basedir: opts.basedir });
95
117
  for (const diagnostic of result.diagnostics)
96
118
  await writeStdout(opts.verbose ?
97
119
  renderDiagnosticVerbose(diagnostic, process.stdout.isTTY === true) :
98
120
  `${renderDiagnostic(diagnostic)}\n`);
99
- if (result.diagnostics.length > 0)
121
+ if (result.diagnostics.some((diagnostic) => diagnostic.severity === "error"))
100
122
  process.exitCode = 1;
101
123
  else
102
124
  verboseOf(opts)("lint", "specification valid", "debug");
@@ -105,34 +127,62 @@ withCommonOptions(program.command("lint"))
105
127
  withCommonOptions(program.command("export"))
106
128
  .description("export the specification Markdown files below the base directory " +
107
129
  "as JSON, JSON5, YAML, TOON, HTML, PDF, or normalized Markdown")
130
+ .option("-w, --watch", "keep the outputs in sync by re-exporting on every source change", envDefaultFlag("watch", false))
108
131
  .option("-o, --output [<format>:]<output-file>", "output file (\"-\" for stdout, repeatable), with the format inferred " +
109
132
  "from the filename extension unless explicitly prefixed", (value, previous) => previous.concat(value), new Array())
110
133
  .action(async (opts) => {
111
134
  const specbook = new SpecBook({ verbose: verboseOf(opts) });
112
135
  const outputs = (opts.output.length > 0 ? opts.output : [envDefault("output") ?? "-"])
113
136
  .map(parseOutputSpec);
137
+ /* a re-export has to land somewhere it can be picked up again,
138
+ which a one-shot stdout stream cannot provide */
139
+ if (opts.watch && outputs.some(({ output }) => output === "-"))
140
+ throw new Error("the watch mode requires regular output files " +
141
+ "(\"-\" for stdout is not supported)");
114
142
  /* parse the input once and export each distinct format once */
115
143
  const distinct = Array.from(new Set(outputs.map(({ format }) => format)));
116
- const buffers = await specbook.export({ config: opts.config, basedir: opts.basedir,
117
- formats: distinct });
118
- for (const { format, output } of outputs)
119
- await writeOutput(output, buffers[distinct.indexOf(format)], "export", verboseOf(opts));
144
+ const write = async (buffers) => {
145
+ for (const { format, output } of outputs)
146
+ await writeOutput(output, buffers[distinct.indexOf(format)], "export", verboseOf(opts));
147
+ };
148
+ if (opts.watch)
149
+ await specbook.watch({ config: configOf(opts), basedir: opts.basedir,
150
+ formats: distinct, onExport: write });
151
+ else
152
+ await write(await specbook.export({ config: configOf(opts), basedir: opts.basedir,
153
+ formats: distinct }));
154
+ });
155
+ /* the preview command serves the HTML export live in the browser */
156
+ withCommonOptions(program.command("preview"))
157
+ .description("serve the HTML export of the specification Markdown files below the base " +
158
+ "directory as a live preview, re-exported and reloaded on every source change")
159
+ .option("-a, --addr <ip-addr>", "IP address to listen on", envDefault("addr", previewAddr))
160
+ .option("-p, --port <tcp-port>", "TCP port to listen on", envDefault("port", String(previewPort)))
161
+ .action(async (opts) => {
162
+ const port = Number(opts.port);
163
+ if (!Number.isInteger(port))
164
+ throw new Error(`invalid TCP port "${opts.port}"`);
165
+ const specbook = new SpecBook({ verbose: verboseOf(opts) });
166
+ await specbook.preview({ config: configOf(opts), basedir: opts.basedir, addr: opts.addr, port });
120
167
  });
121
168
  /* the describe command also describes the generic SpecBook models and
122
169
  formats alone, so its YAML schema configuration stays optional */
123
- withVerboseOption(program.command("describe"))
124
- .description("describe the SpecBook models and formats as Markdown")
125
- .option("-c, --config <yaml-file>", "YAML schema configuration file " +
126
- "(default: the bundled standard schema configuration, embedded verbatim)", envDefault("config"))
170
+ withConfigOption(withVerboseOption(program.command("describe"))
171
+ .description("describe the SpecBook models and formats as Markdown"), "the bundled standard schema configuration, embedded")
127
172
  .option("-b, --basedir <directory>", "base directory of the specification Markdown files", envDefault("basedir"))
128
173
  .option("-e, --embed", "embed the given YAML schema configuration instead of just referencing it", envDefaultFlag("embed", false))
174
+ .option("-z, --compress [level]", "compression level of the emitted YAML schema configuration " +
175
+ "(0: verbatim, 1: re-emitted with 2-space indentation and without comments, " +
176
+ "2: also without \"refs\" fields, " +
177
+ "3: also without \"desc\" fields; default and bare flag: 1)", envDefault("compress", "1"))
129
178
  .option("-f, --format <format>", `output format (${describeFormats.join(", ")})`, envDefault("format", "md"))
130
179
  .option("-p, --part <part>", `document part (${describeParts.join(", ")})`, envDefault("part", "all"))
131
180
  .option("-o, --output <markdown-file>", "output file (\"-\" for stdout)", envDefault("output", "-"))
132
181
  .action(async (opts) => {
133
182
  const specbook = new SpecBook({ verbose: verboseOf(opts) });
134
- const text = await specbook.describe({ config: opts.config, basedir: opts.basedir,
135
- embed: opts.embed, format: parseDescribeFormat(opts.format), part: parseDescribePart(opts.part) });
183
+ const text = await specbook.describe({ config: configOf(opts), basedir: opts.basedir,
184
+ embed: opts.embed, compress: parseCompressLevel(opts.compress),
185
+ format: parseDescribeFormat(opts.format), part: parseDescribePart(opts.part) });
136
186
  await writeOutput(opts.output, text, "describe", verboseOf(opts));
137
187
  });
138
188
  /* run the command line program */
@@ -140,10 +190,19 @@ try {
140
190
  await program.parseAsync();
141
191
  }
142
192
  catch (err) {
193
+ /* Commander requested the termination (help, version, or a usage
194
+ error), so flush its own already rendered output and adopt its
195
+ exit code instead of reporting the control-flow error */
196
+ if (err instanceof CommanderError) {
197
+ await writeStdout(commanderOut);
198
+ await writeStderr(commanderErr);
199
+ process.exit(err.exitCode);
200
+ }
143
201
  /* include the cause chain, as e.g. fetch failures
144
202
  carry the underlying error only in the cause */
145
203
  let msg = err instanceof Error ? err.message : String(err);
146
204
  for (let cause = err instanceof Error ? err.cause : undefined; cause instanceof Error; cause = cause.cause)
147
205
  msg += `: ${cause.message}`;
148
- process.stderr.write(`specbook: ${chalk.red("ERROR:")} ${renderVerbose(msg, chalk.blue)}\n`, () => process.exit(1));
206
+ await writeStderr(`specbook: ${chalk.red("ERROR:")} ${renderVerbose(msg, chalk.blue)}\n`);
207
+ process.exit(1);
149
208
  }
@@ -1,14 +1,20 @@
1
+ import { type Schema } from "./specbook-format-schema.js";
1
2
  export declare const describeFormats: readonly ["md", "raw"];
2
3
  export declare const describeParts: readonly ["all", "meta", "schema", "spec"];
3
4
  export type DescribeFormat = typeof describeFormats[number];
4
5
  export type DescribePart = typeof describeParts[number];
5
6
  export declare const parseDescribeFormat: (value: string) => DescribeFormat;
6
7
  export declare const parseDescribePart: (value: string) => DescribePart;
8
+ export declare const compressLevels: readonly [0, 1, 2, 3];
9
+ export type CompressLevel = typeof compressLevels[number];
10
+ export declare const parseCompressLevel: (value: string | number | boolean) => CompressLevel;
7
11
  export declare const describeFormat: (options: {
8
- config?: string;
12
+ config?: string[];
13
+ schema?: Schema;
9
14
  basedir?: string;
10
15
  embed?: boolean;
11
16
  standard?: boolean;
17
+ compress?: CompressLevel;
12
18
  format?: DescribeFormat;
13
19
  part?: DescribePart;
14
20
  }) => string;
@@ -5,6 +5,7 @@
5
5
  */
6
6
  import * as fs from "node:fs";
7
7
  import textframe from "textframe";
8
+ import { parseDocument, stringify, visit, isScalar } from "yaml";
8
9
  /* the supported description output formats and document parts */
9
10
  export const describeFormats = ["md", "raw"];
10
11
  export const describeParts = ["all", "meta", "schema", "spec"];
@@ -17,20 +18,58 @@ const parseChoice = (choices, kind, value) => {
17
18
  };
18
19
  export const parseDescribeFormat = (value) => parseChoice(describeFormats, "format", value);
19
20
  export const parseDescribePart = (value) => parseChoice(describeParts, "part", value);
21
+ /* the supported compression levels of the emitted YAML schema
22
+ configuration: verbatim (0), re-emitted with 2-space indentation and
23
+ without comments (1), additionally without its "refs" fields (2), and
24
+ additionally without its "desc" fields of objects and properties (3) */
25
+ export const compressLevels = [0, 1, 2, 3];
26
+ /* parse and validate a compression level specification,
27
+ where a bare flag selects the plain re-emitting */
28
+ export const parseCompressLevel = (value) => {
29
+ const level = typeof value === "boolean" ? (value ? 1 : 0) : Number(value);
30
+ if (!compressLevels.includes(level))
31
+ throw new Error(`unknown compress level "${value}" ` +
32
+ `(supported: ${compressLevels.join(", ")})`);
33
+ return level;
34
+ };
20
35
  /* provide the build-time bundled description of the generic
21
36
  SpecBook models and formats */
22
37
  const description = () => fs.readFileSync(new URL("specbook-format.md", import.meta.url), "utf8");
38
+ /* compress the YAML schema configuration text by re-emitting the parsed
39
+ document (block scalar styles retained) with 2-space indentation and
40
+ unwrapped lines, dropping all comments and the fields the level demands */
41
+ const compressYaml = (yaml, level) => {
42
+ const doc = parseDocument(yaml);
43
+ const drop = ["refs", "desc"].slice(0, level - 1);
44
+ doc.commentBefore = null;
45
+ doc.comment = null;
46
+ visit(doc, {
47
+ Node: (_, node) => {
48
+ node.commentBefore = null;
49
+ node.comment = null;
50
+ },
51
+ Pair: (_, pair) => isScalar(pair.key) && drop.includes(String(pair.key.value)) ? visit.REMOVE : undefined
52
+ });
53
+ return doc.toString({ indent: 2, lineWidth: 0 });
54
+ };
55
+ /* provide the YAML text of the schema configuration: the verbatim
56
+ content of its single file, or the merged configuration re-emitted
57
+ for several files (as no single file carries their merge) */
58
+ const schemaYaml = (config, schema) => config.length > 1 && schema !== undefined ?
59
+ stringify(schema, { indent: 2, lineWidth: 0 }) :
60
+ fs.readFileSync(config[0], "utf8");
23
61
  /* render the reference to (or the embedding of) the YAML schema configuration,
24
62
  where the bundled standard one is embedded without its leading comment block */
25
- const schemaSection = (config, embed, standard) => {
63
+ const schemaSection = (config, schema, embed, standard, compress) => {
26
64
  if (!embed)
27
- return textframe(`
28
- The **SpecBook SCHEMA Model** is the YAML schema configuration in file:
29
- @${config}
30
- `);
31
- let yaml = fs.readFileSync(config, "utf8");
65
+ return "The **SpecBook SCHEMA Model** is the YAML schema configuration " +
66
+ `${config.length > 1 ? "merged in order out of the files" : "in file"}:\n` +
67
+ config.map((file) => `@${file}\n`).join("");
68
+ let yaml = schemaYaml(config, schema);
32
69
  if (standard)
33
70
  yaml = yaml.replace(/^(?:[ \t]*##.*\n)+\s*/, "");
71
+ if (compress > 0)
72
+ yaml = compressYaml(yaml, compress);
34
73
  return textframe(`
35
74
  The **SpecBook SCHEMA Model** is the following YAML schema configuration:
36
75
  `) + "\n```yaml\n" + yaml.replace(/\n*$/, "\n") + "```\n\n";
@@ -45,10 +84,14 @@ const specSection = (basedir) => textframe(`
45
84
  artifacts of the particular project instantiation, and optionally
46
85
  reduced to a single part or to the raw original file content, where
47
86
  "standard" marks the configuration as the bundled standard one,
48
- which still belongs to the generic description */
87
+ which still belongs to the generic description, where "schema" is
88
+ the configuration loaded out of the "config" files (required for
89
+ embedding several files), and where "compress" emits the
90
+ configuration compressed by the given level instead of verbatim */
49
91
  export const describeFormat = (options) => {
50
92
  const format = options.format ?? "md";
51
93
  const part = options.part ?? "all";
94
+ const compress = options.compress ?? 1;
52
95
  if (!describeFormats.includes(format))
53
96
  throw new Error(`unknown describe format "${format}"`);
54
97
  if (!describeParts.includes(part))
@@ -63,8 +106,10 @@ export const describeFormat = (options) => {
63
106
  return description();
64
107
  else if (options.config === undefined)
65
108
  throw new Error("format \"raw\" for part \"schema\" requires a YAML schema configuration");
66
- else
67
- return fs.readFileSync(options.config, "utf8");
109
+ else {
110
+ const yaml = schemaYaml(options.config, options.schema);
111
+ return compress > 0 ? compressYaml(yaml, compress) : yaml;
112
+ }
68
113
  }
69
114
  /* the md format renders a single part standalone */
70
115
  if (part === "meta")
@@ -72,7 +117,7 @@ export const describeFormat = (options) => {
72
117
  else if (part === "schema") {
73
118
  if (options.config === undefined)
74
119
  throw new Error("part \"schema\" requires a YAML schema configuration");
75
- return schemaSection(options.config, options.embed === true, options.standard === true);
120
+ return schemaSection(options.config, options.schema, options.embed === true, options.standard === true, compress);
76
121
  }
77
122
  else if (part === "spec") {
78
123
  if (options.basedir === undefined)
@@ -85,7 +130,7 @@ export const describeFormat = (options) => {
85
130
  placed under their own section */
86
131
  const sections = [description()];
87
132
  const schema = options.config !== undefined ?
88
- schemaSection(options.config, options.embed === true, options.standard === true) : undefined;
133
+ schemaSection(options.config, options.schema, options.embed === true, options.standard === true, compress) : undefined;
89
134
  if (schema !== undefined || options.basedir !== undefined) {
90
135
  sections.push("SpecBook Project Instantiation\n" +
91
136
  "==============================\n");
@@ -7,4 +7,5 @@ export declare const parseOutputSpec: (spec: string) => {
7
7
  format: ExportFormat;
8
8
  output: string;
9
9
  };
10
- export declare const exportSpecification: (specification: Spec, format: ExportFormat, verbose: Verbose, config?: Schema) => Promise<Buffer>;
10
+ export declare const watchSpecification: (run: () => Promise<string[]>, verbose: Verbose) => Promise<void>;
11
+ export declare const exportSpecification: (specification: Spec, format: ExportFormat, verbose: Verbose, config?: Schema, realtime?: boolean) => Promise<Buffer>;
@@ -3,6 +3,7 @@
3
3
  ** Copyright (c) 2026 Dr. Ralf S. Engelschall <rse@engelschall.com>
4
4
  ** Licensed under Apache 2.0 <https://spdx.org/licenses/Apache-2.0>
5
5
  */
6
+ import { watch } from "chokidar";
6
7
  import { minify } from "@swc/html";
7
8
  import { documentTitle, documentLogo, documentCharset, documentThemeTone, subsetStylesheet, documentPaperSize, paperStylesheet, charsetCodepoints } from "./specbook-export-common.js";
8
9
  import { themeColors, themeStylesheet, themeMapping } from "./specbook-theme.js";
@@ -46,8 +47,50 @@ export const parseOutputSpec = (spec) => {
46
47
  throw new Error(`unable to infer export format from output "${spec}" ` +
47
48
  "(use an explicit \"<format>:<filename>\" specification)");
48
49
  };
49
- /* export a specification into the requested format */
50
- export const exportSpecification = async (specification, format, verbose, config) => {
50
+ /* the quiet period the observed files have to stay silent for before a
51
+ burst of changes is coalesced into a single re-export */
52
+ const watchDelay = 1000;
53
+ /* keep a specification export in sync with its sources, where "run"
54
+ performs one export and returns the files to observe: the initial
55
+ export is performed up-front and every observed change triggers a
56
+ re-export once the sources fell silent again. The observed set is
57
+ re-synchronized after every run, as an edit can add or drop an
58
+ embedded asset. The returned promise settles once the initial export
59
+ is done, while the active watcher keeps the process alive afterwards */
60
+ export const watchSpecification = async (run, verbose) => {
61
+ /* perform the regular export before entering the observe loop */
62
+ let observed = await run();
63
+ const watcher = watch(observed, { ignoreInitial: true });
64
+ verbose(`observing ${literal(observed.length)} specification file(s) for changes`);
65
+ /* perform a re-export and re-synchronize the observed files */
66
+ const cycle = async () => {
67
+ const files = await run();
68
+ watcher.unwatch(observed.filter((file) => !files.includes(file)));
69
+ watcher.add(files.filter((file) => !observed.includes(file)));
70
+ observed = files;
71
+ };
72
+ /* restart the quiet period on every change, so a burst collapses
73
+ into a single re-export, and chain the runs onto each other, so a
74
+ change arriving during a run cannot start a concurrent one (the
75
+ chained catch keeps an unexpected failure from breaking the chain
76
+ and hence silently ending the observe loop) */
77
+ let chain = Promise.resolve();
78
+ let timer;
79
+ watcher.on("all", () => {
80
+ if (timer !== undefined)
81
+ clearTimeout(timer);
82
+ timer = setTimeout(() => {
83
+ timer = undefined;
84
+ chain = chain.then(cycle).catch((err) => {
85
+ verbose("re-export failed: " +
86
+ (err instanceof Error ? err.message : String(err)), "notice");
87
+ });
88
+ }, watchDelay);
89
+ });
90
+ };
91
+ /* export a specification into the requested format, where "realtime"
92
+ injects the client-side script of the live preview into the HTML */
93
+ export const exportSpecification = async (specification, format, verbose, config, realtime = false) => {
51
94
  if (!formats.includes(format))
52
95
  throw new Error(`unknown export format "${format}"`);
53
96
  verbose(`exporting specification as "${literal(format)}"`);
@@ -71,7 +114,7 @@ export const exportSpecification = async (specification, format, verbose, config
71
114
  const css = themeStylesheet(colors) + await subsetStylesheet(charset) + paperStylesheet(paper);
72
115
  if (format === "html") {
73
116
  /* compress the rendered HTML (whitespace, comments, and inline CSS/JS) */
74
- const html = await renderHtml(specification, config, undefined, css);
117
+ const html = await renderHtml(specification, config, undefined, css, realtime);
75
118
  const minified = await minify(Buffer.from(html, "utf8"), {
76
119
  collapseWhitespaces: "smart",
77
120
  removeComments: true,
@@ -3,7 +3,7 @@ import { type Diagnostic } from "./specbook-diagnostic.js";
3
3
  import { type Spec } from "./specbook-format-spec.js";
4
4
  import { type Schema } from "./specbook-format-schema.js";
5
5
  export interface LintOptions {
6
- config: string;
6
+ config: string[];
7
7
  basedir: string;
8
8
  verbose: Verbose;
9
9
  }
@@ -11,5 +11,6 @@ export interface LintResult {
11
11
  specification: Spec;
12
12
  diagnostics: Diagnostic[];
13
13
  config?: Schema;
14
+ files: string[];
14
15
  }
15
16
  export declare const lint: (options: LintOptions) => LintResult;
@@ -24,8 +24,9 @@ const schemaFiles = (config) => {
24
24
  against the configuration */
25
25
  export const lint = (options) => {
26
26
  const diagnostics = new Array();
27
- /* load the mandatory YAML schema configuration */
28
- options.verbose(`loading schema configuration "${literal(options.config)}"`);
27
+ /* load the mandatory YAML schema configuration, merged out of its files */
28
+ options.verbose("loading schema configuration " +
29
+ options.config.map((file) => `"${literal(file)}"`).join(", "));
29
30
  const loaded = loadConfig(options.config);
30
31
  diagnostics.push(...loaded.diagnostics);
31
32
  const config = loaded.config;
@@ -34,15 +35,17 @@ export const lint = (options) => {
34
35
  its artifacts are optional */
35
36
  const files = config !== undefined ? schemaFiles(config) : new Map();
36
37
  if (config !== undefined && files.size === 0)
37
- diagnostics.push({ file: options.config, line: 1, column: 1,
38
+ diagnostics.push({ file: options.config[0], line: 1, column: 1, severity: "error",
38
39
  message: "no artifact files configured" });
39
40
  const sources = new Array();
40
41
  const present = new Set();
42
+ const watched = new Array();
41
43
  for (const [name, optional] of files) {
42
44
  const file = path.join(options.basedir, name);
45
+ watched.push(path.resolve(file));
43
46
  if (!fs.existsSync(file)) {
44
47
  if (!optional)
45
- diagnostics.push({ file, line: 1, column: 1,
48
+ diagnostics.push({ file, line: 1, column: 1, severity: "error",
46
49
  message: "missing artifact file" });
47
50
  continue;
48
51
  }
@@ -51,7 +54,7 @@ export const lint = (options) => {
51
54
  present.add(name);
52
55
  }
53
56
  catch (err) {
54
- diagnostics.push({ file, line: 1, column: 1,
57
+ diagnostics.push({ file, line: 1, column: 1, severity: "error",
55
58
  message: `unreadable file: ${err instanceof Error ? err.message : String(err)}` });
56
59
  }
57
60
  }
@@ -60,6 +63,7 @@ export const lint = (options) => {
60
63
  `below "${literal(options.basedir)}"`);
61
64
  const result = parseSpecification(sources, config);
62
65
  diagnostics.push(...result.diagnostics);
66
+ watched.push(...result.assets);
63
67
  /* report the non-optional artifacts absent from the specification,
64
68
  against their loaded artifact file (an absent or unreadable file
65
69
  is already reported above) or else against the configuration */
@@ -75,11 +79,11 @@ export const lint = (options) => {
75
79
  if (schema.optional === true || found.has(schema)
76
80
  || (schema.file !== undefined && !present.has(schema.file)))
77
81
  continue;
78
- const file = schema.file !== undefined ? path.join(options.basedir, schema.file) : options.config;
82
+ const file = schema.file !== undefined ? path.join(options.basedir, schema.file) : options.config[0];
79
83
  const paren = schema.id !== undefined ? ` (${schema.id})` : "";
80
- diagnostics.push({ file, line: 1, column: 1,
84
+ diagnostics.push({ file, line: 1, column: 1, severity: "error",
81
85
  message: `missing artifact "${schema.kind}: ${schema.name ?? ""}${paren}"` });
82
86
  }
83
87
  }
84
- return { specification: result.specification, diagnostics, config };
88
+ return { specification: result.specification, diagnostics, config, files: watched };
85
89
  };
@@ -0,0 +1,12 @@
1
+ import { type Verbose } from "./specbook-verbose.js";
2
+ export declare const previewAddr = "127.0.0.1";
3
+ export declare const previewPort = 12345;
4
+ export interface PreviewOptions {
5
+ addr: string;
6
+ port: number;
7
+ verbose: Verbose;
8
+ }
9
+ export interface PreviewServer {
10
+ update: (html: Buffer) => void;
11
+ }
12
+ export declare const servePreview: (options: PreviewOptions) => Promise<PreviewServer>;
@@ -0,0 +1,57 @@
1
+ /*
2
+ ** Specification Book (SpecBook)
3
+ ** Copyright (c) 2026 Dr. Ralf S. Engelschall <rse@engelschall.com>
4
+ ** Licensed under Apache 2.0 <https://spdx.org/licenses/Apache-2.0>
5
+ */
6
+ import Fastify from "fastify";
7
+ import fastifyWebsocket from "@fastify/websocket";
8
+ import { literal } from "./specbook-verbose.js";
9
+ /* the default listening address and port of the preview server */
10
+ export const previewAddr = "127.0.0.1";
11
+ export const previewPort = 12345;
12
+ /* serve the live HTML preview: a plain GET on "/" answers with the
13
+ most recent HTML export (or with 503 before the first successful
14
+ one), while a WebSocket upgrade on "/" subscribes the client to the
15
+ "RELOAD" command every fresh export broadcasts */
16
+ export const servePreview = async (options) => {
17
+ let html;
18
+ const clients = new Set();
19
+ const fastify = Fastify();
20
+ await fastify.register(fastifyWebsocket);
21
+ fastify.route({
22
+ method: "GET",
23
+ url: "/",
24
+ handler: (_request, reply) => {
25
+ if (html === undefined)
26
+ return reply.code(503).type("text/plain; charset=utf-8")
27
+ .send("no specification export available yet\n");
28
+ return reply.type("text/html; charset=utf-8").send(html);
29
+ },
30
+ wsHandler: (socket, request) => {
31
+ /* identify the client by its remote address and port */
32
+ const addr = request.socket.remoteAddress ?? "unknown";
33
+ const client = `${addr.includes(":") ? `[${addr}]` : addr}:${request.socket.remotePort ?? 0}`;
34
+ options.verbose(`client ${literal(client)} connected`, "notice");
35
+ clients.add(socket);
36
+ socket.on("close", () => {
37
+ clients.delete(socket);
38
+ options.verbose(`client ${literal(client)} disconnected`, "notice");
39
+ });
40
+ }
41
+ });
42
+ await fastify.listen({ host: options.addr, port: options.port });
43
+ const url = `http://${options.addr}:${options.port}/`;
44
+ options.verbose(`listening on ${literal(url)}`, "notice");
45
+ return {
46
+ update: (buffer) => {
47
+ /* the first export turns the 503 answers into the document */
48
+ if (html === undefined)
49
+ options.verbose(`serving ${literal(url)}`, "notice");
50
+ html = buffer;
51
+ options.verbose(`notifying ${literal(clients.size)} preview client(s)`);
52
+ for (const client of clients)
53
+ if (client.readyState === client.OPEN)
54
+ client.send("RELOAD");
55
+ }
56
+ };
57
+ };
@@ -1,6 +1,6 @@
1
1
  import { Schema } from "./specbook-format-schema.js";
2
2
  import { type Diagnostic } from "./specbook-diagnostic.js";
3
- export declare const loadConfig: (file: string) => {
3
+ export declare const loadConfig: (files: string[]) => {
4
4
  config?: Schema;
5
5
  diagnostics: Diagnostic[];
6
6
  };