@rse/specbook 0.9.3 → 0.9.4

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -131,7 +131,7 @@ with the (optionally backquoted) name directly:
131
131
 
132
132
  The heading kind is singularized by stripping one trailing `S`, so both
133
133
  `### STATES` and `### STATE` group objects of kind `STATE`. The grouped
134
- objects become childs of the object the heading is nested under. An
134
+ objects become children of the object the heading is nested under. An
135
135
  unquoted name must not contain a `:` (else the item would look like a
136
136
  key/value pair) -- backquote it in this case.
137
137
 
@@ -238,7 +238,7 @@ constrain reference-valued properties (e.g. `[[PERSONA:*]]` or
238
238
 
239
239
  A Markdown image `![<alt/>](<file/>)` inside a description or property
240
240
  value embeds a local image file, resolved relative to the artifact file:
241
- SVG files are inlined as-is, PNG/JPEG files are embedded as base64
241
+ SVG files are inlined as-is; PNG/JPEG files are embedded as base64
242
242
  `data:` URLs. URLs and other file types are left untouched.
243
243
 
244
244
  ### Normalization
@@ -285,7 +285,7 @@ descriptions as well. As the *nesting* of the objects carries no such
285
285
  reference, `hierarchy: true` additionally derives a containment edge
286
286
  from every node object to each of its child objects which is part of the
287
287
  node set. This is what turns a parent object into the hub of its own
288
- childs, as the `TIER` objects of the sample schema configuration
288
+ children, as the `TIER` objects of the sample schema configuration
289
289
  demonstrate:
290
290
 
291
291
  ```yaml
@@ -304,7 +304,7 @@ how the HTML/PDF export renders the child objects of every object of
304
304
  that kind. Its `type` selects the rendering: `complex` (nested
305
305
  sections), `concise` (compact per-kind tables), or the default `auto`,
306
306
  which collapses only the deepest level into tables. Below a `concise`
307
- object, unconfigured childs implicitly stay `concise` and render as
307
+ object, unconfigured children implicitly stay `concise` and render as
308
308
  sub-tables inside the description cells, while an explicitly configured
309
309
  `type` is always honored, even a `complex` rendering pressed into a
310
310
  cell. `maxTableColumns` (default: `4`) bounds the columns of the compact
@@ -6,6 +6,7 @@ export { renderDiagnostic, renderDiagnosticVerbose, type Diagnostic };
6
6
  export { type LintResult };
7
7
  export type { Specification, Artifact, Object, Description, Property } from "./specbook-struct-spec.js";
8
8
  export type { SchemaSpecification, SchemaObject, SchemaProperty } from "./specbook-struct-schema.js";
9
+ export declare const version: string;
9
10
  export interface SpecBookOptions {
10
11
  verbose?: (msg: string) => void;
11
12
  }
@@ -3,6 +3,9 @@
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 { loadConfig } from "./specbook-config.js";
7
10
  import { renderDiagnostic, renderDiagnosticVerbose } from "./specbook-diagnostic.js";
8
11
  import { initSpecification } from "./specbook-cmd-init.js";
@@ -14,6 +17,12 @@ import { editSpecification } from "./specbook-cmd-edit.js";
14
17
  /* re-export the central types for API consumers */
15
18
  export { formats, parseOutputSpec };
16
19
  export { renderDiagnostic, renderDiagnosticVerbose };
20
+ /* the own version, taken from the package manifest, which resides one
21
+ level above both the source and the compiled module directory */
22
+ export const version = (() => {
23
+ const manifest = path.join(path.dirname(fileURLToPath(import.meta.url)), "..", "package.json");
24
+ return JSON.parse(fs.readFileSync(manifest, "utf8")).version;
25
+ })();
17
26
  /* the SpecBook API */
18
27
  export class SpecBook {
19
28
  verbose;
@@ -53,7 +62,7 @@ export class SpecBook {
53
62
  if (result.specification.artifacts.length === 0)
54
63
  throw new Error("unexportable specification:\n" +
55
64
  result.diagnostics.map(renderDiagnostic).join("\n"));
56
- const buffers = [];
65
+ const buffers = new Array();
57
66
  for (const format of options.formats ?? ["json"])
58
67
  buffers.push(await exportSpecification(result.specification, format, this.verbose, result.config));
59
68
  return buffers;
@@ -6,7 +6,7 @@
6
6
  */
7
7
  import * as fs from "node:fs";
8
8
  import { Command } from "commander";
9
- import { SpecBook, renderDiagnostic, renderDiagnosticVerbose, parseOutputSpec } from "./specbook-api.js";
9
+ import { SpecBook, renderDiagnostic, renderDiagnosticVerbose, parseOutputSpec, version } from "./specbook-api.js";
10
10
  import { serveMcp } from "./specbook-mcp.js";
11
11
  /* route verbose messages to stderr, keeping stdout reserved
12
12
  for the command outputs and the MCP protocol */
@@ -33,7 +33,7 @@ const writeOutput = async (output, data, verbose) => {
33
33
  await writeStdout(data);
34
34
  else {
35
35
  await fs.promises.writeFile(output, data);
36
- verbose(`wrote "${output}" (${data.length} bytes)`);
36
+ verbose(`wrote "${output}" (${Buffer.byteLength(data)} bytes)`);
37
37
  }
38
38
  };
39
39
  /* determine an option default value, overridable via a
@@ -46,14 +46,14 @@ const envDefaultFlag = (name, fallback) => {
46
46
  return value !== undefined ? !(/^(?:|0|false|no|off)$/i).test(value) : fallback;
47
47
  };
48
48
  /* provide the common options of all sub-commands */
49
- const common = (command) => command
49
+ const withCommonOptions = (command) => command
50
50
  .option("-v, --verbose", "print verbose processing information to stderr", envDefaultFlag("verbose", false))
51
51
  .option("-c, --config <yaml-file>", "YAML schema configuration file", envDefault("config"));
52
52
  /* parse the command line */
53
53
  const program = new Command();
54
54
  program.name("specbook")
55
55
  .description("Markdown-based Specification Format")
56
- .version("0.9.0")
56
+ .version(version)
57
57
  .action(() => {
58
58
  program.help();
59
59
  });
@@ -64,24 +64,24 @@ program.command("mcp")
64
64
  verboseOf(opts)("starting MCP server on stdio");
65
65
  await serveMcp(verboseOf(opts));
66
66
  });
67
- common(program.command("init"))
67
+ withCommonOptions(program.command("init"))
68
68
  .description("initialize the configured specification artifact files below the base directory")
69
69
  .option("-b, --basedir <directory>", "base directory of the specification Markdown files", envDefault("basedir", "."))
70
70
  .action(async (opts) => {
71
71
  const specbook = new SpecBook({ verbose: verboseOf(opts) });
72
72
  const created = await specbook.init({ config: opts.config, basedir: opts.basedir });
73
- process.stdout.write(created.length > 0 ?
73
+ await writeStdout(created.length > 0 ?
74
74
  `initialized artifact file(s): ${created.join(", ")}\n` :
75
75
  "no artifact files were created\n");
76
76
  });
77
- common(program.command("lint"))
77
+ withCommonOptions(program.command("lint"))
78
78
  .description("lint the specification Markdown files below the base directory")
79
79
  .option("-b, --basedir <directory>", "base directory of the specification Markdown files", envDefault("basedir", "."))
80
80
  .action(async (opts) => {
81
81
  const specbook = new SpecBook({ verbose: verboseOf(opts) });
82
82
  const result = await specbook.lint({ config: opts.config, basedir: opts.basedir });
83
83
  for (const diagnostic of result.diagnostics)
84
- process.stdout.write(opts.verbose ?
84
+ await writeStdout(opts.verbose ?
85
85
  renderDiagnosticVerbose(diagnostic, process.stdout.isTTY === true) :
86
86
  `${renderDiagnostic(diagnostic)}\n`);
87
87
  if (result.diagnostics.length > 0)
@@ -89,7 +89,7 @@ common(program.command("lint"))
89
89
  else
90
90
  verboseOf(opts)("specification valid");
91
91
  });
92
- common(program.command("export"))
92
+ withCommonOptions(program.command("export"))
93
93
  .description("export the specification Markdown files below the base directory " +
94
94
  "as JSON, JSON5, YAML, TOON, HTML, PDF, or normalized Markdown")
95
95
  .option("-b, --basedir <directory>", "base directory of the specification Markdown files", envDefault("basedir", "."))
@@ -106,7 +106,7 @@ common(program.command("export"))
106
106
  for (const { format, output } of outputs)
107
107
  await writeOutput(output, buffers[distinct.indexOf(format)], verboseOf(opts));
108
108
  });
109
- common(program.command("describe"))
109
+ withCommonOptions(program.command("describe"))
110
110
  .description("describe the configured specification format as Markdown")
111
111
  .option("-o, --output <markdown-file>", "output file (\"-\" for stdout)", envDefault("output", "-"))
112
112
  .action(async (opts) => {
@@ -114,7 +114,7 @@ common(program.command("describe"))
114
114
  const text = await specbook.describe({ config: opts.config });
115
115
  await writeOutput(opts.output, text, verboseOf(opts));
116
116
  });
117
- common(program.command("import"))
117
+ withCommonOptions(program.command("import"))
118
118
  .description("import foreign sources into the specification artifact files below the base directory")
119
119
  .option("-b, --basedir <directory>", "base directory of the specification Markdown files", envDefault("basedir", "."))
120
120
  .option("--provider <provider>", "AI provider (\"anthropic\", \"openai\", \"openrouter\" or \"ollama\")", envDefault("provider"))
@@ -124,11 +124,11 @@ common(program.command("import"))
124
124
  const specbook = new SpecBook({ verbose: verboseOf(opts) });
125
125
  const written = await specbook.import({ config: opts.config, basedir: opts.basedir,
126
126
  inputs, provider: opts.provider, model: opts.model });
127
- process.stdout.write(written.length > 0 ?
127
+ await writeStdout(written.length > 0 ?
128
128
  `imported into artifact file(s): ${written.join(", ")}\n` :
129
129
  "no artifact files were changed\n");
130
130
  });
131
- common(program.command("edit"))
131
+ withCommonOptions(program.command("edit"))
132
132
  .description("apply a free-text edit request to the specification artifact files below the base directory")
133
133
  .option("-b, --basedir <directory>", "base directory of the specification Markdown files", envDefault("basedir", "."))
134
134
  .option("--provider <provider>", "AI provider (\"anthropic\", \"openai\", \"openrouter\" or \"ollama\")", envDefault("provider"))
@@ -138,7 +138,7 @@ common(program.command("edit"))
138
138
  const specbook = new SpecBook({ verbose: verboseOf(opts) });
139
139
  const written = await specbook.edit({ config: opts.config, basedir: opts.basedir,
140
140
  query, provider: opts.provider, model: opts.model });
141
- process.stdout.write(written.length > 0 ?
141
+ await writeStdout(written.length > 0 ?
142
142
  `edited artifact file(s): ${written.join(", ")}\n` :
143
143
  "no artifact files were changed\n");
144
144
  });
@@ -84,11 +84,13 @@ export const describeConfiguration = (config) => {
84
84
  const sections = new Array();
85
85
  for (const artifact of config) {
86
86
  const lines = new Array();
87
- lines.push(`### ${artifact.kind}: ${artifact.name} (${artifact.kind}-${artifact.id})`);
87
+ lines.push(`### ${artifact.kind}: ${artifact.name ?? ""} (${artifact.kind}-${artifact.id ?? ""})`);
88
88
  lines.push("");
89
89
  if (artifact.file !== undefined)
90
90
  lines.push(`- file: \`${artifact.file}\``);
91
91
  lines.push(`- description: ${collapse(artifact.desc)}`);
92
+ for (const prop of artifact.props ?? [])
93
+ lines.push(describeProperty(prop, ""));
92
94
  for (const child of artifact.childs ?? [])
93
95
  lines.push(describeObject(child, 2, ""));
94
96
  sections.push(lines.join("\n"));
@@ -42,6 +42,9 @@ const instruction = (description, now) => textframe `
42
42
  export const editSpecification = async (options) => {
43
43
  const basedir = options.basedir ?? ".";
44
44
  const files = options.config.map((artifact) => artifact.file).filter((file) => file !== undefined);
45
+ /* ensure a non-empty edit request was given */
46
+ if (options.query.trim() === "")
47
+ throw new Error("no edit request given");
45
48
  /* gather the existing artifact files */
46
49
  const existing = files
47
50
  .filter((file) => fs.existsSync(path.join(basedir, file)))
@@ -61,7 +64,9 @@ export const editSpecification = async (options) => {
61
64
  options.verbose(`ignoring unconfigured file "${block.name}"`);
62
65
  continue;
63
66
  }
64
- fs.writeFileSync(path.join(basedir, block.name), block.content, "utf8");
67
+ const file = path.join(basedir, block.name);
68
+ fs.mkdirSync(path.dirname(file), { recursive: true });
69
+ fs.writeFileSync(file, block.content, "utf8");
65
70
  options.verbose(`wrote artifact file "${block.name}"`);
66
71
  written.push(block.name);
67
72
  }
@@ -32,8 +32,9 @@ export const parseOutputSpec = (spec) => {
32
32
  if (spec === "-")
33
33
  return { format: "json", output: spec };
34
34
  const ext = spec.match(/\.([a-zA-Z0-9]+)$/);
35
- if (ext !== null && extensions[ext[1].toLowerCase()] !== undefined)
36
- return { format: extensions[ext[1].toLowerCase()], output: spec };
35
+ const format = ext !== null ? extensions[ext[1].toLowerCase()] : undefined;
36
+ if (format !== undefined)
37
+ return { format, output: spec };
37
38
  throw new Error(`unable to infer export format from output "${spec}" ` +
38
39
  "(use an explicit \"<format>:<filename>\" specification)");
39
40
  };
@@ -46,6 +46,9 @@ const instruction = (description, now) => textframe `
46
46
  export const importSpecification = async (options) => {
47
47
  const basedir = options.basedir ?? ".";
48
48
  const files = options.config.map((artifact) => artifact.file).filter((file) => file !== undefined);
49
+ /* ensure at least one foreign source was given */
50
+ if (options.inputs.length === 0)
51
+ throw new Error("no foreign sources given to import");
49
52
  /* gather the existing artifact files and the foreign sources */
50
53
  const existing = files
51
54
  .filter((file) => fs.existsSync(path.join(basedir, file)))
@@ -65,7 +68,9 @@ export const importSpecification = async (options) => {
65
68
  options.verbose(`ignoring unconfigured file "${block.name}"`);
66
69
  continue;
67
70
  }
68
- fs.writeFileSync(path.join(basedir, block.name), block.content, "utf8");
71
+ const file = path.join(basedir, block.name);
72
+ fs.mkdirSync(path.dirname(file), { recursive: true });
73
+ fs.writeFileSync(file, block.content, "utf8");
69
74
  options.verbose(`wrote artifact file "${block.name}"`);
70
75
  written.push(block.name);
71
76
  }
@@ -21,14 +21,16 @@ export const initSpecification = (options) => {
21
21
  options.verbose(`skipping existing artifact file "${artifact.file}"`);
22
22
  continue;
23
23
  }
24
- const name = (artifact.name ?? "").toUpperCase();
24
+ const name = artifact.name ?? "";
25
+ const paren = artifact.id !== undefined ? ` (${artifact.id})` : "";
25
26
  const text = "---\n" +
26
27
  `Created: ${now}\n` +
27
28
  `Modified: ${now}\n` +
28
29
  "---\n" +
29
30
  "\n" +
30
- `# ${artifact.kind}: ${name} (${artifact.kind}-${artifact.id})\n` +
31
+ `# ${artifact.kind}: ${name}${paren}\n` +
31
32
  "\n";
33
+ fs.mkdirSync(path.dirname(file), { recursive: true });
32
34
  fs.writeFileSync(file, text, "utf8");
33
35
  options.verbose(`created artifact file "${artifact.file}"`);
34
36
  created.push(artifact.file);
@@ -25,11 +25,13 @@ export const lint = (options) => {
25
25
  config = loaded.config ?? undefined;
26
26
  }
27
27
  /* parse and validate the specification Markdown files */
28
- const files = scanMarkdown(options.basedir);
28
+ const exists = fs.existsSync(options.basedir) && fs.statSync(options.basedir).isDirectory();
29
+ const files = exists ? scanMarkdown(options.basedir) : [];
29
30
  options.verbose(`parsing ${files.length} specification file(s) below "${options.basedir}"`);
30
31
  if (files.length === 0)
31
32
  diagnostics.push({ file: options.basedir, line: 1, column: 1,
32
- message: "no Markdown files found below base directory" });
33
+ message: exists ? "no Markdown files found below base directory" :
34
+ "base directory does not exist" });
33
35
  const sources = new Array();
34
36
  for (const file of files) {
35
37
  try {
@@ -24,7 +24,17 @@ const lineColOfPath = (yaml, cst, path) => {
24
24
  /* load and validate a YAML schema configuration file */
25
25
  export const loadConfig = (file) => {
26
26
  const diagnostics = [];
27
- const yaml = fs.readFileSync(file, "utf8");
27
+ /* read the configuration file */
28
+ let yaml;
29
+ try {
30
+ yaml = fs.readFileSync(file, "utf8");
31
+ }
32
+ catch (err) {
33
+ diagnostics.push({ file, line: 1, column: 1,
34
+ message: "cannot read configuration file: " +
35
+ (err instanceof Error ? err.message : String(err)) });
36
+ return { config: null, diagnostics };
37
+ }
28
38
  /* syntactically parse the YAML content */
29
39
  let obj;
30
40
  try {
@@ -37,17 +47,22 @@ export const loadConfig = (file) => {
37
47
  line: e?.linePos?.[0]?.line ?? 1,
38
48
  column: e?.linePos?.[0]?.col ?? 1,
39
49
  message: "invalid YAML syntax: " +
40
- (err instanceof Error ? err.message.replace(/:(?:.|\r?\n)*$/, "") : String(err))
50
+ (err instanceof Error ? err.message.replace(/:[\s\S]*$/, "") : String(err))
41
51
  });
42
52
  return { config: null, diagnostics };
43
53
  }
44
- /* semantically validate against the schema of the configuration */
45
- const cst = parseDocument(yaml);
54
+ /* semantically validate against the schema of the configuration
55
+ (the CST is parsed lazily, as it is needed for positions only) */
56
+ let cst;
57
+ const posOfPath = (path) => {
58
+ cst ??= parseDocument(yaml);
59
+ return lineColOfPath(yaml, cst, path);
60
+ };
46
61
  const result = v.safeParse(SchemaSpecification, obj);
47
62
  if (!result.success) {
48
63
  for (const issue of result.issues) {
49
64
  const path = (issue.path ?? []).map((item) => item.key);
50
- const pos = lineColOfPath(yaml, cst, path);
65
+ const pos = posOfPath(path);
51
66
  diagnostics.push({ file, line: pos.line, column: pos.column,
52
67
  message: `invalid configuration: ${path.join(".")}: ${issue.message}` });
53
68
  }
@@ -57,7 +72,7 @@ export const loadConfig = (file) => {
57
72
  const checkFileField = (objects, path, depth) => {
58
73
  objects.forEach((object, i) => {
59
74
  if (depth > 1 && object.file !== undefined) {
60
- const pos = lineColOfPath(yaml, cst, [...path, i, "file"]);
75
+ const pos = posOfPath([...path, i, "file"]);
61
76
  diagnostics.push({ file, line: pos.line, column: pos.column,
62
77
  message: `"file" field is only allowed on the first (artifact) level (found on level ${depth})` });
63
78
  }
@@ -76,7 +91,7 @@ export const loadConfig = (file) => {
76
91
  compileValueExpr(prop.value);
77
92
  }
78
93
  catch (err) {
79
- const pos = lineColOfPath(yaml, cst, [...path, i, "props", j, "value"]);
94
+ const pos = posOfPath([...path, i, "props", j, "value"]);
80
95
  diagnostics.push({ file, line: pos.line, column: pos.column,
81
96
  message: `invalid value expression "${prop.value}": ` +
82
97
  (err instanceof Error ? err.message : String(err)) });
@@ -4,6 +4,9 @@
4
4
  ** Licensed under Apache 2.0 <https://spdx.org/licenses/Apache-2.0>
5
5
  */
6
6
  import { referenceRegex, buildLinkIndex, resolveUnique, resolveSet, anchorPaths } from "./specbook-link.js";
7
+ /* the single Wiki-style reference match (the non-global sibling of
8
+ the imported, global "referenceRegex") */
9
+ const referenceOnce = /\[\[([^[\]]+)\]\]/;
7
10
  /* strip the inline code markup of a name or property value
8
11
  (preserved in the AST for rendering) for labeling purposes */
9
12
  const plainText = (text) => text.replace(/`/g, "");
@@ -27,7 +30,56 @@ const arityToken = (text) => /^[^\s[\]"]+$/.test(text) ? text : quoted(text);
27
30
  collapsed, as a directive is a single line) */
28
31
  const configValue = (value) => {
29
32
  const text = String(value).replace(/\s+/g, " ").trim();
30
- return /^\S+$/.test(text) ? text : quoted(text);
33
+ return /^[^\s"]+$/.test(text) ? text : quoted(text);
34
+ };
35
+ /* generate the Gradia spec text of a derived diagram, opened by a
36
+ "#type" directive and the configured "#config" directives so every
37
+ embedded spec is self-contained: one node statement per object (the
38
+ unique anchor path as the node id, the object name as the displayed
39
+ label) and one edge statement per derived edge */
40
+ const renderSpec = (diagram, type, center, nodes, edges, index, anchors) => {
41
+ const lines = [`#type ${type}`];
42
+ const config = diagram.config ?? {};
43
+ for (const key of Object.keys(config)) {
44
+ const value = config[key];
45
+ if (value !== undefined)
46
+ lines.push(`#config ${key} ${configValue(value)}`);
47
+ }
48
+ lines.push("");
49
+ for (const node of nodes) {
50
+ const anchor = anchors.get(node) ?? node.id;
51
+ const attrs = [`url: ${atom(`#${anchor}`)}`];
52
+ if (diagram.qualified === true && node.kind !== "")
53
+ attrs.push(`type: ${atom(node.kind)}`);
54
+ if (type === "hub" ? node === center : node.primary === true)
55
+ attrs.push("primary: true");
56
+ /* attach the values of the configured properties as Gradia
57
+ key/value attributes (a node lacking a property is fine, as
58
+ the node set can mix objects of different kinds), with every
59
+ "[[...]]" reference stripped to its target object name */
60
+ for (const key of diagram.properties ?? []) {
61
+ const value = node.properties.find((property) => plainKey(property.key) === key)?.value;
62
+ if (value !== undefined) {
63
+ const text = value.replace(referenceRegex, (_, reference) => {
64
+ const target = resolveUnique(index, reference.trim()).target;
65
+ return target !== undefined ? target.name : reference.trim();
66
+ });
67
+ attrs.push(`${atom(key)}: ${atom(plainText(text))}`);
68
+ }
69
+ }
70
+ lines.push(`${atom(anchor)}: ${atom(plainText(node.name))} [ ${attrs.join(", ")} ]`);
71
+ }
72
+ if (edges.length > 0) {
73
+ lines.push("");
74
+ for (const edge of edges) {
75
+ const op = "--" +
76
+ (edge.name !== undefined && edge.name !== "" ? `(${nameToken(edge.name)})--` : "") + ">" +
77
+ (edge.arity !== undefined && edge.arity !== "" ? `[${arityToken(edge.arity)}]` : "");
78
+ lines.push(`${atom(anchors.get(edge.source) ?? edge.source.id)} ${op} ` +
79
+ atom(anchors.get(edge.target) ?? edge.target.id));
80
+ }
81
+ }
82
+ return lines.join("\n") + "\n";
31
83
  };
32
84
  /* derive the Gradia spec of a single object from its "diagram:"
33
85
  schema configuration (the returned spec is absent whenever the
@@ -69,7 +121,8 @@ const deriveDiagram = (object, diagram, index, anchors, parents) => {
69
121
  walk(object);
70
122
  }
71
123
  const edgeObjects = diagram.edges !== undefined ? resolvePatterns(diagram.edges, "edges") : [];
72
- nodes = nodes.filter((node) => !edgeObjects.includes(node));
124
+ const edgeSet = new Set(edgeObjects);
125
+ nodes = nodes.filter((node) => !edgeSet.has(node));
73
126
  const nodeSet = new Set(nodes);
74
127
  /* derive the edges: from the "[[...]]" references of the node
75
128
  objects (per the "links" selection) and from the edge objects by
@@ -98,8 +151,8 @@ const deriveDiagram = (object, diagram, index, anchors, parents) => {
98
151
  const value = diagram.edgeTarget !== undefined ?
99
152
  edgeObject.properties.find((property) => plainKey(property.key) === diagram.edgeTarget)?.value :
100
153
  edgeObject.properties.map((property) => property.value)
101
- .find((v) => /\[\[[^[\]]+\]\]/.test(v));
102
- const reference = value?.match(/\[\[([^[\]]+)\]\]/)?.[1].trim();
154
+ .find((v) => referenceOnce.test(v));
155
+ const reference = value?.match(referenceOnce)?.[1].trim();
103
156
  const target = reference !== undefined ? resolveUnique(index, reference).target : undefined;
104
157
  if (source === undefined) {
105
158
  errors.push(`diagram edge object "${edgeObject.name}" has no parent object as source`);
@@ -124,15 +177,18 @@ const deriveDiagram = (object, diagram, index, anchors, parents) => {
124
177
  edges.push({ source: parent, target: node });
125
178
  }
126
179
  }
127
- else if (diagram.edges !== undefined)
128
- errors.push("\"grid\" diagram cannot carry an \"edges\" configuration");
129
- else if (diagram.hierarchy === true)
130
- errors.push("\"grid\" diagram cannot carry a \"hierarchy\" configuration");
180
+ else {
181
+ if (diagram.edges !== undefined)
182
+ errors.push("\"grid\" diagram cannot carry an \"edges\" configuration");
183
+ if (diagram.hierarchy === true)
184
+ errors.push("\"grid\" diagram cannot carry a \"hierarchy\" configuration");
185
+ }
131
186
  /* deduplicate the edges (the same reference can occur in
132
187
  multiple texts of the same node object) */
133
188
  const seenEdges = new Set();
134
189
  edges = edges.filter((edge) => {
135
- const key = `${anchors.get(edge.source)}\u0000${anchors.get(edge.target)}` +
190
+ const key = `${anchors.get(edge.source) ?? edge.source.id}` +
191
+ `\u0000${anchors.get(edge.target) ?? edge.target.id}` +
136
192
  `\u0000${edge.name ?? ""}\u0000${edge.arity ?? ""}`;
137
193
  if (seenEdges.has(key))
138
194
  return false;
@@ -144,7 +200,7 @@ const deriveDiagram = (object, diagram, index, anchors, parents) => {
144
200
  resolved object of an explicit "[[...]]" reference otherwise */
145
201
  let center = object;
146
202
  if (type === "hub" && diagram.center !== undefined && diagram.center !== "self") {
147
- const reference = diagram.center.match(/\[\[([^[\]]+)\]\]/)?.[1].trim();
203
+ const reference = diagram.center.match(referenceOnce)?.[1].trim();
148
204
  const resolved = reference !== undefined ? resolveUnique(index, reference) : undefined;
149
205
  if (resolved?.target === undefined) {
150
206
  errors.push(`${resolved?.ambiguous === true ? "ambiguous" : "unresolvable"}` +
@@ -190,53 +246,7 @@ const deriveDiagram = (object, diagram, index, anchors, parents) => {
190
246
  diagram carries no information beyond the object itself */
191
247
  if (diagram.collapse !== false && nodes.length === 1 && edges.length === 0)
192
248
  return { errors };
193
- /* generate the Gradia spec text, opened by a "#type" directive and
194
- the configured "#config" directives so every embedded spec is
195
- self-contained: one node statement per object (the unique anchor
196
- path as the node id, the object name as the displayed label)
197
- and one edge statement per derived edge */
198
- const lines = [`#type ${type}`];
199
- const config = diagram.config ?? {};
200
- for (const key of Object.keys(config)) {
201
- const value = config[key];
202
- if (value !== undefined)
203
- lines.push(`#config ${key} ${configValue(value)}`);
204
- }
205
- lines.push("");
206
- for (const node of nodes) {
207
- const anchor = anchors.get(node) ?? node.id;
208
- const attrs = [`url: ${atom(`#${anchor}`)}`];
209
- if (diagram.qualified === true && node.kind !== "")
210
- attrs.push(`type: ${atom(node.kind)}`);
211
- if (type === "hub" ? node === center : node.primary === true)
212
- attrs.push("primary: true");
213
- /* attach the values of the configured properties as Gradia
214
- key/value attributes (a node lacking a property is fine, as
215
- the node set can mix objects of different kinds), with every
216
- "[[...]]" reference stripped to its target object name */
217
- for (const key of diagram.properties ?? []) {
218
- const value = node.properties.find((property) => plainKey(property.key) === key)?.value;
219
- if (value !== undefined) {
220
- const text = value.replace(referenceRegex, (_, reference) => {
221
- const target = resolveUnique(index, reference.trim()).target;
222
- return target !== undefined ? target.name : reference.trim();
223
- });
224
- attrs.push(`${atom(key)}: ${atom(plainText(text))}`);
225
- }
226
- }
227
- lines.push(`${atom(anchor)}: ${atom(plainText(node.name))} [ ${attrs.join(", ")} ]`);
228
- }
229
- if (edges.length > 0) {
230
- lines.push("");
231
- for (const edge of edges) {
232
- const op = "--" +
233
- (edge.name !== undefined && edge.name !== "" ? `(${nameToken(edge.name)})--` : "") + ">" +
234
- (edge.arity !== undefined && edge.arity !== "" ? `[${arityToken(edge.arity)}]` : "");
235
- lines.push(`${atom(anchors.get(edge.source) ?? edge.source.id)} ${op} ` +
236
- atom(anchors.get(edge.target) ?? edge.target.id));
237
- }
238
- }
239
- return { spec: lines.join("\n") + "\n", errors };
249
+ return { spec: renderSpec(diagram, type, center, nodes, edges, index, anchors), errors };
240
250
  };
241
251
  /* collect the objects whose schema configuration carries a "diagram:"
242
252
  field (the schema resolution mirrors the semantic validation) */
@@ -25,10 +25,13 @@ export const renderAst = async (specification, format, config) => {
25
25
  };
26
26
  specification.artifacts.forEach((artifact, i) => artifact.objects.forEach((object, j) => walk(object, plain.artifacts[i].objects[j])));
27
27
  }
28
+ /* serialize the plain specification into the target format
29
+ (JSON and JSON5 in compressed form, as they are intended
30
+ for machine consumption only) */
28
31
  if (format === "json")
29
- return Buffer.from(JSON.stringify(plain, null, 4) + "\n", "utf8");
32
+ return Buffer.from(JSON.stringify(plain) + "\n", "utf8");
30
33
  else if (format === "json5")
31
- return Buffer.from(JSON5.stringify(plain, null, 4) + "\n", "utf8");
34
+ return Buffer.from(JSON5.stringify(plain) + "\n", "utf8");
32
35
  else if (format === "yaml")
33
36
  return Buffer.from(stringifyYaml(plain), "utf8");
34
37
  else
@@ -18,7 +18,6 @@ export type PaperSetup = {
18
18
  right: number;
19
19
  };
20
20
  };
21
- export declare const paperSizes: string[];
22
21
  export declare const paperSizeDefault = "A4";
23
22
  export declare const paperSetup: (paper: string) => PaperSetup;
24
23
  export declare const paperLength: (setup: PaperSetup, value: number) => string;