@rse/specbook 1.0.2 → 1.0.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 +106 -19
- package/dst/specbook-api.d.ts +32 -12
- package/dst/specbook-api.js +114 -33
- package/dst/specbook-cli.js +92 -31
- package/dst/specbook-cmd-describe.d.ts +7 -1
- package/dst/specbook-cmd-describe.js +56 -11
- package/dst/specbook-cmd-export.d.ts +3 -1
- package/dst/specbook-cmd-export.js +46 -3
- package/dst/specbook-cmd-init.d.ts +2 -1
- package/dst/specbook-cmd-lint.d.ts +4 -2
- package/dst/specbook-cmd-lint.js +12 -8
- package/dst/specbook-cmd-preview.d.ts +12 -0
- package/dst/specbook-cmd-preview.js +57 -0
- package/dst/specbook-config.d.ts +1 -1
- package/dst/specbook-config.js +187 -37
- package/dst/specbook-diagnostic.d.ts +2 -0
- package/dst/specbook-diagnostic.js +4 -2
- package/dst/specbook-diagram.d.ts +1 -0
- package/dst/specbook-diagram.js +0 -0
- package/dst/specbook-export-ast.js +4 -2
- package/dst/specbook-export-common.js +5 -3
- package/dst/specbook-export-html-search.js +118 -30
- package/dst/specbook-export-html.css +1140 -233
- package/dst/specbook-export-html.d.ts +1 -1
- package/dst/specbook-export-html.js +764 -120
- package/dst/specbook-export-md.js +4 -2
- package/dst/specbook-export-pdf.d.ts +4 -1
- package/dst/specbook-export-pdf.js +46 -16
- package/dst/specbook-format-schema.d.ts +33 -1
- package/dst/specbook-format-schema.js +34 -2
- package/dst/specbook-format.md +193 -25
- package/dst/specbook-format.yaml +2291 -299
- package/dst/specbook-link.d.ts +2 -1
- package/dst/specbook-link.js +43 -3
- package/dst/specbook-mcp.js +20 -11
- package/dst/specbook-parse-common.d.ts +4 -2
- package/dst/specbook-parse-common.js +6 -3
- package/dst/specbook-parse-semantic.js +290 -30
- package/dst/specbook-parse-syntax.js +3 -1
- package/dst/specbook-parse-value.d.ts +1 -0
- package/dst/specbook-parse-value.js +17 -3
- package/dst/specbook-parse.js +1 -1
- package/dst/specbook-verbose.d.ts +2 -0
- package/package.json +14 -6
package/dst/specbook-cli.js
CHANGED
|
@@ -5,26 +5,29 @@
|
|
|
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
|
|
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
|
|
14
15
|
the tool name plus the scope path of the emitting command, where
|
|
15
|
-
Chalk styles the output only if the terminal supports colors
|
|
16
|
-
|
|
17
|
-
|
|
16
|
+
Chalk styles the output only if the terminal supports colors; the
|
|
17
|
+
"notice" messages pass regardless of the verbose option, as they
|
|
18
|
+
report environment problems the user has to see */
|
|
19
|
+
const verboseOf = (opts, ...scope) => (cmd, msg, level) => {
|
|
20
|
+
if (opts.verbose || level === "notice") {
|
|
18
21
|
const scopePath = [...scope, cmd].map((segment) => chalk.bold(segment)).join(": ");
|
|
19
22
|
process.stderr.write(`specbook: ${scopePath}: ${renderVerbose(msg, chalk.blue)}\n`);
|
|
20
23
|
}
|
|
21
24
|
};
|
|
22
|
-
/* write a buffer to
|
|
23
|
-
fires once the data has been flushed to the underlying
|
|
24
|
-
subsequent "process.exit" cannot truncate the output */
|
|
25
|
-
const
|
|
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) => {
|
|
26
29
|
return new Promise((resolve, reject) => {
|
|
27
|
-
|
|
30
|
+
stream.write(data, (err) => {
|
|
28
31
|
if (err)
|
|
29
32
|
reject(err);
|
|
30
33
|
else
|
|
@@ -32,13 +35,15 @@ const writeStdout = (data) => {
|
|
|
32
35
|
});
|
|
33
36
|
});
|
|
34
37
|
};
|
|
38
|
+
const writeStdout = (data) => writeStream(process.stdout, data);
|
|
39
|
+
const writeStderr = (data) => writeStream(process.stderr, data);
|
|
35
40
|
/* write a command result to the output file or stdout */
|
|
36
41
|
const writeOutput = async (output, data, cmd, verbose) => {
|
|
37
42
|
if (output === "-")
|
|
38
43
|
await writeStdout(data);
|
|
39
44
|
else {
|
|
40
45
|
await fs.promises.writeFile(output, data);
|
|
41
|
-
verbose(cmd, `wrote "${literal(output)}" (${literal(Buffer.byteLength(data))} bytes)
|
|
46
|
+
verbose(cmd, `wrote "${literal(output)}" (${literal(Buffer.byteLength(data))} bytes)`, "debug");
|
|
42
47
|
}
|
|
43
48
|
};
|
|
44
49
|
/* determine an option default value, overridable via a
|
|
@@ -53,17 +58,36 @@ const envDefaultFlag = (name, fallback) => {
|
|
|
53
58
|
/* provide the verbose option of all sub-commands */
|
|
54
59
|
const withVerboseOption = (command) => command
|
|
55
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);
|
|
56
70
|
/* provide the common options of the specification processing sub-commands,
|
|
57
71
|
for which the YAML schema configuration falls back onto the standard one */
|
|
58
|
-
const withCommonOptions = (command) => withVerboseOption(command)
|
|
59
|
-
.option("-c, --config <yaml-file>", "YAML schema configuration file " +
|
|
60
|
-
"(default: the bundled standard schema configuration)", envDefault("config"))
|
|
72
|
+
const withCommonOptions = (command) => withConfigOption(withVerboseOption(command), "the bundled standard schema configuration")
|
|
61
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 = "";
|
|
62
81
|
/* parse the command line */
|
|
63
82
|
const program = new Command();
|
|
64
83
|
program.name("specbook")
|
|
65
84
|
.description("Markdown-based Specification Format")
|
|
66
85
|
.version(version)
|
|
86
|
+
.configureOutput({
|
|
87
|
+
writeOut: (str) => { commanderOut += str; },
|
|
88
|
+
writeErr: (str) => { commanderErr += str; }
|
|
89
|
+
})
|
|
90
|
+
.exitOverride()
|
|
67
91
|
.action(() => {
|
|
68
92
|
program.help();
|
|
69
93
|
});
|
|
@@ -71,7 +95,7 @@ program.name("specbook")
|
|
|
71
95
|
withVerboseOption(program.command("mcp"))
|
|
72
96
|
.description("run as MCP stdio server")
|
|
73
97
|
.action(async (opts) => {
|
|
74
|
-
verboseOf(opts)("mcp", "starting MCP server on stdio");
|
|
98
|
+
verboseOf(opts)("mcp", "starting MCP server on stdio", "debug");
|
|
75
99
|
await serveMcp(verboseOf(opts, "mcp"));
|
|
76
100
|
});
|
|
77
101
|
/* the init command creates the configured artifact files */
|
|
@@ -79,58 +103,86 @@ withCommonOptions(program.command("init"))
|
|
|
79
103
|
.description("initialize the configured specification artifact files below the base directory")
|
|
80
104
|
.action(async (opts) => {
|
|
81
105
|
const specbook = new SpecBook({ verbose: verboseOf(opts) });
|
|
82
|
-
const created = await specbook.init({ config: opts
|
|
106
|
+
const created = await specbook.init({ config: configOf(opts), basedir: opts.basedir });
|
|
83
107
|
await writeStdout(created.length > 0 ?
|
|
84
108
|
`initialized artifact file(s): ${created.join(", ")}\n` :
|
|
85
109
|
"no artifact files were created\n");
|
|
86
110
|
});
|
|
87
|
-
/* the lint command reports all diagnostics and fails on any
|
|
111
|
+
/* the lint command reports all diagnostics and fails on any error */
|
|
88
112
|
withCommonOptions(program.command("lint"))
|
|
89
113
|
.description("lint the specification Markdown files below the base directory")
|
|
90
114
|
.action(async (opts) => {
|
|
91
115
|
const specbook = new SpecBook({ verbose: verboseOf(opts) });
|
|
92
|
-
const result = await specbook.lint({ config: opts
|
|
116
|
+
const result = await specbook.lint({ config: configOf(opts), basedir: opts.basedir });
|
|
93
117
|
for (const diagnostic of result.diagnostics)
|
|
94
118
|
await writeStdout(opts.verbose ?
|
|
95
119
|
renderDiagnosticVerbose(diagnostic, process.stdout.isTTY === true) :
|
|
96
120
|
`${renderDiagnostic(diagnostic)}\n`);
|
|
97
|
-
if (result.diagnostics.
|
|
121
|
+
if (result.diagnostics.some((diagnostic) => diagnostic.severity === "error"))
|
|
98
122
|
process.exitCode = 1;
|
|
99
123
|
else
|
|
100
|
-
verboseOf(opts)("lint", "specification valid");
|
|
124
|
+
verboseOf(opts)("lint", "specification valid", "debug");
|
|
101
125
|
});
|
|
102
126
|
/* the export command parses the input once and writes every output */
|
|
103
127
|
withCommonOptions(program.command("export"))
|
|
104
128
|
.description("export the specification Markdown files below the base directory " +
|
|
105
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))
|
|
106
131
|
.option("-o, --output [<format>:]<output-file>", "output file (\"-\" for stdout, repeatable), with the format inferred " +
|
|
107
132
|
"from the filename extension unless explicitly prefixed", (value, previous) => previous.concat(value), new Array())
|
|
108
133
|
.action(async (opts) => {
|
|
109
134
|
const specbook = new SpecBook({ verbose: verboseOf(opts) });
|
|
110
135
|
const outputs = (opts.output.length > 0 ? opts.output : [envDefault("output") ?? "-"])
|
|
111
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)");
|
|
112
142
|
/* parse the input once and export each distinct format once */
|
|
113
143
|
const distinct = Array.from(new Set(outputs.map(({ format }) => format)));
|
|
114
|
-
const
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
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 });
|
|
118
167
|
});
|
|
119
168
|
/* the describe command also describes the generic SpecBook models and
|
|
120
169
|
formats alone, so its YAML schema configuration stays optional */
|
|
121
|
-
withVerboseOption(program.command("describe"))
|
|
122
|
-
.description("describe the SpecBook models and formats as Markdown")
|
|
123
|
-
.option("-c, --config <yaml-file>", "YAML schema configuration file " +
|
|
124
|
-
"(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")
|
|
125
172
|
.option("-b, --basedir <directory>", "base directory of the specification Markdown files", envDefault("basedir"))
|
|
126
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"))
|
|
127
178
|
.option("-f, --format <format>", `output format (${describeFormats.join(", ")})`, envDefault("format", "md"))
|
|
128
179
|
.option("-p, --part <part>", `document part (${describeParts.join(", ")})`, envDefault("part", "all"))
|
|
129
180
|
.option("-o, --output <markdown-file>", "output file (\"-\" for stdout)", envDefault("output", "-"))
|
|
130
181
|
.action(async (opts) => {
|
|
131
182
|
const specbook = new SpecBook({ verbose: verboseOf(opts) });
|
|
132
|
-
const text = await specbook.describe({ config: opts
|
|
133
|
-
embed: opts.embed,
|
|
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) });
|
|
134
186
|
await writeOutput(opts.output, text, "describe", verboseOf(opts));
|
|
135
187
|
});
|
|
136
188
|
/* run the command line program */
|
|
@@ -138,10 +190,19 @@ try {
|
|
|
138
190
|
await program.parseAsync();
|
|
139
191
|
}
|
|
140
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
|
+
}
|
|
141
201
|
/* include the cause chain, as e.g. fetch failures
|
|
142
202
|
carry the underlying error only in the cause */
|
|
143
203
|
let msg = err instanceof Error ? err.message : String(err);
|
|
144
204
|
for (let cause = err instanceof Error ? err.cause : undefined; cause instanceof Error; cause = cause.cause)
|
|
145
205
|
msg += `: ${cause.message}`;
|
|
146
|
-
|
|
206
|
+
await writeStderr(`specbook: ${chalk.red("ERROR:")} ${renderVerbose(msg, chalk.blue)}\n`);
|
|
207
|
+
process.exit(1);
|
|
147
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
|
|
28
|
-
|
|
29
|
-
|
|
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
|
-
|
|
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");
|
|
@@ -1,9 +1,11 @@
|
|
|
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
|
|
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
|
-
/*
|
|
50
|
-
|
|
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,
|
|
@@ -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:
|
|
6
|
+
verbose: Verbose;
|
|
6
7
|
}
|
|
7
8
|
export declare const initSpecification: (options: InitOptions) => string[];
|
|
@@ -1,14 +1,16 @@
|
|
|
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
|
-
config: string;
|
|
6
|
+
config: string[];
|
|
6
7
|
basedir: string;
|
|
7
|
-
verbose:
|
|
8
|
+
verbose: Verbose;
|
|
8
9
|
}
|
|
9
10
|
export interface LintResult {
|
|
10
11
|
specification: Spec;
|
|
11
12
|
diagnostics: Diagnostic[];
|
|
12
13
|
config?: Schema;
|
|
14
|
+
files: string[];
|
|
13
15
|
}
|
|
14
16
|
export declare const lint: (options: LintOptions) => LintResult;
|
package/dst/specbook-cmd-lint.js
CHANGED
|
@@ -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(
|
|
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
|
+
};
|
package/dst/specbook-config.d.ts
CHANGED
|
@@ -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: (
|
|
3
|
+
export declare const loadConfig: (files: string[]) => {
|
|
4
4
|
config?: Schema;
|
|
5
5
|
diagnostics: Diagnostic[];
|
|
6
6
|
};
|