@rse/specbook 0.9.5 → 0.9.7

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 (61) hide show
  1. package/README.md +108 -328
  2. package/dst/specbook-api.d.ts +13 -20
  3. package/dst/specbook-api.js +43 -39
  4. package/dst/specbook-cli.js +42 -55
  5. package/dst/specbook-cmd-describe.d.ts +5 -2
  6. package/dst/specbook-cmd-describe.js +30 -91
  7. package/dst/specbook-cmd-export.d.ts +3 -3
  8. package/dst/specbook-cmd-export.js +23 -19
  9. package/dst/specbook-cmd-init.d.ts +3 -3
  10. package/dst/specbook-cmd-init.js +38 -15
  11. package/dst/specbook-cmd-lint.d.ts +5 -6
  12. package/dst/specbook-cmd-lint.js +61 -24
  13. package/dst/specbook-config.d.ts +2 -2
  14. package/dst/specbook-config.js +82 -73
  15. package/dst/specbook-diagnostic.js +4 -1
  16. package/dst/specbook-diagram.d.ts +6 -5
  17. package/dst/specbook-diagram.js +94 -104
  18. package/dst/specbook-export-ast.d.ts +3 -3
  19. package/dst/specbook-export-ast.js +1 -1
  20. package/dst/specbook-export-common.d.ts +11 -9
  21. package/dst/specbook-export-common.js +34 -22
  22. package/dst/specbook-export-html-search.js +10 -14
  23. package/dst/specbook-export-html.css +37 -32
  24. package/dst/specbook-export-html.d.ts +4 -4
  25. package/dst/specbook-export-html.js +153 -107
  26. package/dst/specbook-export-logo-dark.svg +35 -0
  27. package/dst/specbook-export-logo-light.svg +35 -0
  28. package/dst/specbook-export-md.d.ts +3 -3
  29. package/dst/specbook-export-md.js +50 -24
  30. package/dst/specbook-export-pdf.d.ts +4 -2
  31. package/dst/specbook-export-pdf.js +48 -38
  32. package/dst/{specbook-struct-schema.d.ts → specbook-format-schema.d.ts} +3 -2
  33. package/dst/{specbook-struct-schema.js → specbook-format-schema.js} +8 -4
  34. package/dst/specbook-format-spec.d.ts +31 -0
  35. package/dst/{specbook-struct-spec.js → specbook-format-spec.js} +10 -10
  36. package/dst/specbook-format.md +750 -0
  37. package/dst/specbook-link.d.ts +3 -2
  38. package/dst/specbook-link.js +7 -4
  39. package/dst/specbook-mcp.d.ts +2 -1
  40. package/dst/specbook-mcp.js +30 -62
  41. package/dst/specbook-parse-common.d.ts +5 -3
  42. package/dst/specbook-parse-common.js +8 -0
  43. package/dst/specbook-parse-semantic.d.ts +6 -4
  44. package/dst/specbook-parse-semantic.js +98 -106
  45. package/dst/specbook-parse-syntax.d.ts +2 -2
  46. package/dst/specbook-parse-syntax.js +175 -144
  47. package/dst/specbook-parse-value.d.ts +1 -1
  48. package/dst/specbook-parse-value.js +6 -3
  49. package/dst/specbook-parse.d.ts +4 -7
  50. package/dst/specbook-parse.js +28 -35
  51. package/dst/specbook-verbose.d.ts +2 -0
  52. package/dst/specbook-verbose.js +17 -0
  53. package/package.json +3 -7
  54. package/dst/specbook-cmd-edit.d.ts +0 -9
  55. package/dst/specbook-cmd-edit.js +0 -74
  56. package/dst/specbook-cmd-import.d.ts +0 -9
  57. package/dst/specbook-cmd-import.js +0 -78
  58. package/dst/specbook-export-logo.svg +0 -35
  59. package/dst/specbook-llm.d.ts +0 -13
  60. package/dst/specbook-llm.js +0 -51
  61. package/dst/specbook-struct-spec.d.ts +0 -31
@@ -1,15 +1,14 @@
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[];
15
14
  export declare const lint: (options: LintOptions) => LintResult;
@@ -6,43 +6,80 @@
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 { 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();
9
+ import { literal } from "./specbook-verbose.js";
10
+ import { parseSpecification, resolveArtifact } from "./specbook-parse.js";
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
+ 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;
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
+ if (config !== undefined && files.size === 0)
37
+ diagnostics.push({ file: options.config, line: 1, column: 1,
38
+ message: "no artifact files configured" });
35
39
  const sources = new Array();
36
- for (const file of files) {
40
+ const present = new Set();
41
+ for (const [name, optional] of files) {
42
+ const file = path.join(options.basedir, name);
43
+ if (!fs.existsSync(file)) {
44
+ if (!optional)
45
+ diagnostics.push({ file, line: 1, column: 1,
46
+ message: "missing artifact file" });
47
+ continue;
48
+ }
37
49
  try {
38
50
  sources.push({ file, text: fs.readFileSync(file, "utf8") });
51
+ present.add(name);
39
52
  }
40
53
  catch (err) {
41
54
  diagnostics.push({ file, line: 1, column: 1,
42
55
  message: `unreadable file: ${err instanceof Error ? err.message : String(err)}` });
43
56
  }
44
57
  }
58
+ /* parse and validate the artifact files against the configuration */
59
+ options.verbose(`parsing ${literal(sources.length)} specification file(s) ` +
60
+ `below "${literal(options.basedir)}"`);
45
61
  const result = parseSpecification(sources, config);
46
62
  diagnostics.push(...result.diagnostics);
63
+ /* report the non-optional artifacts absent from the specification,
64
+ against their loaded artifact file (an absent or unreadable file
65
+ is already reported above) or else against the configuration */
66
+ if (config !== undefined) {
67
+ const found = new Set();
68
+ for (const artifact of result.specification.artifacts)
69
+ for (const object of artifact.objects) {
70
+ const schema = resolveArtifact(config, object);
71
+ if (schema !== undefined)
72
+ found.add(schema);
73
+ }
74
+ for (const schema of config) {
75
+ if (schema.optional === true || found.has(schema)
76
+ || (schema.file !== undefined && !present.has(schema.file)))
77
+ continue;
78
+ const file = schema.file !== undefined ? path.join(options.basedir, schema.file) : options.config;
79
+ const paren = schema.id !== undefined ? ` (${schema.id})` : "";
80
+ diagnostics.push({ file, line: 1, column: 1,
81
+ message: `missing artifact "${schema.kind}: ${schema.name ?? ""}${paren}"` });
82
+ }
83
+ }
47
84
  return { specification: result.specification, diagnostics, config };
48
85
  };
@@ -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;
5
5
  diagnostics: Diagnostic[];
6
6
  };
@@ -4,26 +4,69 @@
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 { parse as parseYaml, parseDocument, YAMLParseError } from "yaml";
7
+ import { parseDocument, isNode, LineCounter } from "yaml";
8
8
  import * as v from "valibot";
9
- import { SchemaSpecification } from "./specbook-struct-schema.js";
10
- import { compileValueExpr } from "./specbook-parse-value.js";
11
- /* determine line/column of a YAML document path via the CST */
12
- const lineColOfPath = (yaml, cst, path) => {
9
+ import { Schema } from "./specbook-format-schema.js";
10
+ import { compileValueExpr, anchored } from "./specbook-parse-value.js";
11
+ /* determine line/column of a YAML document path via the node ranges
12
+ of the parsed document, falling back to the closest ancestor node */
13
+ const lineColOfPath = (doc, lines, path) => {
13
14
  for (let i = path.length; i >= 0; i--) {
14
- const node = cst.getIn(path.slice(0, i), true);
15
- if (typeof node === "object" && node !== null
16
- && "range" in node && Array.isArray(node.range)) {
17
- const start = node.range[0];
18
- const lines = yaml.slice(0, start).split("\n");
19
- return { line: lines.length, column: lines[lines.length - 1].length + 1 };
15
+ const node = doc.getIn(path.slice(0, i), true);
16
+ const start = isNode(node) ? node.range?.[0] : undefined;
17
+ if (start !== undefined) {
18
+ const pos = lines.linePos(start);
19
+ return { line: pos.line, column: pos.col };
20
20
  }
21
21
  }
22
22
  return { line: 1, column: 1 };
23
23
  };
24
+ /* check the constraints of a structurally valid configuration which
25
+ are beyond its schema: "file" fields are allowed on the first
26
+ (artifact) level only, property value expressions have to be
27
+ syntactically valid, and the names of non-artifact objects have to
28
+ be valid patterns (as they are compiled into regular expressions) */
29
+ const checkConstraints = (config, file, posOfPath) => {
30
+ const diagnostics = new Array();
31
+ const diagnose = (path, message) => {
32
+ const pos = posOfPath(path);
33
+ diagnostics.push({ file, line: pos.line, column: pos.column, message });
34
+ };
35
+ const check = (objects, path, depth) => {
36
+ for (const [i, object] of objects.entries()) {
37
+ const at = [...path, i];
38
+ if (depth > 1 && object.file !== undefined)
39
+ diagnose([...at, "file"], `"file" field is only allowed on the first (artifact) level (found on level ${depth})`);
40
+ for (const [j, prop] of (object.props ?? []).entries()) {
41
+ if (prop.value === undefined)
42
+ continue;
43
+ try {
44
+ compileValueExpr(prop.value);
45
+ }
46
+ catch (err) {
47
+ diagnose([...at, "props", j, "value"], `invalid value expression "${prop.value}": ` +
48
+ (err instanceof Error ? err.message : String(err)));
49
+ }
50
+ }
51
+ if (depth > 1 && object.name !== undefined) {
52
+ try {
53
+ anchored(object.name);
54
+ }
55
+ catch (err) {
56
+ diagnose([...at, "name"], `invalid name pattern "${object.name}": ` +
57
+ (err instanceof Error ? err.message : String(err)));
58
+ }
59
+ }
60
+ if (object.childs !== undefined)
61
+ check(object.childs, [...at, "childs"], depth + 1);
62
+ }
63
+ };
64
+ check(config, [], 1);
65
+ return diagnostics;
66
+ };
24
67
  /* load and validate a YAML schema configuration file */
25
68
  export const loadConfig = (file) => {
26
- const diagnostics = [];
69
+ const diagnostics = new Array();
27
70
  /* read the configuration file */
28
71
  let yaml;
29
72
  try {
@@ -33,74 +76,40 @@ export const loadConfig = (file) => {
33
76
  diagnostics.push({ file, line: 1, column: 1,
34
77
  message: "cannot read configuration file: " +
35
78
  (err instanceof Error ? err.message : String(err)) });
36
- return { config: null, diagnostics };
79
+ return { diagnostics };
37
80
  }
38
- /* syntactically parse the YAML content */
39
- let obj;
40
- try {
41
- obj = parseYaml(yaml);
42
- }
43
- catch (err) {
44
- const e = err instanceof YAMLParseError ? err : null;
45
- diagnostics.push({
46
- file,
47
- line: e?.linePos?.[0]?.line ?? 1,
48
- column: e?.linePos?.[0]?.col ?? 1,
49
- message: "invalid YAML syntax: " +
50
- (err instanceof Error ? err.message.replace(/:[\s\S]*$/, "") : String(err))
51
- });
52
- return { config: null, diagnostics };
81
+ /* syntactically parse the YAML content into its document, keeping
82
+ the line positions for the diagnostics */
83
+ const lines = new LineCounter();
84
+ const doc = parseDocument(yaml, { lineCounter: lines });
85
+ if (doc.errors.length > 0) {
86
+ /* strip the position and the source snippet the YAML parser
87
+ appends to its message, as the diagnostic carries the
88
+ position itself */
89
+ for (const err of doc.errors)
90
+ diagnostics.push({
91
+ file,
92
+ line: err.linePos?.[0].line ?? 1,
93
+ column: err.linePos?.[0].col ?? 1,
94
+ message: "invalid YAML syntax: " + err.message.replace(/ at line \d+, column \d+(?::\n[\s\S]*)?$/, "")
95
+ });
96
+ return { diagnostics };
53
97
  }
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
- };
61
- const result = v.safeParse(SchemaSpecification, obj);
98
+ const obj = doc.toJS();
99
+ /* semantically validate against the schema of the configuration */
100
+ const posOfPath = (path) => lineColOfPath(doc, lines, path);
101
+ const result = v.safeParse(Schema, obj);
62
102
  if (!result.success) {
63
103
  for (const issue of result.issues) {
64
104
  const path = (issue.path ?? []).map((item) => item.key);
65
105
  const pos = posOfPath(path);
106
+ const where = path.length > 0 ? `${path.join(".")}: ` : "";
66
107
  diagnostics.push({ file, line: pos.line, column: pos.column,
67
- message: `invalid configuration: ${path.join(".")}: ${issue.message}` });
108
+ message: `invalid configuration: ${where}${issue.message}` });
68
109
  }
69
- return { config: null, diagnostics };
110
+ return { diagnostics };
70
111
  }
71
- /* reject "file" fields below the first (artifact) level */
72
- const checkFileField = (objects, path, depth) => {
73
- objects.forEach((object, i) => {
74
- if (depth > 1 && object.file !== undefined) {
75
- const pos = posOfPath([...path, i, "file"]);
76
- diagnostics.push({ file, line: pos.line, column: pos.column,
77
- message: `"file" field is only allowed on the first (artifact) level (found on level ${depth})` });
78
- }
79
- if (object.childs !== undefined)
80
- checkFileField(object.childs, [...path, i, "childs"], depth + 1);
81
- });
82
- };
83
- checkFileField(result.output, [], 1);
84
- /* reject syntactically invalid property value expressions */
85
- const checkValueExpr = (objects, path) => {
86
- objects.forEach((object, i) => {
87
- (object.props ?? []).forEach((prop, j) => {
88
- if (prop.value === undefined)
89
- return;
90
- try {
91
- compileValueExpr(prop.value);
92
- }
93
- catch (err) {
94
- const pos = posOfPath([...path, i, "props", j, "value"]);
95
- diagnostics.push({ file, line: pos.line, column: pos.column,
96
- message: `invalid value expression "${prop.value}": ` +
97
- (err instanceof Error ? err.message : String(err)) });
98
- }
99
- });
100
- if (object.childs !== undefined)
101
- checkValueExpr(object.childs, [...path, i, "childs"]);
102
- });
103
- };
104
- checkValueExpr(result.output, []);
105
- return { config: diagnostics.length === 0 ? result.output : null, diagnostics };
112
+ /* check the constraints beyond the structural schema */
113
+ diagnostics.push(...checkConstraints(result.output, file, posOfPath));
114
+ return { config: diagnostics.length === 0 ? result.output : undefined, diagnostics };
106
115
  };
@@ -9,7 +9,8 @@ import sourceCodeError from "source-code-error";
9
9
  export const renderDiagnostic = (diagnostic) => `${diagnostic.file}:${diagnostic.line}:${diagnostic.column}: ${diagnostic.message}`;
10
10
  /* render a diagnostic as a multi-line message with the affected
11
11
  source snippet, falling back to the single-line message when the
12
- source file is unreadable (e.g. the file is a directory) */
12
+ source file is unreadable (e.g. the file is a directory) or empty
13
+ (as there is no snippet to show and sourceCodeError rejects it) */
13
14
  export const renderDiagnosticVerbose = (diagnostic, colors = false) => {
14
15
  let code;
15
16
  try {
@@ -18,6 +19,8 @@ export const renderDiagnosticVerbose = (diagnostic, colors = false) => {
18
19
  catch {
19
20
  return `${renderDiagnostic(diagnostic)}\n`;
20
21
  }
22
+ if (code === "")
23
+ return `${renderDiagnostic(diagnostic)}\n`;
21
24
  return sourceCodeError({ message: diagnostic.message, filename: diagnostic.file,
22
25
  code, line: diagnostic.line, column: diagnostic.column, colors });
23
26
  };
@@ -1,9 +1,10 @@
1
- import type { Specification, Object as SpecObject } from "./specbook-struct-spec.js";
2
- import type { SchemaSpecification } from "./specbook-struct-schema.js";
3
- import { type ParseContext } from "./specbook-parse-common.js";
1
+ import type { Spec, SpecObject } from "./specbook-format-spec.js";
2
+ import type { Schema, SchemaDiagram } from "./specbook-format-schema.js";
3
+ import type { ParseContext } from "./specbook-parse-common.js";
4
4
  export interface DiagramResult {
5
5
  spec?: string;
6
+ config?: SchemaDiagram["config"];
6
7
  errors: string[];
7
8
  }
8
- export declare const specDiagrams: (specification: Specification, config: SchemaSpecification) => Map<SpecObject, DiagramResult>;
9
- export declare const validateDiagrams: (ctx: ParseContext, specification: Specification, config: SchemaSpecification) => void;
9
+ export declare const specDiagrams: (specification: Spec, config: Schema) => Map<SpecObject, DiagramResult>;
10
+ export declare const validateDiagrams: (ctx: ParseContext, specification: Spec, config: Schema) => void;
@@ -3,16 +3,11 @@
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 { referenceRegex, buildLinkIndex, resolveUnique, resolveSet, anchorPaths } from "./specbook-link.js";
6
+ import { referenceRegex, buildLinkIndex, resolveUnique, resolveSet, anchorPaths, expandReferences, plainText } from "./specbook-link.js";
7
+ import { collectSchemas } from "./specbook-parse-semantic.js";
7
8
  /* the single Wiki-style reference match (the non-global sibling of
8
9
  the imported, global "referenceRegex") */
9
- const referenceOnce = /\[\[([^[\]]+)\]\]/;
10
- /* strip the inline code markup of a name or property value
11
- (preserved in the AST for rendering) for labeling purposes */
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();
10
+ const referenceOnce = new RegExp(referenceRegex.source);
16
11
  /* render a text as a Gradia atom: a bareword where possible
17
12
  (no whitespace, no special characters, no "--"), a quoted
18
13
  string otherwise */
@@ -40,11 +35,9 @@ const configValue = (value) => {
40
35
  const renderSpec = (diagram, type, center, nodes, edges, index, anchors) => {
41
36
  const lines = [`#type ${type}`];
42
37
  const config = diagram.config ?? {};
43
- for (const key of Object.keys(config)) {
44
- const value = config[key];
38
+ for (const [key, value] of Object.entries(config))
45
39
  if (value !== undefined)
46
40
  lines.push(`#config ${key} ${configValue(value)}`);
47
- }
48
41
  lines.push("");
49
42
  for (const node of nodes) {
50
43
  const anchor = anchors.get(node) ?? node.id;
@@ -58,12 +51,9 @@ const renderSpec = (diagram, type, center, nodes, edges, index, anchors) => {
58
51
  the node set can mix objects of different kinds), with every
59
52
  "[[...]]" reference stripped to its target object name */
60
53
  for (const key of diagram.properties ?? []) {
61
- const value = node.properties.find((property) => plainKey(property.key) === key)?.value;
54
+ const value = node.properties.find((property) => property.key === key)?.value;
62
55
  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
- });
56
+ const text = expandReferences(value, (reference) => resolveUnique(index, reference).target?.name ?? reference);
67
57
  attrs.push(`${atom(key)}: ${atom(plainText(text))}`);
68
58
  }
69
59
  }
@@ -81,56 +71,14 @@ const renderSpec = (diagram, type, center, nodes, edges, index, anchors) => {
81
71
  }
82
72
  return lines.join("\n") + "\n";
83
73
  };
84
- /* derive the Gradia spec of a single object from its "diagram:"
85
- schema configuration (the returned spec is absent whenever the
86
- configured diagram situation is invalid) */
87
- const deriveDiagram = (object, diagram, index, anchors, parents) => {
88
- const type = diagram.type ?? "graph";
89
- const errors = new Array();
90
- /* resolve a comma-separated "[[...]]" reference pattern string
91
- into its (deduplicated, order-preserving) object match set */
92
- const resolvePatterns = (value, field) => {
93
- const matches = new Array();
94
- const seen = new Set();
95
- for (const m of value.matchAll(referenceRegex)) {
96
- const pattern = m[1].trim();
97
- const set = resolveSet(index, pattern);
98
- if (set.length === 0)
99
- errors.push(`unresolvable diagram "${field}" pattern "[[${pattern}]]"`);
100
- for (const match of set) {
101
- if (!seen.has(match)) {
102
- seen.add(match);
103
- matches.push(match);
104
- }
105
- }
106
- }
107
- return matches;
108
- };
109
- /* determine the node objects (default: the current object plus
110
- all objects below it) and the edge objects (default: none),
111
- with the edge objects never acting as nodes themselves */
112
- let nodes;
113
- if (diagram.nodes !== undefined)
114
- nodes = resolvePatterns(diagram.nodes, "nodes");
115
- else {
116
- nodes = new Array();
117
- const walk = (o) => {
118
- nodes.push(o);
119
- o.childs.forEach(walk);
120
- };
121
- walk(object);
122
- }
123
- const edgeObjects = diagram.edges !== undefined ? resolvePatterns(diagram.edges, "edges") : [];
124
- const edgeSet = new Set(edgeObjects);
125
- nodes = nodes.filter((node) => !edgeSet.has(node));
126
- const nodeSet = new Set(nodes);
127
- /* derive the edges: from the "[[...]]" references of the node
128
- objects (per the "links" selection) and from the edge objects by
129
- convention (source: parent object, target: first reference in the
130
- 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) */
133
- let edges = new Array();
74
+ /* derive the edges of a diagram: from the "[[...]]" references of the
75
+ node objects (per the "links" selection) and from the edge objects by
76
+ convention (source: parent object, target: first reference in the
77
+ property values, name: object name, arity: "ARITY" property),
78
+ overridable via "edgeSource"/"edgeTarget"/"edgeArity" (a "grid"
79
+ diagram is edge-less by definition, so no edges are derived at all) */
80
+ const deriveEdges = (diagram, type, nodes, nodeSet, edgeObjects, index, anchors, parents, errors) => {
81
+ const edges = new Array();
134
82
  if (type !== "grid") {
135
83
  for (const node of nodes) {
136
84
  const texts = node.properties.map((property) => property.value);
@@ -146,16 +94,25 @@ const deriveDiagram = (object, diagram, index, anchors, parents) => {
146
94
  edges.push({ source: node, target });
147
95
  }
148
96
  }
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));
97
+ /* resolve the node an edge object references through a named
98
+ property, or through its first single-reference property when
99
+ no name is configured (skipping the source property) */
100
+ const edgeNode = (edgeObject, key) => {
101
+ const value = key !== undefined ?
102
+ edgeObject.properties.find((property) => property.key === key)?.value :
103
+ edgeObject.properties.filter((property) => property.key !== diagram.edgeSource)
104
+ .map((property) => property.value).find((v) => referenceOnce.test(v));
155
105
  const reference = value?.match(referenceOnce)?.[1].trim();
156
- const target = reference !== undefined ? resolveUnique(index, reference).target : undefined;
106
+ return reference !== undefined ? resolveUnique(index, reference).target : undefined;
107
+ };
108
+ for (const edgeObject of edgeObjects) {
109
+ const source = diagram.edgeSource !== undefined ?
110
+ edgeNode(edgeObject, diagram.edgeSource) : parents.get(edgeObject);
111
+ const target = edgeNode(edgeObject, diagram.edgeTarget);
157
112
  if (source === undefined) {
158
- errors.push(`diagram edge object "${edgeObject.name}" has no parent object as source`);
113
+ errors.push(diagram.edgeSource !== undefined ?
114
+ `diagram edge object "${edgeObject.name}" carries no resolvable source reference` :
115
+ `diagram edge object "${edgeObject.name}" has no parent object as source`);
159
116
  continue;
160
117
  }
161
118
  if (target === undefined) {
@@ -164,7 +121,7 @@ const deriveDiagram = (object, diagram, index, anchors, parents) => {
164
121
  }
165
122
  if (!nodeSet.has(source) || !nodeSet.has(target))
166
123
  continue;
167
- const arity = edgeObject.properties.find((property) => plainKey(property.key) === (diagram.edgeArity ?? "ARITY"))?.value;
124
+ const arity = edgeObject.properties.find((property) => property.key === (diagram.edgeArity ?? "ARITY"))?.value;
168
125
  edges.push({ source, target, name: plainText(edgeObject.name),
169
126
  arity: arity !== undefined ? plainText(arity) : undefined });
170
127
  }
@@ -182,11 +139,13 @@ const deriveDiagram = (object, diagram, index, anchors, parents) => {
182
139
  errors.push("\"grid\" diagram cannot carry an \"edges\" configuration");
183
140
  if (diagram.hierarchy === true)
184
141
  errors.push("\"grid\" diagram cannot carry a \"hierarchy\" configuration");
142
+ if (diagram.onlyConnected === true)
143
+ errors.push("\"grid\" diagram cannot carry an \"onlyConnected\" configuration");
185
144
  }
186
145
  /* deduplicate the edges (the same reference can occur in
187
146
  multiple texts of the same node object) */
188
147
  const seenEdges = new Set();
189
- edges = edges.filter((edge) => {
148
+ return edges.filter((edge) => {
190
149
  const key = `${anchors.get(edge.source) ?? edge.source.id}` +
191
150
  `\u0000${anchors.get(edge.target) ?? edge.target.id}` +
192
151
  `\u0000${edge.name ?? ""}\u0000${edge.arity ?? ""}`;
@@ -195,15 +154,68 @@ const deriveDiagram = (object, diagram, index, anchors, parents) => {
195
154
  seenEdges.add(key);
196
155
  return true;
197
156
  });
157
+ };
158
+ /* resolve a comma-separated "[[...]]" reference pattern string into
159
+ its (deduplicated, order-preserving) object match set, reporting
160
+ every unresolvable pattern as an error */
161
+ const resolvePatterns = (index, errors, value, field) => {
162
+ const matches = new Array();
163
+ const seen = new Set();
164
+ let found = false;
165
+ for (const m of value.matchAll(referenceRegex)) {
166
+ found = true;
167
+ const pattern = m[1].trim();
168
+ const set = resolveSet(index, pattern);
169
+ if (set.length === 0)
170
+ errors.push(`unresolvable diagram "${field}" pattern "[[${pattern}]]"`);
171
+ for (const match of set) {
172
+ if (!seen.has(match)) {
173
+ seen.add(match);
174
+ matches.push(match);
175
+ }
176
+ }
177
+ }
178
+ if (!found)
179
+ errors.push(`diagram "${field}" carries no "[[...]]" reference pattern`);
180
+ return matches;
181
+ };
182
+ /* derive the Gradia spec of a single object from its "diagram:"
183
+ schema configuration (the returned spec is absent whenever the
184
+ configured diagram situation is invalid) */
185
+ const deriveDiagram = (object, diagram, index, anchors, parents) => {
186
+ const type = diagram.type ?? "graph";
187
+ const errors = new Array();
188
+ /* determine the node objects (default: the current object plus
189
+ all objects below it) and the edge objects (default: none),
190
+ with the edge objects never acting as nodes themselves */
191
+ let nodes;
192
+ if (diagram.nodes !== undefined)
193
+ nodes = resolvePatterns(index, errors, diagram.nodes, "nodes");
194
+ else {
195
+ nodes = new Array();
196
+ const walk = (o) => {
197
+ nodes.push(o);
198
+ o.childs.forEach(walk);
199
+ };
200
+ walk(object);
201
+ }
202
+ const edgeObjects = diagram.edges !== undefined ? resolvePatterns(index, errors, diagram.edges, "edges") : [];
203
+ const edgeSet = new Set(edgeObjects);
204
+ nodes = nodes.filter((node) => !edgeSet.has(node));
205
+ const nodeSet = new Set(nodes);
206
+ /* derive the (deduplicated) edges of the diagram */
207
+ let edges = deriveEdges(diagram, type, nodes, nodeSet, edgeObjects, index, anchors, parents, errors);
198
208
  /* determine the center object of a "hub" diagram: the current
199
209
  object for the default "self" configuration, the uniquely
200
210
  resolved object of an explicit "[[...]]" reference otherwise */
201
211
  let center = object;
212
+ if (type !== "hub" && diagram.center !== undefined)
213
+ errors.push(`"${type}" diagram cannot carry a "center" configuration`);
202
214
  if (type === "hub" && diagram.center !== undefined && diagram.center !== "self") {
203
215
  const reference = diagram.center.match(referenceOnce)?.[1].trim();
204
216
  const resolved = reference !== undefined ? resolveUnique(index, reference) : undefined;
205
217
  if (resolved?.target === undefined) {
206
- errors.push(`${resolved?.ambiguous === true ? "ambiguous" : "unresolvable"}` +
218
+ errors.push((resolved?.ambiguous === true ? "ambiguous" : "unresolvable") +
207
219
  ` diagram "center" reference "${diagram.center}"`);
208
220
  return { errors };
209
221
  }
@@ -242,34 +254,11 @@ const deriveDiagram = (object, diagram, index, anchors, parents) => {
242
254
  if (errors.length > 0)
243
255
  return { errors };
244
256
  /* the "collapse" handling (enabled by default) silently omits a
245
- degenerated diagram, consisting of a single node only, as such a
257
+ degenerate diagram, consisting of a single node only, as such a
246
258
  diagram carries no information beyond the object itself */
247
259
  if (diagram.collapse !== false && nodes.length === 1 && edges.length === 0)
248
260
  return { errors };
249
- return { spec: renderSpec(diagram, type, center, nodes, edges, index, anchors), errors };
250
- };
251
- /* collect the objects whose schema configuration carries a "diagram:"
252
- field (the schema resolution mirrors the semantic validation) */
253
- const collectDiagrams = (specification, config) => {
254
- const diagrams = new Map();
255
- const walk = (object, schema) => {
256
- if (schema.diagram !== undefined)
257
- diagrams.set(object, schema.diagram);
258
- for (const child of object.childs) {
259
- const childSchema = (schema.childs ?? []).find((c) => c.kind === child.kind);
260
- if (childSchema !== undefined)
261
- walk(child, childSchema);
262
- }
263
- };
264
- for (const artifact of specification.artifacts) {
265
- for (const object of artifact.objects) {
266
- const schema = config.find((s) => (s.kind === object.kind && s.id === object.id) || `${s.kind}-${s.id}` === object.id) ??
267
- config.find((s) => (s.name ?? "").toUpperCase() === plainText(object.name).toUpperCase());
268
- if (schema !== undefined)
269
- walk(object, schema);
270
- }
271
- }
272
- return diagrams;
261
+ return { spec: renderSpec(diagram, type, center, nodes, edges, index, anchors), config: diagram.config, errors };
273
262
  };
274
263
  /* derive the Gradia specs of all diagram-configured objects
275
264
  of a specification */
@@ -280,8 +269,9 @@ export const specDiagrams = (specification, config) => {
280
269
  for (const node of index)
281
270
  parents.set(node.object, node.parent?.object);
282
271
  const results = new Map();
283
- for (const [object, diagram] of collectDiagrams(specification, config))
284
- results.set(object, deriveDiagram(object, diagram, index, anchors, parents));
272
+ for (const [object, schema] of collectSchemas(specification, config))
273
+ if (schema.diagram !== undefined)
274
+ results.set(object, deriveDiagram(object, schema.diagram, index, anchors, parents));
285
275
  return results;
286
276
  };
287
277
  /* validate all configured diagrams of a specification, reporting