@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 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
@@ -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, formats, verbose, realtime) {
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 formats)
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 an artifact file or of one
119
- of its embedded assets re-exports the specification and hands the
120
- buffers to "onExport" once the sources fell silent again. A failed
121
- re-export is reported and leaves the observe loop intact, so a
122
- transiently invalid specification does not end the watch. The
123
- returned promise settles once the initial export is done, while
124
- the active watcher keeps the process alive afterwards */
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, so a failing export still keeps the
130
- observe loop fed with an up-to-date file set */
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" || options.embed === true);
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);
@@ -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
- return new Promise((resolve, reject) => {
30
- stream.write(data, (err) => {
31
- if (err)
32
- reject(err);
33
- else
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) => process.env[`SPECBOOK_${name.toUpperCase().replace(/-/g, "_")}`] ?? 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"), "the bundled standard schema configuration, embedded")
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) : Number(value);
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) => config.length > 1 && schema !== undefined ?
59
- stringify(schema, { indent: 2, lineWidth: 0 }) :
60
- fs.readFileSync(config[0], "utf8");
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
- if (compress > 0)
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 compress > 0 ? compressYaml(yaml, compress) : yaml;
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 schema = options.config !== undefined ?
134
+ const schemaText = options.config !== undefined ?
133
135
  schemaSection(options.config, options.schema, options.embed === true, options.standard === true, compress) : undefined;
134
- if (schema !== undefined || options.basedir !== undefined) {
136
+ if (schemaText !== undefined || options.basedir !== undefined) {
135
137
  sections.push("SpecBook Project Instantiation\n" +
136
138
  "==============================\n");
137
- if (schema !== undefined)
138
- sections.push(schema);
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 type { Verbose } from "./specbook-verbose.js";
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
- /* export a specification into the requested format, where "realtime"
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
- export const exportSpecification = async (specification, format, verbose, config, realtime = false) => {
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").send(html);
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 url = `http://${options.addr}:${options.port}/`;
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) => {
@@ -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.slice().reverse()) {
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 ? object.kind : `${object.kind}:${object.id ?? object.name ?? ""}`;
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
- for (const [j, prop] of (object.props ?? []).entries()) {
121
- /* the relation shape flags apply to reference-valued
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
- const obj = docs.map(({ doc }) => doc.toJS())
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, obj, path);
261
- const result = v.safeParse(Schema, obj);
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({ message: renderMessage(diagnostic), filename: diagnostic.file,
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;
Binary file