@rse/specbook 0.9.4 → 0.9.6

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (53) hide show
  1. package/README.md +64 -319
  2. package/dst/specbook-api.d.ts +13 -20
  3. package/dst/specbook-api.js +28 -25
  4. package/dst/specbook-cli.js +34 -50
  5. package/dst/specbook-cmd-describe.d.ts +5 -2
  6. package/dst/specbook-cmd-describe.js +32 -91
  7. package/dst/specbook-cmd-export.d.ts +3 -3
  8. package/dst/specbook-cmd-export.js +4 -3
  9. package/dst/specbook-cmd-init.d.ts +2 -2
  10. package/dst/specbook-cmd-init.js +35 -13
  11. package/dst/specbook-cmd-lint.d.ts +6 -6
  12. package/dst/specbook-cmd-lint.js +36 -23
  13. package/dst/specbook-config.d.ts +2 -2
  14. package/dst/specbook-config.js +2 -2
  15. package/dst/specbook-diagram.d.ts +4 -4
  16. package/dst/specbook-diagram.js +21 -15
  17. package/dst/specbook-export-ast.d.ts +3 -3
  18. package/dst/specbook-export-common.d.ts +10 -9
  19. package/dst/specbook-export-common.js +23 -9
  20. package/dst/specbook-export-html.css +13 -3
  21. package/dst/specbook-export-html.d.ts +4 -4
  22. package/dst/specbook-export-html.js +61 -32
  23. package/dst/specbook-export-logo-dark.svg +35 -0
  24. package/dst/specbook-export-logo-light.svg +35 -0
  25. package/dst/specbook-export-md.d.ts +3 -3
  26. package/dst/{specbook-struct-schema.d.ts → specbook-format-schema.d.ts} +3 -2
  27. package/dst/{specbook-struct-schema.js → specbook-format-schema.js} +2 -1
  28. package/dst/specbook-format-spec.d.ts +31 -0
  29. package/dst/{specbook-struct-spec.js → specbook-format-spec.js} +10 -10
  30. package/dst/specbook-format.md +734 -0
  31. package/dst/specbook-link.d.ts +2 -2
  32. package/dst/specbook-mcp.d.ts +2 -1
  33. package/dst/specbook-mcp.js +13 -52
  34. package/dst/specbook-parse-common.d.ts +5 -3
  35. package/dst/specbook-parse-common.js +8 -0
  36. package/dst/specbook-parse-semantic.d.ts +4 -4
  37. package/dst/specbook-parse-semantic.js +4 -65
  38. package/dst/specbook-parse-syntax.d.ts +2 -2
  39. package/dst/specbook-parse-syntax.js +18 -14
  40. package/dst/specbook-parse-value.js +1 -1
  41. package/dst/specbook-parse.d.ts +4 -4
  42. package/dst/specbook-parse.js +3 -3
  43. package/dst/specbook-verbose.d.ts +2 -0
  44. package/dst/specbook-verbose.js +16 -0
  45. package/package.json +6 -11
  46. package/dst/specbook-cmd-edit.d.ts +0 -9
  47. package/dst/specbook-cmd-edit.js +0 -74
  48. package/dst/specbook-cmd-import.d.ts +0 -9
  49. package/dst/specbook-cmd-import.js +0 -78
  50. package/dst/specbook-export-logo.svg +0 -35
  51. package/dst/specbook-llm.d.ts +0 -13
  52. package/dst/specbook-llm.js +0 -51
  53. package/dst/specbook-struct-spec.d.ts +0 -31
@@ -6,13 +6,18 @@
6
6
  */
7
7
  import * as fs from "node:fs";
8
8
  import { Command } from "commander";
9
- import { SpecBook, renderDiagnostic, renderDiagnosticVerbose, parseOutputSpec, version } from "./specbook-api.js";
9
+ import chalk from "chalk";
10
+ import { SpecBook, renderDiagnostic, renderDiagnosticVerbose, renderVerbose, literal, parseOutputSpec, version } from "./specbook-api.js";
10
11
  import { serveMcp } from "./specbook-mcp.js";
11
- /* route verbose messages to stderr, keeping stdout reserved
12
- for the command outputs and the MCP protocol */
13
- const verboseOf = (opts) => (msg) => {
14
- if (opts.verbose)
15
- process.stderr.write(`specbook: ${msg}\n`);
12
+ /* route verbose messages to stderr, keeping stdout reserved for the
13
+ command outputs and the MCP protocol, and qualify every message with
14
+ the tool name plus the scope path of the emitting command, where
15
+ Chalk styles the output only if the terminal supports colors */
16
+ const verboseOf = (opts, ...scope) => (cmd, msg) => {
17
+ if (opts.verbose) {
18
+ const scopePath = [...scope, cmd].map((segment) => chalk.bold(segment)).join(": ");
19
+ process.stderr.write(`specbook: ${scopePath}: ${renderVerbose(msg, chalk.blue)}\n`);
20
+ }
16
21
  };
17
22
  /* write a buffer to stdout, awaiting the write callback which only
18
23
  fires once the data has been flushed to the underlying pipe, so a
@@ -28,12 +33,12 @@ const writeStdout = (data) => {
28
33
  });
29
34
  };
30
35
  /* write a command result to the output file or stdout */
31
- const writeOutput = async (output, data, verbose) => {
36
+ const writeOutput = async (output, data, cmd, verbose) => {
32
37
  if (output === "-")
33
38
  await writeStdout(data);
34
39
  else {
35
40
  await fs.promises.writeFile(output, data);
36
- verbose(`wrote "${output}" (${Buffer.byteLength(data)} bytes)`);
41
+ verbose(cmd, `wrote "${literal(output)}" (${literal(Buffer.byteLength(data))} bytes)`);
37
42
  }
38
43
  };
39
44
  /* determine an option default value, overridable via a
@@ -45,10 +50,13 @@ const envDefaultFlag = (name, fallback) => {
45
50
  const value = process.env[`SPECBOOK_${name.toUpperCase().replace(/-/g, "_")}`];
46
51
  return value !== undefined ? !(/^(?:|0|false|no|off)$/i).test(value) : fallback;
47
52
  };
48
- /* provide the common options of all sub-commands */
49
- const withCommonOptions = (command) => command
50
- .option("-v, --verbose", "print verbose processing information to stderr", envDefaultFlag("verbose", false))
51
- .option("-c, --config <yaml-file>", "YAML schema configuration file", envDefault("config"));
53
+ /* provide the verbose option of all sub-commands */
54
+ const withVerboseOption = (command) => command
55
+ .option("-v, --verbose", "print verbose processing information to stderr", envDefaultFlag("verbose", false));
56
+ /* provide the common options of the specification processing sub-commands,
57
+ for which the YAML schema configuration is mandatory */
58
+ const withCommonOptions = (command) => withVerboseOption(command)
59
+ .requiredOption("-c, --config <yaml-file>", "YAML schema configuration file", envDefault("config"));
52
60
  /* parse the command line */
53
61
  const program = new Command();
54
62
  program.name("specbook")
@@ -57,12 +65,11 @@ program.name("specbook")
57
65
  .action(() => {
58
66
  program.help();
59
67
  });
60
- program.command("mcp")
68
+ withVerboseOption(program.command("mcp"))
61
69
  .description("run as MCP stdio server")
62
- .option("-v, --verbose", "print verbose processing information to stderr", envDefaultFlag("verbose", false))
63
70
  .action(async (opts) => {
64
- verboseOf(opts)("starting MCP server on stdio");
65
- await serveMcp(verboseOf(opts));
71
+ verboseOf(opts)("mcp", "starting MCP server on stdio");
72
+ await serveMcp(verboseOf(opts, "mcp"));
66
73
  });
67
74
  withCommonOptions(program.command("init"))
68
75
  .description("initialize the configured specification artifact files below the base directory")
@@ -87,7 +94,7 @@ withCommonOptions(program.command("lint"))
87
94
  if (result.diagnostics.length > 0)
88
95
  process.exitCode = 1;
89
96
  else
90
- verboseOf(opts)("specification valid");
97
+ verboseOf(opts)("lint", "specification valid");
91
98
  });
92
99
  withCommonOptions(program.command("export"))
93
100
  .description("export the specification Markdown files below the base directory " +
@@ -104,43 +111,20 @@ withCommonOptions(program.command("export"))
104
111
  const buffers = await specbook.export({ config: opts.config, basedir: opts.basedir,
105
112
  formats: distinct });
106
113
  for (const { format, output } of outputs)
107
- await writeOutput(output, buffers[distinct.indexOf(format)], verboseOf(opts));
114
+ await writeOutput(output, buffers[distinct.indexOf(format)], "export", verboseOf(opts));
108
115
  });
109
- withCommonOptions(program.command("describe"))
110
- .description("describe the configured specification format as Markdown")
116
+ /* the describe command also describes the generic SpecBook models and
117
+ formats alone, so its YAML schema configuration stays optional */
118
+ withVerboseOption(program.command("describe"))
119
+ .description("describe the SpecBook models and formats as Markdown")
120
+ .option("-c, --config <yaml-file>", "YAML schema configuration file", envDefault("config"))
121
+ .option("-b, --basedir <directory>", "base directory of the specification Markdown files", envDefault("basedir"))
122
+ .option("-e, --embed", "embed the YAML schema configuration instead of just referencing it", envDefaultFlag("embed", false))
111
123
  .option("-o, --output <markdown-file>", "output file (\"-\" for stdout)", envDefault("output", "-"))
112
124
  .action(async (opts) => {
113
125
  const specbook = new SpecBook({ verbose: verboseOf(opts) });
114
- const text = await specbook.describe({ config: opts.config });
115
- await writeOutput(opts.output, text, verboseOf(opts));
116
- });
117
- withCommonOptions(program.command("import"))
118
- .description("import foreign sources into the specification artifact files below the base directory")
119
- .option("-b, --basedir <directory>", "base directory of the specification Markdown files", envDefault("basedir", "."))
120
- .option("--provider <provider>", "AI provider (\"anthropic\", \"openai\", \"openrouter\" or \"ollama\")", envDefault("provider"))
121
- .option("--model <model>", "AI model", envDefault("model"))
122
- .argument("<input-files...>", "foreign source files to import")
123
- .action(async (inputs, opts) => {
124
- const specbook = new SpecBook({ verbose: verboseOf(opts) });
125
- const written = await specbook.import({ config: opts.config, basedir: opts.basedir,
126
- inputs, provider: opts.provider, model: opts.model });
127
- await writeStdout(written.length > 0 ?
128
- `imported into artifact file(s): ${written.join(", ")}\n` :
129
- "no artifact files were changed\n");
130
- });
131
- withCommonOptions(program.command("edit"))
132
- .description("apply a free-text edit request to the specification artifact files below the base directory")
133
- .option("-b, --basedir <directory>", "base directory of the specification Markdown files", envDefault("basedir", "."))
134
- .option("--provider <provider>", "AI provider (\"anthropic\", \"openai\", \"openrouter\" or \"ollama\")", envDefault("provider"))
135
- .option("--model <model>", "AI model", envDefault("model"))
136
- .argument("<query>", "free-text edit request")
137
- .action(async (query, opts) => {
138
- const specbook = new SpecBook({ verbose: verboseOf(opts) });
139
- const written = await specbook.edit({ config: opts.config, basedir: opts.basedir,
140
- query, provider: opts.provider, model: opts.model });
141
- await writeStdout(written.length > 0 ?
142
- `edited artifact file(s): ${written.join(", ")}\n` :
143
- "no artifact files were changed\n");
126
+ const text = await specbook.describe({ config: opts.config, basedir: opts.basedir, embed: opts.embed });
127
+ await writeOutput(opts.output, text, "describe", verboseOf(opts));
144
128
  });
145
129
  try {
146
130
  await program.parseAsync();
@@ -1,2 +1,5 @@
1
- import { type SchemaSpecification } from "./specbook-struct-schema.js";
2
- export declare const describeConfiguration: (config: SchemaSpecification) => string;
1
+ export declare const describeFormat: (options: {
2
+ config?: string;
3
+ basedir?: string;
4
+ embed?: boolean;
5
+ }) => string;
@@ -3,97 +3,38 @@
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 * as fs from "node:fs";
7
+ import * as path from "node:path";
8
+ import { fileURLToPath } from "node:url";
6
9
  import textframe from "textframe";
7
- /* collapse folded YAML prose into a single line */
8
- const collapse = (text) => (text ?? "").replace(/\s+/g, " ").trim();
9
- /* the static description of the generic Markdown syntax */
10
- const syntax = textframe `
11
- # SpecBook Specification Format
12
-
13
- A specification consists of Markdown *artifact* files, each carrying a
14
- frontmatter with \`Created:\` and \`Modified:\` timestamps (format
15
- \`yyyy-LL-dd HH:mm\`) and a tree of *objects*. Every object has a *kind*,
16
- a *name*, a unique anchor *id*, optional *properties* (key/value pairs),
17
- an optional *description* statement with an optional rationale, and
18
- optional *child* objects. Two concrete syntaxes exist:
19
-
20
- ## Complex Format (used on levels 1-3)
21
-
22
- \`\`\`
23
- # <kind>: <name> (<id>)
24
-
25
- - <key>: <value>
26
- - [...]
27
-
28
- <statement>, BECAUSE <rationale>.
29
- \`\`\`
30
-
31
- Level 1 (\`#\`) carries the artifact id in parentheses. Levels 2 (\`##\`)
32
- and 3 (\`###\`) instead carry an HTML anchor \`<a id="<id>"></a>\` after
33
- the name, where the id uses the artifact id as its prefix.
34
-
35
- ## Concise Format (used on level 4 and deeper)
36
-
37
- \`\`\`
38
- - <kind>: <name>; <key>: <value>; [...]; <statement>, BECAUSE <rationale>.
39
- \`\`\`
40
-
41
- Child objects nest as indented list items below their parent item.
42
-
43
- ## Primary Marker
44
-
45
- In both formats a literal \`(*)\` directly after the \`<name>\` marks the
46
- object as *primary* (e.g. the primary attribute of an entity). It is
47
- distinct from an id given as \`(<id>)\` and both may be combined.
48
-
49
- ## Configured Artifacts
50
-
51
- The following artifacts, object kinds, and properties are configured.
52
- Object kinds and property names are *case-sensitive* and must be
53
- written exactly as configured. Properties marked \`required\` must be
54
- present, and property values must match their given constraint (if
55
- any, else any value is allowed): \`/xxx/\` requires a match of the
56
- regular expression, \`[[xxx]]\` requires exactly one Wiki-style link
57
- reference resolving into the (usually wildcard) reference pattern,
58
- \`enum(xxx,yyy)\` requires one of the listed members, \`tags(xxx,yyy)\`
59
- requires a comma-separated set of the listed members (each at most
60
- once), and \`list(xxx[, ...])\` requires a comma-separated list of
61
- items, each matching one of the alternative constraints.
62
- `;
63
- /* describe a single configured property */
64
- const describeProperty = (prop, indent) => {
65
- let line = `${indent}- property \`${prop.name}\` (${prop.optional === true ? "optional" : "required"}`;
66
- if (prop.value !== undefined)
67
- line += `, constraint \`${prop.value}\``;
68
- line += `): ${collapse(prop.desc)}`;
69
- return line;
70
- };
71
- /* describe a single configured object kind (and recursively its childs) */
72
- const describeObject = (object, level, indent) => {
73
- const lines = new Array();
74
- lines.push(`${indent}- object kind \`${object.kind}\` on level ${level}` +
75
- `${object.optional === true ? " (optional)" : ""}: ${collapse(object.desc)}`);
76
- for (const prop of object.props ?? [])
77
- lines.push(describeProperty(prop, `${indent} `));
78
- for (const child of object.childs ?? [])
79
- lines.push(describeObject(child, level + 1, `${indent} `));
80
- return lines.join("\n");
81
- };
82
- /* describe the configured specification format as Markdown */
83
- export const describeConfiguration = (config) => {
84
- const sections = new Array();
85
- for (const artifact of config) {
86
- const lines = new Array();
87
- lines.push(`### ${artifact.kind}: ${artifact.name ?? ""} (${artifact.kind}-${artifact.id ?? ""})`);
88
- lines.push("");
89
- if (artifact.file !== undefined)
90
- lines.push(`- file: \`${artifact.file}\``);
91
- lines.push(`- description: ${collapse(artifact.desc)}`);
92
- for (const prop of artifact.props ?? [])
93
- lines.push(describeProperty(prop, ""));
94
- for (const child of artifact.childs ?? [])
95
- lines.push(describeObject(child, 2, ""));
96
- sections.push(lines.join("\n"));
10
+ /* provide the build-time bundled description of the generic
11
+ SpecBook models and formats */
12
+ const description = () => fs.readFileSync(path.join(path.dirname(fileURLToPath(import.meta.url)), "specbook-format.md"), "utf8");
13
+ /* describe the generic SpecBook models and formats as Markdown,
14
+ optionally extended by the references to (or the embeddings of) the
15
+ artifacts of the particular project instantiation */
16
+ export const describeFormat = (options) => {
17
+ const sections = [description()];
18
+ if (options.config !== undefined || options.basedir !== undefined) {
19
+ sections.push("SpecBook Project Instantiation\n" +
20
+ "==============================\n");
21
+ if (options.config !== undefined && options.embed === true)
22
+ sections.push(textframe(`
23
+ The **SpecBook SCHEMA Model** of this particular project is the following
24
+ YAML schema configuration of the file \`${options.config}\`:
25
+ `) + "\n```yaml\n" +
26
+ fs.readFileSync(options.config, "utf8").replace(/\n*$/, "\n") +
27
+ "```\n");
28
+ else if (options.config !== undefined)
29
+ sections.push(textframe(`
30
+ The **SpecBook SCHEMA Model** of this particular project is the YAML
31
+ schema configuration in the file \`${options.config}\`.
32
+ `));
33
+ if (options.basedir !== undefined)
34
+ sections.push(textframe(`
35
+ The **SpecBook SPEC Model** of this particular project is the set of
36
+ specification Markdown files in the directory \`${options.basedir}\`.
37
+ `));
97
38
  }
98
- return `${syntax}\n${sections.join("\n\n")}\n`;
39
+ return sections.join("\n");
99
40
  };
@@ -1,9 +1,9 @@
1
- import type { Specification } from "./specbook-struct-spec.js";
2
- import type { SchemaSpecification } from "./specbook-struct-schema.js";
1
+ import type { Spec } from "./specbook-format-spec.js";
2
+ import type { Schema } from "./specbook-format-schema.js";
3
3
  export declare const formats: readonly ["json", "json5", "yaml", "toon", "html", "pdf", "md"];
4
4
  export type ExportFormat = typeof formats[number];
5
5
  export declare const parseOutputSpec: (spec: string) => {
6
6
  format: ExportFormat;
7
7
  output: string;
8
8
  };
9
- export declare const exportSpecification: (specification: Specification, format: ExportFormat, verbose: (msg: string) => void, config?: SchemaSpecification) => Promise<Buffer>;
9
+ export declare const exportSpecification: (specification: Spec, format: ExportFormat, verbose: (msg: string) => void, config?: Schema) => Promise<Buffer>;
@@ -10,6 +10,7 @@ import { renderAst } from "./specbook-export-ast.js";
10
10
  import { renderMarkdown } from "./specbook-export-md.js";
11
11
  import { renderHtml, htmlOutline } from "./specbook-export-html.js";
12
12
  import { htmlToPdf } from "./specbook-export-pdf.js";
13
+ import { literal } from "./specbook-verbose.js";
13
14
  /* the supported export formats */
14
15
  export const formats = ["json", "json5", "yaml", "toon", "html", "pdf", "md"];
15
16
  /* the filename extensions mapping onto the export formats */
@@ -42,7 +43,7 @@ export const parseOutputSpec = (spec) => {
42
43
  export const exportSpecification = async (specification, format, verbose, config) => {
43
44
  if (!formats.includes(format))
44
45
  throw new Error(`unknown export format "${format}"`);
45
- verbose(`exporting specification as "${format}"`);
46
+ verbose(`exporting specification as "${literal(format)}"`);
46
47
  if (format === "json" || format === "json5" || format === "yaml" || format === "toon")
47
48
  return renderAst(specification, format, config);
48
49
  else if (format === "md")
@@ -51,11 +52,11 @@ export const exportSpecification = async (specification, format, verbose, config
51
52
  fonts subsetted to the CHARSET of the specification (if any) */
52
53
  const charset = documentCharset(specification);
53
54
  if (charset !== undefined)
54
- verbose(`subsetting embedded fonts to charset "${charset}"`);
55
+ verbose(`subsetting embedded fonts to charset "${literal(charset)}"`);
55
56
  /* the theme tone (THEME-TONE) drives the layer-1 color spread
56
57
  variables and the PDF decoration colors */
57
58
  const tone = documentThemeTone(specification) ?? "#336699";
58
- verbose(`generating theme color spreads (tone "${tone}")`);
59
+ verbose(`generating theme color spreads (tone "${literal(tone)}")`);
59
60
  const colors = themeColors(tone);
60
61
  /* the paper size (PAPER-SIZE) drives the PDF page setup and the
61
62
  print-time height constraint of the diagrams */
@@ -1,6 +1,6 @@
1
- import { type SchemaSpecification } from "./specbook-struct-schema.js";
1
+ import { type Schema } from "./specbook-format-schema.js";
2
2
  export interface InitOptions {
3
- config: SchemaSpecification;
3
+ config: Schema;
4
4
  basedir?: string;
5
5
  verbose: (msg: string) => void;
6
6
  }
@@ -5,35 +5,57 @@
5
5
  */
6
6
  import * as fs from "node:fs";
7
7
  import * as path from "node:path";
8
- import { timestamp } from "./specbook-llm.js";
8
+ import { literal } from "./specbook-verbose.js";
9
+ /* the current time in the frontmatter timestamp format */
10
+ const timestamp = () => {
11
+ const d = new Date();
12
+ const pad = (n) => String(n).padStart(2, "0");
13
+ return `${d.getFullYear()}-${pad(d.getMonth() + 1)}-${pad(d.getDate())} ` +
14
+ `${pad(d.getHours())}:${pad(d.getMinutes())}`;
15
+ };
9
16
  /* initialize the configured specification artifact files below the
10
- base directory with their frontmatter and artifact heading */
17
+ base directory with their frontmatter and artifact headings, where
18
+ all artifacts configured onto the same file reside in it side by
19
+ side, following each other on level 1 */
11
20
  export const initSpecification = (options) => {
12
21
  const basedir = options.basedir ?? ".";
13
22
  fs.mkdirSync(basedir, { recursive: true });
14
23
  const now = timestamp();
15
24
  const created = new Array();
25
+ /* group the configured artifacts by their file, preserving the
26
+ schema order both of the files and of the artifacts within */
27
+ const groups = new Map();
16
28
  for (const artifact of options.config) {
17
29
  if (artifact.file === undefined)
18
30
  continue;
19
- const file = path.join(basedir, artifact.file);
20
- if (fs.existsSync(file)) {
21
- options.verbose(`skipping existing artifact file "${artifact.file}"`);
31
+ const group = groups.get(artifact.file);
32
+ if (group === undefined)
33
+ groups.set(artifact.file, [artifact]);
34
+ else
35
+ group.push(artifact);
36
+ }
37
+ for (const [file, artifacts] of groups) {
38
+ const target = path.join(basedir, file);
39
+ if (fs.existsSync(target)) {
40
+ options.verbose(`skipping existing artifact file "${literal(file)}"`);
22
41
  continue;
23
42
  }
24
- const name = artifact.name ?? "";
25
- const paren = artifact.id !== undefined ? ` (${artifact.id})` : "";
43
+ const headings = artifacts.map((artifact) => {
44
+ const name = artifact.name ?? "";
45
+ const paren = artifact.id !== undefined ? ` (${artifact.id})` : "";
46
+ return `# ${artifact.kind}: ${name}${paren}\n` + "\n";
47
+ }).join("");
26
48
  const text = "---\n" +
27
49
  `Created: ${now}\n` +
28
50
  `Modified: ${now}\n` +
29
51
  "---\n" +
30
52
  "\n" +
31
- `# ${artifact.kind}: ${name}${paren}\n` +
32
- "\n";
33
- fs.mkdirSync(path.dirname(file), { recursive: true });
34
- fs.writeFileSync(file, text, "utf8");
35
- options.verbose(`created artifact file "${artifact.file}"`);
36
- created.push(artifact.file);
53
+ headings;
54
+ fs.mkdirSync(path.dirname(target), { recursive: true });
55
+ fs.writeFileSync(target, text, "utf8");
56
+ options.verbose(`created artifact file "${literal(file)}" ` +
57
+ `with ${literal(artifacts.length)} artifact(s)`);
58
+ created.push(file);
37
59
  }
38
60
  return created;
39
61
  };
@@ -1,15 +1,15 @@
1
1
  import { type Diagnostic } from "./specbook-diagnostic.js";
2
- import { type Specification } from "./specbook-struct-spec.js";
3
- import { type SchemaSpecification } from "./specbook-struct-schema.js";
2
+ import { type Spec } from "./specbook-format-spec.js";
3
+ import { type Schema } from "./specbook-format-schema.js";
4
4
  export interface LintOptions {
5
- config?: string;
5
+ config: string;
6
6
  basedir: string;
7
7
  verbose: (msg: string) => void;
8
8
  }
9
9
  export interface LintResult {
10
- specification: Specification;
10
+ specification: Spec;
11
11
  diagnostics: Diagnostic[];
12
- config?: SchemaSpecification;
12
+ config?: Schema;
13
13
  }
14
- export declare const scanMarkdown: (basedir: string) => string[];
14
+ export declare const schemaFiles: (config: Schema) => Map<string, boolean>;
15
15
  export declare const lint: (options: LintOptions) => LintResult;
@@ -6,34 +6,47 @@
6
6
  import * as fs from "node:fs";
7
7
  import * as path from "node:path";
8
8
  import { loadConfig } from "./specbook-config.js";
9
+ import { literal } from "./specbook-verbose.js";
9
10
  import { parseSpecification } from "./specbook-parse.js";
10
- /* recursively scan a base directory for Markdown files */
11
- export const scanMarkdown = (basedir) => fs.readdirSync(basedir, { recursive: true, withFileTypes: true })
12
- .filter((entry) => entry.isFile() && entry.name.endsWith(".md"))
13
- .map((entry) => path.join(entry.parentPath, entry.name))
14
- .sort();
11
+ /* collect the distinct artifact files the schema configuration
12
+ references, in their declaration order, each mapped onto whether
13
+ all of its artifacts are optional and hence the file may be absent */
14
+ export const schemaFiles = (config) => {
15
+ const files = new Map();
16
+ for (const artifact of config) {
17
+ if (artifact.file === undefined)
18
+ continue;
19
+ files.set(artifact.file, (files.get(artifact.file) ?? true) && artifact.optional === true);
20
+ }
21
+ return files;
22
+ };
15
23
  /* lint the specification Markdown files below the base directory
16
- against the optional configuration */
24
+ against the configuration */
17
25
  export const lint = (options) => {
18
26
  const diagnostics = new Array();
19
- /* load the optional YAML schema configuration */
20
- let config;
21
- if (options.config !== undefined) {
22
- options.verbose(`loading configuration "${options.config}"`);
23
- const loaded = loadConfig(options.config);
24
- diagnostics.push(...loaded.diagnostics);
25
- config = loaded.config ?? undefined;
26
- }
27
- /* parse and validate the specification Markdown files */
28
- const exists = fs.existsSync(options.basedir) && fs.statSync(options.basedir).isDirectory();
29
- const files = exists ? scanMarkdown(options.basedir) : [];
30
- options.verbose(`parsing ${files.length} specification file(s) below "${options.basedir}"`);
31
- if (files.length === 0)
32
- diagnostics.push({ file: options.basedir, line: 1, column: 1,
33
- message: exists ? "no Markdown files found below base directory" :
34
- "base directory does not exist" });
27
+ /* load the mandatory YAML schema configuration */
28
+ options.verbose(`loading configuration "${literal(options.config)}"`);
29
+ const loaded = loadConfig(options.config);
30
+ diagnostics.push(...loaded.diagnostics);
31
+ const config = loaded.config ?? undefined;
32
+ /* read the artifact files the configuration references, resolved
33
+ against the base directory and tolerating an absent file if all
34
+ its artifacts are optional */
35
+ const files = config !== undefined ? schemaFiles(config) : new Map();
36
+ options.verbose(`parsing ${literal(files.size)} specification file(s) ` +
37
+ `below "${literal(options.basedir)}"`);
38
+ if (config !== undefined && files.size === 0)
39
+ diagnostics.push({ file: options.config, line: 1, column: 1,
40
+ message: "no artifact files configured" });
35
41
  const sources = new Array();
36
- for (const file of files) {
42
+ for (const [name, optional] of files) {
43
+ const file = path.join(options.basedir, name);
44
+ if (!fs.existsSync(file)) {
45
+ if (!optional)
46
+ diagnostics.push({ file, line: 1, column: 1,
47
+ message: "missing artifact file" });
48
+ continue;
49
+ }
37
50
  try {
38
51
  sources.push({ file, text: fs.readFileSync(file, "utf8") });
39
52
  }
@@ -1,6 +1,6 @@
1
- import { SchemaSpecification } from "./specbook-struct-schema.js";
1
+ import { Schema } from "./specbook-format-schema.js";
2
2
  import { type Diagnostic } from "./specbook-diagnostic.js";
3
3
  export declare const loadConfig: (file: string) => {
4
- config: SchemaSpecification | null;
4
+ config: Schema | null;
5
5
  diagnostics: Diagnostic[];
6
6
  };
@@ -6,7 +6,7 @@
6
6
  import * as fs from "node:fs";
7
7
  import { parse as parseYaml, parseDocument, YAMLParseError } from "yaml";
8
8
  import * as v from "valibot";
9
- import { SchemaSpecification } from "./specbook-struct-schema.js";
9
+ import { Schema } from "./specbook-format-schema.js";
10
10
  import { compileValueExpr } from "./specbook-parse-value.js";
11
11
  /* determine line/column of a YAML document path via the CST */
12
12
  const lineColOfPath = (yaml, cst, path) => {
@@ -58,7 +58,7 @@ export const loadConfig = (file) => {
58
58
  cst ??= parseDocument(yaml);
59
59
  return lineColOfPath(yaml, cst, path);
60
60
  };
61
- const result = v.safeParse(SchemaSpecification, obj);
61
+ const result = v.safeParse(Schema, obj);
62
62
  if (!result.success) {
63
63
  for (const issue of result.issues) {
64
64
  const path = (issue.path ?? []).map((item) => item.key);
@@ -1,9 +1,9 @@
1
- import type { Specification, Object as SpecObject } from "./specbook-struct-spec.js";
2
- import type { SchemaSpecification } from "./specbook-struct-schema.js";
1
+ import type { Spec, SpecObject } from "./specbook-format-spec.js";
2
+ import type { Schema } from "./specbook-format-schema.js";
3
3
  import { type ParseContext } from "./specbook-parse-common.js";
4
4
  export interface DiagramResult {
5
5
  spec?: string;
6
6
  errors: string[];
7
7
  }
8
- export declare const specDiagrams: (specification: Specification, config: SchemaSpecification) => Map<SpecObject, DiagramResult>;
9
- export declare const validateDiagrams: (ctx: ParseContext, specification: Specification, config: SchemaSpecification) => void;
8
+ export declare const specDiagrams: (specification: Spec, config: Schema) => Map<SpecObject, DiagramResult>;
9
+ export declare const validateDiagrams: (ctx: ParseContext, specification: Spec, config: Schema) => void;
@@ -10,9 +10,6 @@ const referenceOnce = /\[\[([^[\]]+)\]\]/;
10
10
  /* strip the inline code markup of a name or property value
11
11
  (preserved in the AST for rendering) for labeling purposes */
12
12
  const plainText = (text) => text.replace(/`/g, "");
13
- /* strip a trailing parenthesized annotation from a
14
- property key (e.g. "WHEN (Context)") */
15
- const plainKey = (key) => key.replace(/\s*\([^)]*\)\s*$/, "").trim();
16
13
  /* render a text as a Gradia atom: a bareword where possible
17
14
  (no whitespace, no special characters, no "--"), a quoted
18
15
  string otherwise */
@@ -58,7 +55,7 @@ const renderSpec = (diagram, type, center, nodes, edges, index, anchors) => {
58
55
  the node set can mix objects of different kinds), with every
59
56
  "[[...]]" reference stripped to its target object name */
60
57
  for (const key of diagram.properties ?? []) {
61
- const value = node.properties.find((property) => plainKey(property.key) === key)?.value;
58
+ const value = node.properties.find((property) => property.key === key)?.value;
62
59
  if (value !== undefined) {
63
60
  const text = value.replace(referenceRegex, (_, reference) => {
64
61
  const target = resolveUnique(index, reference.trim()).target;
@@ -128,8 +125,8 @@ const deriveDiagram = (object, diagram, index, anchors, parents) => {
128
125
  objects (per the "links" selection) and from the edge objects by
129
126
  convention (source: parent object, target: first reference in the
130
127
  property values, name: object name, arity: "ARITY" property),
131
- overridable via "edgeTarget"/"edgeArity" (a "grid" diagram is
132
- edge-less by definition, so no edges are derived for it at all) */
128
+ overridable via "edgeSource"/"edgeTarget"/"edgeArity" (a "grid"
129
+ diagram is edge-less by definition, so no edges are derived at all) */
133
130
  let edges = new Array();
134
131
  if (type !== "grid") {
135
132
  for (const node of nodes) {
@@ -146,16 +143,25 @@ const deriveDiagram = (object, diagram, index, anchors, parents) => {
146
143
  edges.push({ source: node, target });
147
144
  }
148
145
  }
149
- for (const edgeObject of edgeObjects) {
150
- const source = parents.get(edgeObject);
151
- const value = diagram.edgeTarget !== undefined ?
152
- edgeObject.properties.find((property) => plainKey(property.key) === diagram.edgeTarget)?.value :
153
- edgeObject.properties.map((property) => property.value)
154
- .find((v) => referenceOnce.test(v));
146
+ /* resolve the node an edge object references through a named
147
+ property, or through its first single-reference property when
148
+ no name is configured (skipping the source property) */
149
+ const edgeNode = (edgeObject, key) => {
150
+ const value = key !== undefined ?
151
+ edgeObject.properties.find((property) => property.key === key)?.value :
152
+ edgeObject.properties.filter((property) => property.key !== diagram.edgeSource)
153
+ .map((property) => property.value).find((v) => referenceOnce.test(v));
155
154
  const reference = value?.match(referenceOnce)?.[1].trim();
156
- const target = reference !== undefined ? resolveUnique(index, reference).target : undefined;
155
+ return reference !== undefined ? resolveUnique(index, reference).target : undefined;
156
+ };
157
+ for (const edgeObject of edgeObjects) {
158
+ const source = diagram.edgeSource !== undefined ?
159
+ edgeNode(edgeObject, diagram.edgeSource) : parents.get(edgeObject);
160
+ const target = edgeNode(edgeObject, diagram.edgeTarget);
157
161
  if (source === undefined) {
158
- errors.push(`diagram edge object "${edgeObject.name}" has no parent object as source`);
162
+ errors.push(diagram.edgeSource !== undefined ?
163
+ `diagram edge object "${edgeObject.name}" carries no resolvable source reference` :
164
+ `diagram edge object "${edgeObject.name}" has no parent object as source`);
159
165
  continue;
160
166
  }
161
167
  if (target === undefined) {
@@ -164,7 +170,7 @@ const deriveDiagram = (object, diagram, index, anchors, parents) => {
164
170
  }
165
171
  if (!nodeSet.has(source) || !nodeSet.has(target))
166
172
  continue;
167
- const arity = edgeObject.properties.find((property) => plainKey(property.key) === (diagram.edgeArity ?? "ARITY"))?.value;
173
+ const arity = edgeObject.properties.find((property) => property.key === (diagram.edgeArity ?? "ARITY"))?.value;
168
174
  edges.push({ source, target, name: plainText(edgeObject.name),
169
175
  arity: arity !== undefined ? plainText(arity) : undefined });
170
176
  }
@@ -1,4 +1,4 @@
1
- import type { Specification } from "./specbook-struct-spec.js";
2
- import type { SchemaSpecification } from "./specbook-struct-schema.js";
1
+ import type { Spec } from "./specbook-format-spec.js";
2
+ import type { Schema } from "./specbook-format-schema.js";
3
3
  export type AstFormat = "json" | "json5" | "yaml" | "toon";
4
- export declare const renderAst: (specification: Specification, format: AstFormat, config?: SchemaSpecification) => Promise<Buffer>;
4
+ export declare const renderAst: (specification: Spec, format: AstFormat, config?: Schema) => Promise<Buffer>;