@rse/specbook 1.1.0 → 1.2.1
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 +2 -1
- package/dst/specbook-api.js +19 -16
- package/dst/specbook-cli.js +14 -13
- package/dst/specbook-cmd-describe.js +20 -18
- package/dst/specbook-cmd-export.d.ts +3 -1
- package/dst/specbook-cmd-export.js +27 -9
- package/dst/specbook-cmd-preview.js +11 -2
- package/dst/specbook-config.js +90 -74
- package/dst/specbook-diagnostic.js +2 -1
- package/dst/specbook-diagram.d.ts +4 -0
- package/dst/specbook-diagram.js +0 -0
- package/dst/specbook-export-common.js +28 -14
- package/dst/specbook-export-html.d.ts +4 -2
- package/dst/specbook-export-html.js +89 -113
- package/dst/specbook-export-md.js +4 -3
- package/dst/specbook-export-pdf.d.ts +1 -1
- package/dst/specbook-export-pdf.js +11 -7
- package/dst/specbook-format.md +13 -9
- package/dst/specbook-format.yaml +1263 -676
- package/dst/specbook-link.d.ts +2 -0
- package/dst/specbook-link.js +77 -52
- package/dst/specbook-mcp.js +5 -6
- package/dst/specbook-parse-common.d.ts +1 -0
- package/dst/specbook-parse-common.js +4 -0
- package/dst/specbook-parse-semantic.js +81 -71
- package/dst/specbook-parse-syntax.js +4 -3
- package/dst/specbook-parse.js +1 -1
- package/dst/specbook-theme.d.ts +0 -1
- package/dst/specbook-theme.js +2 -3
- package/package.json +11 -9
package/README.md
CHANGED
|
@@ -74,7 +74,8 @@ CLI with commands `specbook <xxx>`, and an MCP service with tools
|
|
|
74
74
|
theme toggle. It is intended for the day-to-day online reading during
|
|
75
75
|
development. The HTML export can map all object kinds to nested
|
|
76
76
|
sections or compact tables, or an automatic mixture which collapses
|
|
77
|
-
only the deepest level into tables.
|
|
77
|
+
only the deepest level into tables. The HTML export even can be
|
|
78
|
+
previewed in the browser with live updates.
|
|
78
79
|
|
|
79
80
|
- **PDF Export for Customers**:
|
|
80
81
|
The PDF export prints the HTML rendering via Chromium and post-processes
|
package/dst/specbook-api.js
CHANGED
|
@@ -4,16 +4,16 @@
|
|
|
4
4
|
** Licensed under Apache 2.0 <https://spdx.org/licenses/Apache-2.0>
|
|
5
5
|
*/
|
|
6
6
|
import * as fs from "node:fs";
|
|
7
|
+
import * as path from "node:path";
|
|
7
8
|
import { fileURLToPath } from "node:url";
|
|
8
9
|
import { glob } from "glob";
|
|
9
10
|
import { loadConfig } from "./specbook-config.js";
|
|
10
11
|
import { renderDiagnostic, renderDiagnosticVerbose } from "./specbook-diagnostic.js";
|
|
11
12
|
import { initSpecification } from "./specbook-cmd-init.js";
|
|
12
13
|
import { lint } from "./specbook-cmd-lint.js";
|
|
13
|
-
import { exportSpecification, watchSpecification, parseOutputSpec, formats } from "./specbook-cmd-export.js";
|
|
14
|
+
import { exportSpecification, watchSpecification, parseOutputSpec, formats, requireBrowser } from "./specbook-cmd-export.js";
|
|
14
15
|
import { servePreview, previewAddr, previewPort } from "./specbook-cmd-preview.js";
|
|
15
16
|
import { describeFormat, describeFormats, describeParts, parseDescribeFormat, parseDescribePart, compressLevels, parseCompressLevel } from "./specbook-cmd-describe.js";
|
|
16
|
-
import { requireBrowser } from "./specbook-export-pdf.js";
|
|
17
17
|
import { literal, renderVerbose } from "./specbook-verbose.js";
|
|
18
18
|
/* re-export the central types for API consumers */
|
|
19
19
|
export { literal, renderVerbose };
|
|
@@ -53,7 +53,8 @@ export class SpecBook {
|
|
|
53
53
|
files.push(standardConfig);
|
|
54
54
|
continue;
|
|
55
55
|
}
|
|
56
|
-
const matches = await glob(pattern, { nodir: true
|
|
56
|
+
const matches = await glob(pattern, { nodir: true,
|
|
57
|
+
windowsPathsNoEscape: process.platform === "win32" });
|
|
57
58
|
if (matches.length === 0)
|
|
58
59
|
throw new Error(`no configuration file matches "${pattern}"`);
|
|
59
60
|
files.push(...matches.sort());
|
|
@@ -87,7 +88,7 @@ export class SpecBook {
|
|
|
87
88
|
invalid specification must never be emitted, while the warnings
|
|
88
89
|
are just surfaced as notices), where "realtime" injects the
|
|
89
90
|
client-side script of the live preview into the HTML */
|
|
90
|
-
async renderFormats(result,
|
|
91
|
+
async renderFormats(result, requested, verbose, realtime) {
|
|
91
92
|
if (result.diagnostics.some((diagnostic) => diagnostic.severity === "error"))
|
|
92
93
|
throw new Error("invalid specification:\n" +
|
|
93
94
|
result.diagnostics.map(renderDiagnostic).join("\n"));
|
|
@@ -96,7 +97,7 @@ export class SpecBook {
|
|
|
96
97
|
if (result.specification.artifacts.length === 0)
|
|
97
98
|
throw new Error("unexportable specification: no artifacts found");
|
|
98
99
|
const buffers = new Array();
|
|
99
|
-
for (const format of
|
|
100
|
+
for (const format of requested)
|
|
100
101
|
buffers.push(await exportSpecification(result.specification, format, verbose, result.config, realtime));
|
|
101
102
|
return buffers;
|
|
102
103
|
}
|
|
@@ -115,19 +116,21 @@ export class SpecBook {
|
|
|
115
116
|
basedir: options.basedir ?? ".", verbose }), requested, verbose, options.realtime === true);
|
|
116
117
|
}
|
|
117
118
|
/* keep an export in sync with its sources (the shared core of
|
|
118
|
-
"watch" and "preview"): every change of
|
|
119
|
-
of its embedded assets re-exports the
|
|
120
|
-
buffers to "onExport" once the sources
|
|
121
|
-
re-export is reported and leaves the
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
the active watcher keeps the
|
|
119
|
+
"watch" and "preview"): every change of a configuration file, of an
|
|
120
|
+
artifact file, or of one of its embedded assets re-exports the
|
|
121
|
+
specification and hands the buffers to "onExport" once the sources
|
|
122
|
+
fell silent again. A failed re-export is reported and leaves the
|
|
123
|
+
observe loop intact, so a transiently invalid specification (or
|
|
124
|
+
configuration) does not end the watch. The returned promise settles
|
|
125
|
+
once the initial export is done, while the active watcher keeps the
|
|
126
|
+
process alive afterwards */
|
|
125
127
|
async observe(options, verbose) {
|
|
126
128
|
const config = await this.configFiles(options.config);
|
|
127
129
|
return watchSpecification(async () => {
|
|
128
130
|
/* the lint result carries the files to observe even for an
|
|
129
|
-
invalid specification,
|
|
130
|
-
|
|
131
|
+
invalid specification, and the configuration files are
|
|
132
|
+
observed, too, so a failing export (even one due to an
|
|
133
|
+
invalid configuration) still keeps the observe loop fed */
|
|
131
134
|
const result = lint({ config, basedir: options.basedir ?? ".", verbose });
|
|
132
135
|
try {
|
|
133
136
|
await options.onExport(await this.renderFormats(result, options.formats, verbose, options.realtime));
|
|
@@ -136,7 +139,7 @@ export class SpecBook {
|
|
|
136
139
|
verbose("export failed: " +
|
|
137
140
|
(err instanceof Error ? err.message : String(err)), "notice");
|
|
138
141
|
}
|
|
139
|
-
return result.files;
|
|
142
|
+
return [...config.map((file) => path.resolve(file)), ...result.files];
|
|
140
143
|
}, verbose);
|
|
141
144
|
}
|
|
142
145
|
/* export the specification like "export" and then keep the export
|
|
@@ -175,7 +178,7 @@ export class SpecBook {
|
|
|
175
178
|
they fall back onto the bundled standard one, which is always
|
|
176
179
|
embedded, as its bundled file is no meaningful reference */
|
|
177
180
|
const given = options.config !== undefined && options.config.length > 0;
|
|
178
|
-
const standard = !given && (part === "all" || part === "schema"
|
|
181
|
+
const standard = !given && (part === "all" || part === "schema");
|
|
179
182
|
const config = given || standard ? await this.configFiles(options.config) : undefined;
|
|
180
183
|
const embed = options.embed === true || standard;
|
|
181
184
|
const basedir = options.basedir ?? (part === "spec" ? "." : undefined);
|
package/dst/specbook-cli.js
CHANGED
|
@@ -25,16 +25,14 @@ const verboseOf = (opts, ...scope) => (cmd, msg, level) => {
|
|
|
25
25
|
/* write a buffer to a standard stream, awaiting the write callback
|
|
26
26
|
which only fires once the data has been flushed to the underlying
|
|
27
27
|
pipe, so a subsequent "process.exit" cannot truncate the output */
|
|
28
|
-
const writeStream = (stream, data) => {
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
resolve();
|
|
35
|
-
});
|
|
28
|
+
const writeStream = (stream, data) => new Promise((resolve, reject) => {
|
|
29
|
+
stream.write(data, (err) => {
|
|
30
|
+
if (err)
|
|
31
|
+
reject(err);
|
|
32
|
+
else
|
|
33
|
+
resolve();
|
|
36
34
|
});
|
|
37
|
-
};
|
|
35
|
+
});
|
|
38
36
|
const writeStdout = (data) => writeStream(process.stdout, data);
|
|
39
37
|
const writeStderr = (data) => writeStream(process.stderr, data);
|
|
40
38
|
/* write a command result to the output file or stdout */
|
|
@@ -48,7 +46,10 @@ const writeOutput = async (output, data, cmd, verbose) => {
|
|
|
48
46
|
};
|
|
49
47
|
/* determine an option default value, overridable via a
|
|
50
48
|
corresponding SPECBOOK_<OPTION> environment variable */
|
|
51
|
-
const envDefault = (name, fallback) =>
|
|
49
|
+
const envDefault = (name, fallback) => {
|
|
50
|
+
const value = process.env[`SPECBOOK_${name.toUpperCase().replace(/-/g, "_")}`];
|
|
51
|
+
return value !== undefined && value !== "" ? value : fallback;
|
|
52
|
+
};
|
|
52
53
|
/* determine a flag option default value, overridable via a
|
|
53
54
|
corresponding SPECBOOK_<OPTION> environment variable */
|
|
54
55
|
const envDefaultFlag = (name, fallback) => {
|
|
@@ -160,15 +161,15 @@ withCommonOptions(program.command("preview"))
|
|
|
160
161
|
.option("-p, --port <tcp-port>", "TCP port to listen on", envDefault("port", String(previewPort)))
|
|
161
162
|
.action(async (opts) => {
|
|
162
163
|
const port = Number(opts.port);
|
|
163
|
-
if (!Number.isInteger(port))
|
|
164
|
+
if (!Number.isInteger(port) || port < 1 || port > 65535)
|
|
164
165
|
throw new Error(`invalid TCP port "${opts.port}"`);
|
|
165
166
|
const specbook = new SpecBook({ verbose: verboseOf(opts) });
|
|
166
167
|
await specbook.preview({ config: configOf(opts), basedir: opts.basedir, addr: opts.addr, port });
|
|
167
168
|
});
|
|
168
169
|
/* the describe command also describes the generic SpecBook models and
|
|
169
170
|
formats alone, so its YAML schema configuration stays optional */
|
|
170
|
-
withConfigOption(withVerboseOption(program.command("describe"))
|
|
171
|
-
.description("describe the SpecBook models and formats as Markdown")
|
|
171
|
+
withConfigOption(withVerboseOption(program.command("describe")), "the bundled standard schema configuration, embedded")
|
|
172
|
+
.description("describe the SpecBook models and formats as Markdown")
|
|
172
173
|
.option("-b, --basedir <directory>", "base directory of the specification Markdown files", envDefault("basedir"))
|
|
173
174
|
.option("-e, --embed", "embed the given YAML schema configuration instead of just referencing it", envDefaultFlag("embed", false))
|
|
174
175
|
.option("-z, --compress [level]", "compression level of the emitted YAML schema configuration " +
|
|
@@ -26,7 +26,8 @@ export const compressLevels = [0, 1, 2, 3];
|
|
|
26
26
|
/* parse and validate a compression level specification,
|
|
27
27
|
where a bare flag selects the plain re-emitting */
|
|
28
28
|
export const parseCompressLevel = (value) => {
|
|
29
|
-
const level = typeof value === "boolean" ? (value ? 1 : 0) :
|
|
29
|
+
const level = typeof value === "boolean" ? (value ? 1 : 0) :
|
|
30
|
+
typeof value === "string" && !(/^\d+$/).test(value) ? NaN : Number(value);
|
|
30
31
|
if (!compressLevels.includes(level))
|
|
31
32
|
throw new Error(`unknown compress level "${value}" ` +
|
|
32
33
|
`(supported: ${compressLevels.join(", ")})`);
|
|
@@ -39,6 +40,8 @@ const description = () => fs.readFileSync(new URL("specbook-format.md", import.m
|
|
|
39
40
|
document (block scalar styles retained) with 2-space indentation and
|
|
40
41
|
unwrapped lines, dropping all comments and the fields the level demands */
|
|
41
42
|
const compressYaml = (yaml, level) => {
|
|
43
|
+
if (level === 0)
|
|
44
|
+
return yaml;
|
|
42
45
|
const doc = parseDocument(yaml);
|
|
43
46
|
const drop = ["refs", "desc"].slice(0, level - 1);
|
|
44
47
|
doc.commentBefore = null;
|
|
@@ -55,9 +58,13 @@ const compressYaml = (yaml, level) => {
|
|
|
55
58
|
/* provide the YAML text of the schema configuration: the verbatim
|
|
56
59
|
content of its single file, or the merged configuration re-emitted
|
|
57
60
|
for several files (as no single file carries their merge) */
|
|
58
|
-
const schemaYaml = (config, schema) =>
|
|
59
|
-
|
|
60
|
-
|
|
61
|
+
const schemaYaml = (config, schema) => {
|
|
62
|
+
if (config.length === 1)
|
|
63
|
+
return fs.readFileSync(config[0], "utf8");
|
|
64
|
+
if (schema === undefined)
|
|
65
|
+
throw new Error("re-emitting the merged YAML schema configuration requires its loaded configuration");
|
|
66
|
+
return stringify(schema, { indent: 2, lineWidth: 0 });
|
|
67
|
+
};
|
|
61
68
|
/* render the reference to (or the embedding of) the YAML schema configuration,
|
|
62
69
|
where the bundled standard one is embedded without its leading comment block */
|
|
63
70
|
const schemaSection = (config, schema, embed, standard, compress) => {
|
|
@@ -68,8 +75,7 @@ const schemaSection = (config, schema, embed, standard, compress) => {
|
|
|
68
75
|
let yaml = schemaYaml(config, schema);
|
|
69
76
|
if (standard)
|
|
70
77
|
yaml = yaml.replace(/^(?:[ \t]*##.*\n)+\s*/, "");
|
|
71
|
-
|
|
72
|
-
yaml = compressYaml(yaml, compress);
|
|
78
|
+
yaml = compressYaml(yaml, compress);
|
|
73
79
|
return textframe(`
|
|
74
80
|
The **SpecBook SCHEMA Model** is the following YAML schema configuration:
|
|
75
81
|
`) + "\n```yaml\n" + yaml.replace(/\n*$/, "\n") + "```\n\n";
|
|
@@ -89,13 +95,9 @@ const specSection = (basedir) => textframe(`
|
|
|
89
95
|
embedding several files), and where "compress" emits the
|
|
90
96
|
configuration compressed by the given level instead of verbatim */
|
|
91
97
|
export const describeFormat = (options) => {
|
|
92
|
-
const format = options.format ?? "md";
|
|
93
|
-
const part = options.part ?? "all";
|
|
94
|
-
const compress = options.compress ?? 1;
|
|
95
|
-
if (!describeFormats.includes(format))
|
|
96
|
-
throw new Error(`unknown describe format "${format}"`);
|
|
97
|
-
if (!describeParts.includes(part))
|
|
98
|
-
throw new Error(`unknown describe part "${part}"`);
|
|
98
|
+
const format = parseDescribeFormat(options.format ?? "md");
|
|
99
|
+
const part = parseDescribePart(options.part ?? "all");
|
|
100
|
+
const compress = parseCompressLevel(options.compress ?? 1);
|
|
99
101
|
/* the raw format emits the original file content of a single part,
|
|
100
102
|
so it is available for the file-backed parts only */
|
|
101
103
|
if (format === "raw") {
|
|
@@ -108,7 +110,7 @@ export const describeFormat = (options) => {
|
|
|
108
110
|
throw new Error("format \"raw\" for part \"schema\" requires a YAML schema configuration");
|
|
109
111
|
else {
|
|
110
112
|
const yaml = schemaYaml(options.config, options.schema);
|
|
111
|
-
return
|
|
113
|
+
return compressYaml(yaml, compress);
|
|
112
114
|
}
|
|
113
115
|
}
|
|
114
116
|
/* the md format renders a single part standalone */
|
|
@@ -129,13 +131,13 @@ export const describeFormat = (options) => {
|
|
|
129
131
|
artifacts of the project instantiation and hence are always
|
|
130
132
|
placed under their own section */
|
|
131
133
|
const sections = [description()];
|
|
132
|
-
const
|
|
134
|
+
const schemaText = options.config !== undefined ?
|
|
133
135
|
schemaSection(options.config, options.schema, options.embed === true, options.standard === true, compress) : undefined;
|
|
134
|
-
if (
|
|
136
|
+
if (schemaText !== undefined || options.basedir !== undefined) {
|
|
135
137
|
sections.push("SpecBook Project Instantiation\n" +
|
|
136
138
|
"==============================\n");
|
|
137
|
-
if (
|
|
138
|
-
sections.push(
|
|
139
|
+
if (schemaText !== undefined)
|
|
140
|
+
sections.push(schemaText);
|
|
139
141
|
if (options.basedir !== undefined)
|
|
140
142
|
sections.push(specSection(options.basedir));
|
|
141
143
|
}
|
|
@@ -1,6 +1,8 @@
|
|
|
1
1
|
import type { Spec } from "./specbook-format-spec.js";
|
|
2
2
|
import type { Schema } from "./specbook-format-schema.js";
|
|
3
|
-
import
|
|
3
|
+
import { requireBrowser } from "./specbook-export-pdf.js";
|
|
4
|
+
import { type Verbose } from "./specbook-verbose.js";
|
|
5
|
+
export { requireBrowser };
|
|
4
6
|
export declare const formats: readonly ["json", "json5", "yaml", "toon", "html", "pdf", "md"];
|
|
5
7
|
export type ExportFormat = typeof formats[number];
|
|
6
8
|
export declare const parseOutputSpec: (spec: string) => {
|
|
@@ -9,9 +9,12 @@ import { documentTitle, documentLogo, documentCharset, documentThemeTone, subset
|
|
|
9
9
|
import { themeColors, themeStylesheet, themeMapping } from "./specbook-theme.js";
|
|
10
10
|
import { renderAst } from "./specbook-export-ast.js";
|
|
11
11
|
import { renderMarkdown } from "./specbook-export-md.js";
|
|
12
|
-
import { renderHtml, htmlOutline } from "./specbook-export-html.js";
|
|
13
|
-
import { htmlToPdf } from "./specbook-export-pdf.js";
|
|
12
|
+
import { renderHtml, htmlOutline, titlePageObject } from "./specbook-export-html.js";
|
|
13
|
+
import { htmlToPdf, requireBrowser } from "./specbook-export-pdf.js";
|
|
14
14
|
import { literal } from "./specbook-verbose.js";
|
|
15
|
+
/* re-export the browser pre-check of the PDF renderer, so the API
|
|
16
|
+
facade reaches the renderers through this command module only */
|
|
17
|
+
export { requireBrowser };
|
|
15
18
|
/* the supported export formats */
|
|
16
19
|
export const formats = ["json", "json5", "yaml", "toon", "html", "pdf", "md"];
|
|
17
20
|
/* the format names and filename extensions mapping onto the export formats */
|
|
@@ -62,6 +65,12 @@ export const watchSpecification = async (run, verbose) => {
|
|
|
62
65
|
let observed = await run();
|
|
63
66
|
const watcher = watch(observed, { ignoreInitial: true });
|
|
64
67
|
verbose(`observing ${literal(observed.length)} specification file(s) for changes`);
|
|
68
|
+
/* report the failures of the observation itself, as an unhandled
|
|
69
|
+
"error" event would otherwise terminate the process */
|
|
70
|
+
watcher.on("error", (err) => {
|
|
71
|
+
verbose("observing failed: " +
|
|
72
|
+
(err instanceof Error ? err.message : String(err)), "notice");
|
|
73
|
+
});
|
|
65
74
|
/* perform a re-export and re-synchronize the observed files */
|
|
66
75
|
const cycle = async () => {
|
|
67
76
|
const files = await run();
|
|
@@ -88,12 +97,9 @@ export const watchSpecification = async (run, verbose) => {
|
|
|
88
97
|
}, watchDelay);
|
|
89
98
|
});
|
|
90
99
|
};
|
|
91
|
-
/*
|
|
100
|
+
/* render a specification into the requested format, where "realtime"
|
|
92
101
|
injects the client-side script of the live preview into the HTML */
|
|
93
|
-
|
|
94
|
-
if (!formats.includes(format))
|
|
95
|
-
throw new Error(`unknown export format "${format}"`);
|
|
96
|
-
verbose(`exporting specification as "${literal(format)}"`);
|
|
102
|
+
const renderFormat = async (specification, format, verbose, config, realtime = false) => {
|
|
97
103
|
if (format === "json" || format === "json5" || format === "yaml" || format === "toon")
|
|
98
104
|
return renderAst(specification, format, config);
|
|
99
105
|
else if (format === "md")
|
|
@@ -114,7 +120,7 @@ export const exportSpecification = async (specification, format, verbose, config
|
|
|
114
120
|
const css = themeStylesheet(colors) + await subsetStylesheet(charset) + paperStylesheet(paper);
|
|
115
121
|
if (format === "html") {
|
|
116
122
|
/* compress the rendered HTML (whitespace, comments, and inline CSS/JS) */
|
|
117
|
-
const html = await renderHtml(specification, config, undefined, css, realtime);
|
|
123
|
+
const html = await renderHtml(specification, config, undefined, css, realtime, verbose);
|
|
118
124
|
const minified = await minify(Buffer.from(html, "utf8"), {
|
|
119
125
|
collapseWhitespaces: "smart",
|
|
120
126
|
removeComments: true,
|
|
@@ -126,5 +132,17 @@ export const exportSpecification = async (specification, format, verbose, config
|
|
|
126
132
|
else
|
|
127
133
|
/* the PDF export (like print in general) always uses the light
|
|
128
134
|
theme, so its decoration colors are the light mapping, too */
|
|
129
|
-
return htmlToPdf((tocPages) => renderHtml(specification, config, tocPages, css), { ...documentTitle(specification), logo: documentLogo(specification) }, htmlOutline(specification, config), verbose, css, themeMapping(colors, "light"), paper);
|
|
135
|
+
return htmlToPdf((tocPages) => renderHtml(specification, config, tocPages, css, false, verbose), { ...documentTitle(specification), logo: documentLogo(specification) }, htmlOutline(specification, config), titlePageObject(specification) !== undefined, verbose, css, themeMapping(colors, "light"), paper);
|
|
136
|
+
};
|
|
137
|
+
/* export a specification into the requested format (see "renderFormat"),
|
|
138
|
+
reporting the duration of the rendering in the verbose output */
|
|
139
|
+
export const exportSpecification = async (specification, format, verbose, config, realtime = false) => {
|
|
140
|
+
if (!formats.includes(format))
|
|
141
|
+
throw new Error(`unknown export format "${format}"`);
|
|
142
|
+
verbose(`exporting specification as "${literal(format)}"`);
|
|
143
|
+
const started = performance.now();
|
|
144
|
+
const buffer = await renderFormat(specification, format, verbose, config, realtime);
|
|
145
|
+
const seconds = ((performance.now() - started) / 1000).toFixed(3);
|
|
146
|
+
verbose(`exported specification as "${literal(format)}" in ${literal(seconds)}s`);
|
|
147
|
+
return buffer;
|
|
130
148
|
};
|
|
@@ -18,6 +18,7 @@ export const servePreview = async (options) => {
|
|
|
18
18
|
const clients = new Set();
|
|
19
19
|
const fastify = Fastify();
|
|
20
20
|
await fastify.register(fastifyWebsocket);
|
|
21
|
+
/* answer a plain GET with the document and subscribe a WebSocket upgrade */
|
|
21
22
|
fastify.route({
|
|
22
23
|
method: "GET",
|
|
23
24
|
url: "/",
|
|
@@ -25,7 +26,8 @@ export const servePreview = async (options) => {
|
|
|
25
26
|
if (html === undefined)
|
|
26
27
|
return reply.code(503).type("text/plain; charset=utf-8")
|
|
27
28
|
.send("no specification export available yet\n");
|
|
28
|
-
return reply.type("text/html; charset=utf-8")
|
|
29
|
+
return reply.type("text/html; charset=utf-8")
|
|
30
|
+
.header("cache-control", "no-store").send(html);
|
|
29
31
|
},
|
|
30
32
|
wsHandler: (socket, request) => {
|
|
31
33
|
/* identify the client by its remote address and port */
|
|
@@ -37,10 +39,17 @@ export const servePreview = async (options) => {
|
|
|
37
39
|
clients.delete(socket);
|
|
38
40
|
options.verbose(`client ${literal(client)} disconnected`, "notice");
|
|
39
41
|
});
|
|
42
|
+
/* report a failing client, as an unhandled "error" event
|
|
43
|
+
would otherwise terminate the process */
|
|
44
|
+
socket.on("error", (err) => {
|
|
45
|
+
options.verbose(`client ${literal(client)} failed: ${err.message}`, "notice");
|
|
46
|
+
});
|
|
40
47
|
}
|
|
41
48
|
});
|
|
49
|
+
/* start listening and hand out the export feed */
|
|
42
50
|
await fastify.listen({ host: options.addr, port: options.port });
|
|
43
|
-
const
|
|
51
|
+
const host = options.addr.includes(":") ? `[${options.addr}]` : options.addr;
|
|
52
|
+
const url = `http://${host}:${options.port}/`;
|
|
44
53
|
options.verbose(`listening on ${literal(url)}`, "notice");
|
|
45
54
|
return {
|
|
46
55
|
update: (buffer) => {
|
package/dst/specbook-config.js
CHANGED
|
@@ -67,7 +67,7 @@ const mergeConfig = (target, source, list) => {
|
|
|
67
67
|
const posOfMergedPath = (docs, merged, path) => {
|
|
68
68
|
const at = (value, key) => value?.[key];
|
|
69
69
|
let best = { file: docs[docs.length - 1].file, line: 1, column: 1, path, depth: -1 };
|
|
70
|
-
for (const { file, doc, lines, raw } of docs.
|
|
70
|
+
for (const { file, doc, lines, raw } of docs.toReversed()) {
|
|
71
71
|
const translated = [];
|
|
72
72
|
let node = merged;
|
|
73
73
|
let own = raw;
|
|
@@ -89,6 +89,84 @@ const posOfMergedPath = (docs, merged, path) => {
|
|
|
89
89
|
return { file: best.file, line: best.line, column: best.column,
|
|
90
90
|
path: best.depth === path.length ? best.path : path };
|
|
91
91
|
};
|
|
92
|
+
/* check the properties of an object: the value expressions have to be
|
|
93
|
+
syntactically valid, the relation shape flags apply to
|
|
94
|
+
reference-valued properties only, and the sibling marker flags
|
|
95
|
+
optionally carry a regexp or enum expression */
|
|
96
|
+
const checkProperties = (props, at, diagnose) => {
|
|
97
|
+
for (const [j, prop] of props.entries()) {
|
|
98
|
+
/* the sibling marker flags optionally carry a regexp or
|
|
99
|
+
enum expression selecting the values they apply to */
|
|
100
|
+
for (const flag of ["unique", "present"]) {
|
|
101
|
+
const marker = prop[flag];
|
|
102
|
+
if (typeof marker !== "string")
|
|
103
|
+
continue;
|
|
104
|
+
let kind;
|
|
105
|
+
try {
|
|
106
|
+
kind = compileValueExpr(marker).kind;
|
|
107
|
+
}
|
|
108
|
+
catch (err) {
|
|
109
|
+
diagnose([...at, "props", j, flag], `invalid "${flag}" expression "${marker}": ` +
|
|
110
|
+
(err instanceof Error ? err.message : String(err)));
|
|
111
|
+
}
|
|
112
|
+
if (kind !== undefined && kind !== "regex" && kind !== "enum")
|
|
113
|
+
diagnose([...at, "props", j, flag], `"${flag}" expression "${marker}" is neither a regexp nor an enum`);
|
|
114
|
+
}
|
|
115
|
+
/* the relation shape flags apply to reference-valued
|
|
116
|
+
properties only (an invalid expression is reported on
|
|
117
|
+
its own, without a cascading flag diagnostic) */
|
|
118
|
+
let expr;
|
|
119
|
+
if (prop.value !== undefined) {
|
|
120
|
+
try {
|
|
121
|
+
expr = compileValueExpr(prop.value);
|
|
122
|
+
}
|
|
123
|
+
catch (err) {
|
|
124
|
+
diagnose([...at, "props", j, "value"], `invalid value expression "${prop.value}": ` +
|
|
125
|
+
(err instanceof Error ? err.message : String(err)));
|
|
126
|
+
continue;
|
|
127
|
+
}
|
|
128
|
+
}
|
|
129
|
+
for (const flag of ["local", "symmetric", "acyclic"])
|
|
130
|
+
if (prop[flag] === true && (expr === undefined || !admitsReferences(expr)))
|
|
131
|
+
diagnose([...at, "props", j, flag], `"${flag}" flag requires a reference-valued property`);
|
|
132
|
+
}
|
|
133
|
+
};
|
|
134
|
+
/* check the automaton of an object (if any): it names the child kinds
|
|
135
|
+
acting as nodes and edges, the reference-valued edge properties
|
|
136
|
+
referencing the source and target nodes, and the node properties
|
|
137
|
+
flagging the initial and final nodes */
|
|
138
|
+
const checkAutomaton = (object, at, diagnose) => {
|
|
139
|
+
if (object.automaton === undefined)
|
|
140
|
+
return;
|
|
141
|
+
const automaton = object.automaton;
|
|
142
|
+
const childOf = (kind) => (object.childs ?? []).find((c) => c.kind === kind);
|
|
143
|
+
const propOf = (child, name) => (child?.props ?? []).find((p) => p.name === name);
|
|
144
|
+
/* an invalid value expression is reported on its own (see
|
|
145
|
+
"checkProperties"), so it must not cascade into a shape diagnostic */
|
|
146
|
+
const isReference = (prop) => {
|
|
147
|
+
try {
|
|
148
|
+
return prop.value !== undefined && admitsReferences(compileValueExpr(prop.value));
|
|
149
|
+
}
|
|
150
|
+
catch {
|
|
151
|
+
return undefined;
|
|
152
|
+
}
|
|
153
|
+
};
|
|
154
|
+
const nodes = childOf(automaton.nodes);
|
|
155
|
+
const edges = childOf(automaton.edges);
|
|
156
|
+
for (const [field, child] of [["nodes", nodes], ["edges", edges]])
|
|
157
|
+
if (child === undefined)
|
|
158
|
+
diagnose([...at, "automaton", field], `"automaton" ${field} kind "${automaton[field]}" is not a child object kind`);
|
|
159
|
+
for (const field of ["source", "target"]) {
|
|
160
|
+
const prop = propOf(edges, automaton[field]);
|
|
161
|
+
if (edges !== undefined && prop === undefined)
|
|
162
|
+
diagnose([...at, "automaton", field], `"automaton" ${field} property "${automaton[field]}" is not a property of "${automaton.edges}"`);
|
|
163
|
+
else if (prop !== undefined && isReference(prop) === false)
|
|
164
|
+
diagnose([...at, "automaton", field], `"automaton" ${field} property "${automaton[field]}" is not reference-valued`);
|
|
165
|
+
}
|
|
166
|
+
for (const field of ["initial", "final"])
|
|
167
|
+
if (nodes !== undefined && propOf(nodes, automaton[field]) === undefined)
|
|
168
|
+
diagnose([...at, "automaton", field], `"automaton" ${field} property "${automaton[field]}" is not a property of "${automaton.nodes}"`);
|
|
169
|
+
};
|
|
92
170
|
/* check the constraints of a structurally valid configuration which
|
|
93
171
|
are beyond its schema: sibling objects have to stay distinctly
|
|
94
172
|
resolvable, "file" fields are allowed on the first (artifact) level
|
|
@@ -110,81 +188,15 @@ const checkConstraints = (config, posOfPath) => {
|
|
|
110
188
|
const seen = new Set();
|
|
111
189
|
for (const [i, object] of objects.entries()) {
|
|
112
190
|
const at = [...path, i];
|
|
113
|
-
const ident = depth > 1 ?
|
|
191
|
+
const ident = identityOf(object, depth > 1 ? "childs" : undefined);
|
|
114
192
|
if (seen.has(ident))
|
|
115
193
|
diagnose([...at, "kind"], `object "${ident}" collides with a preceding sibling object (the later one is unreachable)`);
|
|
116
194
|
else
|
|
117
195
|
seen.add(ident);
|
|
118
196
|
if (depth > 1 && object.file !== undefined)
|
|
119
197
|
diagnose([...at, "file"], `"file" field is only allowed on the first (artifact) level (found on level ${depth})`);
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
properties only (an invalid expression is reported on
|
|
123
|
-
its own, without a cascading flag diagnostic) */
|
|
124
|
-
let expr;
|
|
125
|
-
if (prop.value !== undefined) {
|
|
126
|
-
try {
|
|
127
|
-
expr = compileValueExpr(prop.value);
|
|
128
|
-
}
|
|
129
|
-
catch (err) {
|
|
130
|
-
diagnose([...at, "props", j, "value"], `invalid value expression "${prop.value}": ` +
|
|
131
|
-
(err instanceof Error ? err.message : String(err)));
|
|
132
|
-
continue;
|
|
133
|
-
}
|
|
134
|
-
}
|
|
135
|
-
for (const flag of ["local", "symmetric", "acyclic"])
|
|
136
|
-
if (prop[flag] === true && (expr === undefined || !admitsReferences(expr)))
|
|
137
|
-
diagnose([...at, "props", j, flag], `"${flag}" flag requires a reference-valued property`);
|
|
138
|
-
/* the sibling marker flags optionally carry a regexp or
|
|
139
|
-
enum expression selecting the values they apply to */
|
|
140
|
-
for (const flag of ["unique", "present"]) {
|
|
141
|
-
const marker = prop[flag];
|
|
142
|
-
if (typeof marker !== "string")
|
|
143
|
-
continue;
|
|
144
|
-
let kind;
|
|
145
|
-
try {
|
|
146
|
-
kind = compileValueExpr(marker).kind;
|
|
147
|
-
}
|
|
148
|
-
catch (err) {
|
|
149
|
-
diagnose([...at, "props", j, flag], `invalid "${flag}" expression "${marker}": ` +
|
|
150
|
-
(err instanceof Error ? err.message : String(err)));
|
|
151
|
-
}
|
|
152
|
-
if (kind !== undefined && kind !== "regex" && kind !== "enum")
|
|
153
|
-
diagnose([...at, "props", j, flag], `"${flag}" expression "${marker}" is neither a regexp nor an enum`);
|
|
154
|
-
}
|
|
155
|
-
}
|
|
156
|
-
if (object.automaton !== undefined) {
|
|
157
|
-
/* an automaton names the child kinds acting as nodes and
|
|
158
|
-
edges, the reference-valued edge properties referencing
|
|
159
|
-
the source and target nodes, and the node properties
|
|
160
|
-
flagging the initial and final nodes */
|
|
161
|
-
const automaton = object.automaton;
|
|
162
|
-
const childOf = (kind) => (object.childs ?? []).find((c) => c.kind === kind);
|
|
163
|
-
const propOf = (child, name) => (child?.props ?? []).find((p) => p.name === name);
|
|
164
|
-
const isReference = (prop) => {
|
|
165
|
-
try {
|
|
166
|
-
return prop.value !== undefined && admitsReferences(compileValueExpr(prop.value));
|
|
167
|
-
}
|
|
168
|
-
catch {
|
|
169
|
-
return false;
|
|
170
|
-
}
|
|
171
|
-
};
|
|
172
|
-
const nodes = childOf(automaton.nodes);
|
|
173
|
-
const edges = childOf(automaton.edges);
|
|
174
|
-
for (const [field, child] of [["nodes", nodes], ["edges", edges]])
|
|
175
|
-
if (child === undefined)
|
|
176
|
-
diagnose([...at, "automaton", field], `"automaton" ${field} kind "${automaton[field]}" is not a child object kind`);
|
|
177
|
-
for (const field of ["source", "target"]) {
|
|
178
|
-
const prop = propOf(edges, automaton[field]);
|
|
179
|
-
if (edges !== undefined && prop === undefined)
|
|
180
|
-
diagnose([...at, "automaton", field], `"automaton" ${field} property "${automaton[field]}" is not a property of "${automaton.edges}"`);
|
|
181
|
-
else if (prop !== undefined && !isReference(prop))
|
|
182
|
-
diagnose([...at, "automaton", field], `"automaton" ${field} property "${automaton[field]}" is not reference-valued`);
|
|
183
|
-
}
|
|
184
|
-
for (const field of ["initial", "final"])
|
|
185
|
-
if (nodes !== undefined && propOf(nodes, automaton[field]) === undefined)
|
|
186
|
-
diagnose([...at, "automaton", field], `"automaton" ${field} property "${automaton[field]}" is not a property of "${automaton.nodes}"`);
|
|
187
|
-
}
|
|
198
|
+
checkProperties(object.props ?? [], at, diagnose);
|
|
199
|
+
checkAutomaton(object, at, diagnose);
|
|
188
200
|
for (const [j, entry] of (object.referenced ?? []).entries()) {
|
|
189
201
|
/* a reference coverage entry has to be a single
|
|
190
202
|
(usually wildcard) reference expression */
|
|
@@ -220,6 +232,8 @@ const checkConstraints = (config, posOfPath) => {
|
|
|
220
232
|
has to be readable and syntactically valid on its own, while the
|
|
221
233
|
merged result alone is validated */
|
|
222
234
|
export const loadConfig = (files) => {
|
|
235
|
+
if (files.length === 0)
|
|
236
|
+
throw new Error("no configuration files given");
|
|
223
237
|
const diagnostics = new Array();
|
|
224
238
|
/* read and syntactically parse every configuration file into its
|
|
225
239
|
document, keeping the line positions for the diagnostics */
|
|
@@ -253,12 +267,14 @@ export const loadConfig = (files) => {
|
|
|
253
267
|
}
|
|
254
268
|
if (diagnostics.length > 0)
|
|
255
269
|
return { diagnostics };
|
|
256
|
-
/* merge the documents in order
|
|
257
|
-
|
|
270
|
+
/* merge the documents in order, out of fresh conversions, as the
|
|
271
|
+
merge mutates its target while the raw ones have to stay intact
|
|
272
|
+
for the path translation */
|
|
273
|
+
const merged = docs.map(({ doc }) => doc.toJS())
|
|
258
274
|
.reduce((target, source) => mergeConfig(target, source));
|
|
259
275
|
/* semantically validate the merged result against the schema of the configuration */
|
|
260
|
-
const posOfPath = (path) => posOfMergedPath(docs,
|
|
261
|
-
const result = v.safeParse(Schema,
|
|
276
|
+
const posOfPath = (path) => posOfMergedPath(docs, merged, path);
|
|
277
|
+
const result = v.safeParse(Schema, merged);
|
|
262
278
|
if (!result.success) {
|
|
263
279
|
for (const issue of result.issues) {
|
|
264
280
|
/* the path is rendered as it is in the positioned file */
|
|
@@ -23,6 +23,7 @@ export const renderDiagnosticVerbose = (diagnostic, colors = false) => {
|
|
|
23
23
|
}
|
|
24
24
|
if (code === "")
|
|
25
25
|
return `${renderDiagnostic(diagnostic)}\n`;
|
|
26
|
-
return sourceCodeError({
|
|
26
|
+
return sourceCodeError({ type: diagnostic.severity === "warning" ? "WARNING" : "ERROR",
|
|
27
|
+
message: diagnostic.message, filename: diagnostic.file,
|
|
27
28
|
code, line: diagnostic.line, column: diagnostic.column, colors });
|
|
28
29
|
};
|
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
import type { Spec, SpecObject } from "./specbook-format-spec.js";
|
|
2
2
|
import type { Schema, SchemaDiagram } from "./specbook-format-schema.js";
|
|
3
3
|
import type { ParseContext } from "./specbook-parse-common.js";
|
|
4
|
+
import { type Verbose } from "./specbook-verbose.js";
|
|
4
5
|
export interface DiagramError {
|
|
5
6
|
reason: string;
|
|
6
7
|
object?: SpecObject;
|
|
@@ -12,4 +13,7 @@ export interface DiagramResult {
|
|
|
12
13
|
errors: DiagramError[];
|
|
13
14
|
}
|
|
14
15
|
export declare const specDiagrams: (specification: Spec, config: Schema) => Map<SpecObject, DiagramResult>;
|
|
16
|
+
export declare const renderDiagrams: (specification: Spec, config: Schema, verbose?: Verbose) => Promise<Map<SpecObject, DiagramResult & {
|
|
17
|
+
svg: string;
|
|
18
|
+
}>>;
|
|
15
19
|
export declare const validateDiagrams: (ctx: ParseContext, specification: Spec, config: Schema) => void;
|
package/dst/specbook-diagram.js
CHANGED
|
Binary file
|