@rse/specbook 1.2.14 → 1.2.16

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 (45) hide show
  1. package/README.md +98 -11
  2. package/dst/specbook-api.d.ts +22 -21
  3. package/dst/specbook-api.js +80 -27
  4. package/dst/specbook-cli.js +50 -23
  5. package/dst/specbook-cmd-describe.js +8 -4
  6. package/dst/specbook-cmd-export.d.ts +4 -1
  7. package/dst/specbook-cmd-export.js +22 -12
  8. package/dst/specbook-cmd-init.js +2 -8
  9. package/dst/specbook-cmd-lint.d.ts +2 -1
  10. package/dst/specbook-cmd-lint.js +31 -28
  11. package/dst/specbook-config.d.ts +7 -0
  12. package/dst/specbook-config.js +6 -1
  13. package/dst/specbook-coverage.js +3 -4
  14. package/dst/specbook-diagram.d.ts +3 -1
  15. package/dst/specbook-diagram.js +78 -27
  16. package/dst/specbook-export-ast.d.ts +2 -1
  17. package/dst/specbook-export-ast.js +16 -8
  18. package/dst/specbook-export-common.d.ts +7 -0
  19. package/dst/specbook-export-common.js +36 -3
  20. package/dst/specbook-export-html-search.js +27 -24
  21. package/dst/specbook-export-html.css +64 -18
  22. package/dst/specbook-export-html.d.ts +2 -1
  23. package/dst/specbook-export-html.js +519 -277
  24. package/dst/specbook-export-image.d.ts +3 -0
  25. package/dst/specbook-export-image.js +121 -0
  26. package/dst/specbook-export-md.d.ts +8 -2
  27. package/dst/specbook-export-md.js +67 -6
  28. package/dst/specbook-export-pdf.js +7 -4
  29. package/dst/specbook-format.md +35 -8
  30. package/dst/specbook-format.yaml +12 -9
  31. package/dst/specbook-ignore.js +5 -5
  32. package/dst/specbook-link.js +1 -1
  33. package/dst/specbook-mcp.js +51 -34
  34. package/dst/specbook-parse-common.d.ts +4 -1
  35. package/dst/specbook-parse-common.js +15 -3
  36. package/dst/specbook-parse-semantic.d.ts +1 -1
  37. package/dst/specbook-parse-syntax.d.ts +1 -1
  38. package/dst/specbook-parse-syntax.js +28 -8
  39. package/dst/specbook-parse.js +6 -2
  40. package/dst/specbook-project.d.ts +7 -0
  41. package/dst/specbook-project.js +93 -0
  42. package/dst/specbook-theme.d.ts +1 -1
  43. package/dst/specbook-theme.js +9 -8
  44. package/dst/specbook-verbose.js +4 -9
  45. package/package.json +14 -6
@@ -20,8 +20,9 @@ export const parseDescribeFormat = (value) => parseChoice(describeFormats, "form
20
20
  export const parseDescribePart = (value) => parseChoice(describeParts, "part", value);
21
21
  /* the supported compression levels of the emitted YAML schema
22
22
  configuration: verbatim (0), re-emitted with 2-space indentation and
23
- without comments (1), additionally without its "refs" fields (2), and
24
- additionally without its "desc" fields of objects and properties (3) */
23
+ without comments (1), additionally without its "refs", "diagram", and
24
+ "format" fields (2), and additionally without its "desc" fields of
25
+ objects and properties (3) */
25
26
  export const compressLevels = [0, 1, 2, 3];
26
27
  /* parse and validate a compression level specification,
27
28
  where a bare flag selects the plain re-emitting */
@@ -43,7 +44,7 @@ const compressYaml = (yaml, level) => {
43
44
  if (level === 0)
44
45
  return yaml;
45
46
  const doc = parseDocument(yaml);
46
- const drop = ["refs", "desc"].slice(0, level - 1);
47
+ const drop = [["refs", "diagram", "format"], ["desc"]].slice(0, level - 1).flat();
47
48
  doc.commentBefore = null;
48
49
  doc.comment = null;
49
50
  visit(doc, {
@@ -76,9 +77,12 @@ const schemaSection = (config, schema, embed, standard, compress) => {
76
77
  if (standard)
77
78
  yaml = yaml.replace(/^(?:[ \t]*##.*\n)+\s*/, "");
78
79
  yaml = compressYaml(yaml, compress);
80
+ /* the fence has to outgrow every backtick run inside the content,
81
+ so an embedded code block can never break out of it */
82
+ const fence = "`".repeat(Math.max(3, ...Array.from(yaml.matchAll(/^[ \t]*(`{3,})/gm), (match) => match[1].length + 1)));
79
83
  return textframe(`
80
84
  The **SpecBook SCHEMA Model** is the following YAML schema configuration:
81
- `) + "\n```yaml\n" + yaml.replace(/\n*$/, "\n") + "```\n";
85
+ `) + `\n${fence}yaml\n` + yaml.replace(/\n*$/, "\n") + `${fence}\n`;
82
86
  };
83
87
  /* render the reference to the specification Markdown files */
84
88
  const specSection = (basedir) => textframe(`
@@ -1,8 +1,11 @@
1
1
  import type { Spec } from "./specbook-format-spec.js";
2
2
  import type { Schema } from "./specbook-format-schema.js";
3
+ import { parseOmit, omitAspects, type ExportOptions } from "./specbook-export-common.js";
4
+ import { type MarkdownRebase } from "./specbook-export-md.js";
3
5
  import { requireBrowser } from "./specbook-export-pdf.js";
4
6
  import { type Verbose } from "./specbook-verbose.js";
5
7
  export { requireBrowser };
8
+ export { parseOmit, omitAspects, type ExportOptions };
6
9
  export declare const formats: readonly ["json", "json5", "yaml", "toon", "html", "pdf", "md"];
7
10
  export type ExportFormat = typeof formats[number];
8
11
  export declare const parseOutputSpec: (spec: string) => {
@@ -10,4 +13,4 @@ export declare const parseOutputSpec: (spec: string) => {
10
13
  output: string;
11
14
  };
12
15
  export declare const watchSpecification: (run: () => Promise<string[]>, verbose: Verbose) => Promise<void>;
13
- export declare const exportSpecification: (specification: Spec, format: ExportFormat, verbose: Verbose, config?: Schema, realtime?: boolean) => Promise<Buffer>;
16
+ export declare const exportSpecification: (specification: Spec, format: ExportFormat, verbose: Verbose, config?: Schema, options?: ExportOptions, rebase?: MarkdownRebase) => Promise<Buffer>;
@@ -9,7 +9,7 @@ import * as fs from "node:fs";
9
9
  Chokidar under its plain name would raise a peer warning on install */
10
10
  import { watch } from "chokidar5";
11
11
  import { minify } from "@swc/html";
12
- import { documentTitle, documentLogo, documentCharset, documentThemeTone, subsetStylesheet, documentPaperSize, paperStylesheet, charsetCodepoints } from "./specbook-export-common.js";
12
+ import { documentTitle, documentLogo, documentCharset, documentThemeTone, subsetStylesheet, documentPaperSize, paperStylesheet, charsetCodepoints, parseOmit, omitAspects } from "./specbook-export-common.js";
13
13
  import { themeColors, themeStylesheet, themeMapping } from "./specbook-theme.js";
14
14
  import { renderAst } from "./specbook-export-ast.js";
15
15
  import { renderMarkdown } from "./specbook-export-md.js";
@@ -19,6 +19,8 @@ import { literal } from "./specbook-verbose.js";
19
19
  /* re-export the browser pre-check of the PDF renderer, so the API
20
20
  facade reaches the renderers through this command module only */
21
21
  export { requireBrowser };
22
+ /* re-export the omit aspects of the renderers for the very same reason */
23
+ export { parseOmit, omitAspects };
22
24
  /* the supported export formats */
23
25
  export const formats = ["json", "json5", "yaml", "toon", "html", "pdf", "md"];
24
26
  /* the format names and filename extensions mapping onto the export formats */
@@ -111,16 +113,18 @@ export const watchSpecification = async (run, verbose) => {
111
113
  chained catch keeps an unexpected failure from breaking the chain
112
114
  and hence silently ending the observe loop) */
113
115
  let chain = Promise.resolve();
116
+ let pending = 0;
114
117
  let timer;
115
118
  const schedule = () => {
116
119
  if (timer !== undefined)
117
120
  clearTimeout(timer);
118
121
  timer = setTimeout(() => {
119
122
  timer = undefined;
123
+ pending += 1;
120
124
  chain = chain.then(cycle).catch((err) => {
121
125
  verbose("re-export failed: " +
122
126
  (err instanceof Error ? err.message : String(err)), "none");
123
- });
127
+ }).finally(() => { pending -= 1; });
124
128
  }, watchDelay);
125
129
  };
126
130
  watcher.on("all", schedule);
@@ -130,6 +134,9 @@ export const watchSpecification = async (run, verbose) => {
130
134
  regains it on its own and the following changes would be caught
131
135
  by the scan only */
132
136
  setInterval(() => {
137
+ /* a pending or running re-export legitimately lags behind */
138
+ if (timer !== undefined || pending > 0)
139
+ return;
133
140
  for (const [file, time] of snapshot(observed))
134
141
  if (times.get(file) !== time) {
135
142
  verbose(`observing missed the change of "${literal(file)}" ` +
@@ -141,13 +148,14 @@ export const watchSpecification = async (run, verbose) => {
141
148
  }
142
149
  }, scanDelay);
143
150
  };
144
- /* render a specification into the requested format, where "realtime"
145
- injects the client-side script of the live preview into the HTML */
146
- const renderFormat = async (specification, format, verbose, config, realtime = false) => {
151
+ /* render a specification into the requested format, where "options"
152
+ carries the rendering options of the HTML, PDF, and AST formats and
153
+ "rebase" re-bases the image references of the normalized Markdown */
154
+ const renderFormat = async (specification, format, verbose, config, options = {}, rebase) => {
147
155
  if (format === "json" || format === "json5" || format === "yaml" || format === "toon")
148
- return renderAst(specification, format, config);
156
+ return renderAst(specification, format, config, verbose, options.omit);
149
157
  else if (format === "md")
150
- return Buffer.from(renderMarkdown(specification, config), "utf8");
158
+ return Buffer.from(await renderMarkdown(specification, config, rebase, verbose), "utf8");
151
159
  /* the HTML-based formats share the stylesheet, with the embedded
152
160
  fonts subsetted to the CHARSET of the specification (if any) */
153
161
  const charset = documentCharset(specification);
@@ -164,7 +172,7 @@ const renderFormat = async (specification, format, verbose, config, realtime = f
164
172
  const css = themeStylesheet(colors) + await subsetStylesheet(charset) + paperStylesheet(paper);
165
173
  if (format === "html") {
166
174
  /* compress the rendered HTML (whitespace, comments, and inline CSS/JS) */
167
- const html = await renderHtml(specification, config, undefined, css, realtime, verbose);
175
+ const html = await renderHtml(specification, config, undefined, css, options, verbose);
168
176
  const minified = await minify(Buffer.from(html, "utf8"), {
169
177
  collapseWhitespaces: "smart",
170
178
  removeComments: true,
@@ -173,19 +181,21 @@ const renderFormat = async (specification, format, verbose, config, realtime = f
173
181
  });
174
182
  return Buffer.from(minified.code, "utf8");
175
183
  }
176
- else
184
+ else {
177
185
  /* the PDF export (like print in general) always uses the light
178
186
  theme, so its decoration colors are the light mapping, too */
179
- return htmlToPdf((tocPages) => renderHtml(specification, config, tocPages, css, false, verbose), { ...documentTitle(specification), logo: documentLogo(specification) }, htmlOutline(specification, config), titlePageObject(specification) !== undefined, verbose, css, themeMapping(colors, "light"), paper);
187
+ const pass = (tocPages) => renderHtml(specification, config, tocPages, css, { omit: options.omit }, verbose, true);
188
+ return htmlToPdf(pass, { ...documentTitle(specification), logo: documentLogo(specification) }, htmlOutline(specification, config), titlePageObject(specification) !== undefined, verbose, css, themeMapping(colors, "light"), paper);
189
+ }
180
190
  };
181
191
  /* export a specification into the requested format (see "renderFormat"),
182
192
  reporting the duration of the rendering in the verbose output */
183
- export const exportSpecification = async (specification, format, verbose, config, realtime = false) => {
193
+ export const exportSpecification = async (specification, format, verbose, config, options = {}, rebase) => {
184
194
  if (!formats.includes(format))
185
195
  throw new Error(`unknown export format "${format}"`);
186
196
  verbose(`exporting specification as "${literal(format)}"`);
187
197
  const started = performance.now();
188
- const buffer = await renderFormat(specification, format, verbose, config, realtime);
198
+ const buffer = await renderFormat(specification, format, verbose, config, options, rebase);
189
199
  const seconds = ((performance.now() - started) / 1000).toFixed(3);
190
200
  verbose(`exported specification as "${literal(format)}" in ${literal(seconds)}s`);
191
201
  return buffer;
@@ -6,20 +6,14 @@
6
6
  import * as fs from "node:fs";
7
7
  import * as path from "node:path";
8
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
+ import { formatTimestamp } from "./specbook-export-md.js";
16
10
  /* initialize the configured specification artifact files below the
17
11
  base directory with their frontmatter and artifact headings, where
18
12
  all artifacts configured onto the same file reside in it side by
19
13
  side, following each other on level 1 */
20
14
  export const initSpecification = (options) => {
21
15
  fs.mkdirSync(options.basedir, { recursive: true });
22
- const now = timestamp();
16
+ const now = formatTimestamp(new Date());
23
17
  const created = new Array();
24
18
  /* group the configured artifacts by their file, preserving the
25
19
  schema order both of the files and of the artifacts within */
@@ -1,6 +1,6 @@
1
1
  import { type Verbose } from "./specbook-verbose.js";
2
2
  import { type Diagnostic } from "./specbook-diagnostic.js";
3
- import { type Spec } from "./specbook-format-spec.js";
3
+ import { type Spec, type SpecArtifact } from "./specbook-format-spec.js";
4
4
  import { type Schema } from "./specbook-format-schema.js";
5
5
  export interface LintOptions {
6
6
  config: string[];
@@ -13,5 +13,6 @@ export interface LintResult {
13
13
  diagnostics: Diagnostic[];
14
14
  config?: Schema;
15
15
  files: string[];
16
+ origins: Map<SpecArtifact, string>;
16
17
  }
17
18
  export declare const lint: (options: LintOptions) => LintResult;
@@ -23,6 +23,32 @@ const schemaFiles = (config) => {
23
23
  }
24
24
  return files;
25
25
  };
26
+ /* report the reference coverage the "referenced"-flagged object kinds
27
+ receive and the "coverage"-configured objects report as additional
28
+ figures, the unreferenced objects of the latter by name as a lengthy
29
+ detail (the ones of the former are already reported as warnings) */
30
+ const reportCoverage = (specification, config, verbose) => {
31
+ const index = buildLinkIndex(specification);
32
+ const schemas = collectSchemas(specification, config);
33
+ const label = (object) => `${object.kind} "${literal(plainText(object.name))}"`;
34
+ const ratio = (covered, uncovered) => `${literal(covered.length)} of ${literal(covered.length + uncovered.length)} ` +
35
+ `(${literal(coverageRatio(covered.length, covered.length + uncovered.length))}%)`;
36
+ for (const { schema, covered, uncovered } of referencedCoverage(index, schemas)) {
37
+ const chain = chainOf(index, covered[0] ?? uncovered[0]);
38
+ verbose((schema.referenced ?? []).map((entry) => `"${literal(entry)}"`).join(" or ") +
39
+ ` references ${ratio(covered, uncovered)} ${schema.kind} object(s)` +
40
+ (chain.length > 1 ? ` below ${label(chain[0])}` : ""), "detail");
41
+ }
42
+ for (const [object, entries] of specCoverage(index, schemas))
43
+ for (const { pattern, covered, uncovered } of entries) {
44
+ verbose(`${label(object)} references ${ratio(covered, uncovered)} ` +
45
+ `"${literal(pattern)}" object(s)`, "detail");
46
+ if (uncovered.length > 0)
47
+ verbose(`${label(object)} leaves ${literal(uncovered.length)} ` +
48
+ `"${literal(pattern)}" object(s) unreferenced: ` +
49
+ uncovered.map(label).join(", "), "trace");
50
+ }
51
+ };
26
52
  /* lint the specification Markdown files below the base directory
27
53
  against the configuration */
28
54
  export const lint = (options) => {
@@ -87,33 +113,9 @@ export const lint = (options) => {
87
113
  const stats = specStatistics(result.specification);
88
114
  options.verbose(`parsed specification defining ${literal(stats.objects)} object(s) ` +
89
115
  `and ${literal(stats.links)} link relationship(s)`);
90
- /* report the reference coverage the "referenced"-flagged object
91
- kinds receive and the "coverage"-configured objects report as
92
- additional figures, the unreferenced objects of the latter by
93
- name as a lengthy detail (the ones of the former are already
94
- reported as warnings) */
95
- if (config !== undefined && result.specification.artifacts.length > 0) {
96
- const index = buildLinkIndex(result.specification);
97
- const schemas = collectSchemas(result.specification, config);
98
- const label = (object) => `${object.kind} "${literal(plainText(object.name))}"`;
99
- const ratio = (covered, uncovered) => `${literal(covered.length)} of ${literal(covered.length + uncovered.length)} ` +
100
- `(${literal(coverageRatio(covered.length, covered.length + uncovered.length))}%)`;
101
- for (const { schema, covered, uncovered } of referencedCoverage(index, schemas)) {
102
- const chain = chainOf(index, covered[0] ?? uncovered[0]);
103
- options.verbose((schema.referenced ?? []).map((entry) => `"${literal(entry)}"`).join(" or ") +
104
- ` references ${ratio(covered, uncovered)} ${schema.kind} object(s)` +
105
- (chain.length > 1 ? ` below ${label(chain[0])}` : ""), "detail");
106
- }
107
- for (const [object, entries] of specCoverage(index, schemas))
108
- for (const { pattern, covered, uncovered } of entries) {
109
- options.verbose(`${label(object)} references ${ratio(covered, uncovered)} ` +
110
- `"${literal(pattern)}" object(s)`, "detail");
111
- if (uncovered.length > 0)
112
- options.verbose(`${label(object)} leaves ${literal(uncovered.length)} ` +
113
- `"${literal(pattern)}" object(s) unreferenced: ` +
114
- uncovered.map(label).join(", "), "trace");
115
- }
116
- }
116
+ /* report the reference coverage as additional figures */
117
+ if (config !== undefined && result.specification.artifacts.length > 0)
118
+ reportCoverage(result.specification, config, options.verbose);
117
119
  /* report the non-optional artifacts absent from the specification,
118
120
  against their loaded artifact file (an absent or unreadable file
119
121
  is already reported above) or else against the configuration */
@@ -135,5 +137,6 @@ export const lint = (options) => {
135
137
  message: `missing artifact "${schema.kind}: ${schema.name ?? ""}${paren}"` });
136
138
  }
137
139
  }
138
- return { specification: result.specification, diagnostics, config, files: watched };
140
+ return { specification: result.specification, diagnostics, config, files: watched,
141
+ origins: result.origins };
139
142
  };
@@ -1,5 +1,11 @@
1
+ import { LineCounter, type Document } from "yaml";
1
2
  import { Schema } from "./specbook-format-schema.js";
2
3
  import { type Diagnostic } from "./specbook-diagnostic.js";
4
+ type YamlPath = (string | number)[];
5
+ export declare const lineColOfPath: (doc: Document, lines: LineCounter, path: YamlPath) => {
6
+ line: number;
7
+ column: number;
8
+ };
3
9
  export declare const configStatistics: (config: Schema) => {
4
10
  objects: number;
5
11
  links: number;
@@ -8,3 +14,4 @@ export declare const loadConfig: (files: string[]) => {
8
14
  config?: Schema;
9
15
  diagnostics: Diagnostic[];
10
16
  };
17
+ export {};
@@ -11,7 +11,7 @@ import { Schema } from "./specbook-format-schema.js";
11
11
  import { compileValueExpr, anchored, admitsReferences } from "./specbook-parse-value.js";
12
12
  /* determine line/column of a YAML document path via the node ranges
13
13
  of the parsed document, falling back to the closest ancestor node */
14
- const lineColOfPath = (doc, lines, path) => {
14
+ export const lineColOfPath = (doc, lines, path) => {
15
15
  for (let i = path.length; i >= 0; i--) {
16
16
  const node = doc.getIn(path.slice(0, i), true);
17
17
  const start = isNode(node) ? node.range?.[0] : undefined;
@@ -214,6 +214,11 @@ const checkConstraints = (config, posOfPath) => {
214
214
  seen.add(ident);
215
215
  if (depth > 1 && object.file !== undefined)
216
216
  diagnose([...at, "file"], `"file" field is only allowed on the first (artifact) level (found on level ${depth})`);
217
+ /* an artifact file is placed below the base directory, so
218
+ it must neither be absolute nor escape that directory */
219
+ if (object.file !== undefined
220
+ && ((/^[/\\]/).test(object.file) || (/(?:^|[/\\])\.\.(?:[/\\]|$)/).test(object.file)))
221
+ diagnose([...at, "file"], `"file" field "${object.file}" is not a relative path below the base directory`);
217
222
  checkProperties(object.props ?? [], at, diagnose);
218
223
  checkAutomaton(object, at, diagnose);
219
224
  for (const field of ["referenced", "coverage"])
@@ -39,15 +39,14 @@ const isCovered = (index, referrers, sources, object, lifting) => {
39
39
  };
40
40
  /* the objects matching the reference patterns of a schema field */
41
41
  const matchingObjects = (index, patterns) => {
42
- const objects = new Array();
42
+ const objects = new Set();
43
43
  for (const pattern of patterns) {
44
44
  const expr = compileValueExpr(pattern);
45
45
  if (expr.kind === "reference")
46
46
  for (const object of resolveSet(index, expr.pattern))
47
- if (!objects.includes(object))
48
- objects.push(object);
47
+ objects.add(object);
49
48
  }
50
- return objects;
49
+ return Array.from(objects);
51
50
  };
52
51
  /* determine the coverage the objects of every "referenced"-flagged
53
52
  kind receive, one entry per flagged schema node (in the order of
@@ -1,6 +1,7 @@
1
1
  import type { Spec, SpecObject } from "./specbook-format-spec.js";
2
2
  import type { Schema, SchemaDiagram } from "./specbook-format-schema.js";
3
3
  import type { ParseContext } from "./specbook-parse-common.js";
4
+ import type { OmitAspect } from "./specbook-export-common.js";
4
5
  import { type Verbose } from "./specbook-verbose.js";
5
6
  export interface DiagramError {
6
7
  reason: string;
@@ -13,7 +14,8 @@ export interface DiagramResult {
13
14
  errors: DiagramError[];
14
15
  }
15
16
  export declare const specDiagrams: (specification: Spec, config: Schema) => Map<SpecObject, DiagramResult>;
16
- export declare const renderDiagrams: (specification: Spec, config: Schema, verbose?: Verbose) => Promise<Map<SpecObject, DiagramResult & {
17
+ export declare const omittedDiagrams: (specification: Spec, config: Schema, omit: Set<OmitAspect>) => Set<SpecObject>;
18
+ export declare const renderDiagrams: (specification: Spec, config: Schema, verbose?: Verbose, omit?: Set<"diagram:graph" | "diagram:hub" | "diagram:grid" | "diagram:1" | "diagram:2" | "diagram:3" | "text:long">) => Promise<Map<SpecObject, DiagramResult & {
17
19
  svg: string;
18
20
  }>>;
19
21
  export declare const validateDiagrams: (ctx: ParseContext, specification: Spec, config: Schema) => void;
@@ -10,10 +10,43 @@ import { literal } from "./specbook-verbose.js";
10
10
  /* the single Wiki-style reference match (the non-global sibling of
11
11
  the imported, global "referenceRegex") */
12
12
  const referenceOnce = new RegExp(referenceRegex.source);
13
+ /* the Gradia rendering options preset for every diagram, which the
14
+ "config" of a diagram configuration overrides: inside a document
15
+ the diagrams are rendered compactly, as their defaults are
16
+ dimensioned for a stand-alone canvas -- no canvas margin beyond the
17
+ box strokes (the document spaces the diagram), the node boxes at
18
+ their content height, tighter gaps between the stacked nodes and
19
+ the tiles, a "hub" column of more than six nodes wrapped into
20
+ two staggered sub-columns, and four tiles per "grid" row, as the
21
+ square derivation of the column count otherwise leaves a third of
22
+ the document width unused at the reduced coordinate scale. The node
23
+ types and the edge labels are raised by about 20% over their Gradia
24
+ defaults, as that very coordinate scale would otherwise shrink them
25
+ below a comfortably readable size (the "--gradia-size-font-*" custom
26
+ properties of the stylesheet mirror them for a display-time tweak).
27
+ The parallel edges are spread further apart for the same reason, and
28
+ the "graph" channels are practically uncapped, as a capped channel
29
+ squeezes its many edges into an unreadable bundle */
30
+ const diagramPresets = {
31
+ "size-canvas-margin": 8,
32
+ "size-node-height-scale": 2,
33
+ "size-font-type": 19,
34
+ "size-font-edge": 19,
35
+ "size-font-arity": 19,
36
+ "size-edge-track-gap": 20,
37
+ "graph-channel-width-max": 1000,
38
+ "hub-node-gap": 10,
39
+ "hub-node-count-max": 6,
40
+ "grid-gap-horizontal": 24,
41
+ "grid-gap-vertical": 12,
42
+ "grid-columns-min": 4
43
+ };
44
+ /* render a text as a double-quoted Gradia string, with the
45
+ backslashes and the double quotes escaped */
46
+ const quoted = (text) => `"${text.replace(/\\/g, "\\\\").replace(/"/g, "\\\"")}"`;
13
47
  /* render a text as a Gradia atom: a bareword where possible
14
48
  (no whitespace, no special characters, no "--"), a quoted
15
49
  string otherwise */
16
- const quoted = (text) => `"${text.replace(/\\/g, "\\\\").replace(/"/g, "\\\"")}"`;
17
50
  const atom = (text) => {
18
51
  const plain = text.replace(/\s+/g, " ").trim();
19
52
  return /^[^\s[\]():,">#]+$/.test(plain) && !plain.includes("--") ? plain : quoted(plain);
@@ -44,7 +77,7 @@ const propValue = (parenProps, object, key) => propsOf(parenProps, object).find(
44
77
  derived edge */
45
78
  const renderSpec = (diagram, type, center, centerUrl, nodes, edges, nesting, index, anchors, positions, parenProps) => {
46
79
  const lines = [`#type ${type}`];
47
- const config = diagram.config ?? {};
80
+ const config = { ...diagramPresets, ...diagram.config };
48
81
  for (const [key, value] of Object.entries(config))
49
82
  if (value !== undefined)
50
83
  lines.push(`#config ${key} ${configValue(value)}`);
@@ -221,16 +254,10 @@ const deriveEdges = (diagram, type, nodes, nodeSet, edgeObjects, index, anchors,
221
254
  }
222
255
  }
223
256
  else {
224
- if (diagram.edges !== undefined)
225
- errors.push({ reason: "\"grid\" diagram cannot carry an \"edges\" configuration" });
226
- if (diagram.hierarchy === true)
227
- errors.push({ reason: "\"grid\" diagram cannot carry a \"hierarchy\" configuration" });
228
- if (diagram.deep === true)
229
- errors.push({ reason: "\"grid\" diagram cannot carry a \"deep\" configuration" });
230
- if (diagram.labeled === true)
231
- errors.push({ reason: "\"grid\" diagram cannot carry a \"labeled\" configuration" });
232
- if (diagram.onlyConnected === true)
233
- errors.push({ reason: "\"grid\" diagram cannot carry an \"onlyConnected\" configuration" });
257
+ for (const field of ["edges", "hierarchy", "deep", "labeled", "onlyConnected"])
258
+ if (diagram[field] !== undefined && diagram[field] !== false)
259
+ errors.push({ reason: "\"grid\" diagram cannot carry " +
260
+ `${(/^[aeiou]/).test(field) ? "an" : "a"} "${field}" configuration` });
234
261
  }
235
262
  return edges;
236
263
  };
@@ -457,8 +484,8 @@ const liftEdges = (edges, parentOf, mode, anchors) => {
457
484
  const source = mode === "both" ? lift(edge.source, edge.target) : edge.source;
458
485
  if (source === target)
459
486
  continue;
460
- const key = `${anchors.get(source) ?? source.id}${anchors.get(target) ?? target.id}` +
461
- `${edge.name ?? ""}`;
487
+ const key = `${anchors.get(source) ?? source.id}\u0000${anchors.get(target) ?? target.id}` +
488
+ `\u0000${edge.name ?? ""}`;
462
489
  const known = merged.get(key);
463
490
  if (known === undefined)
464
491
  merged.set(key, { source, target, name: edge.name, arity: edge.arity });
@@ -518,18 +545,17 @@ const deriveDiagram = (object, diagram, index, anchors, positions, parents, pare
518
545
  edges = dedupEdges(edges, anchors);
519
546
  /* a "hub" diagram is the hub-projection onto its center object:
520
547
  only the edges incident to the center and only the nodes
521
- connected through them remain, with self-loops dropped, as
522
- Gradia requires exactly this constrained topology (and the
523
- occupied columns are the center plus the input column, when
524
- an edge targets the center, plus the output column, when an
525
- edge originates from the center) */
548
+ connected through them remain, as Gradia requires exactly this
549
+ constrained topology (and the occupied columns are the center
550
+ plus the input column, when an edge of another node targets the
551
+ center, plus the output column, when an edge originates from the
552
+ center, where Gradia places the target of its self-loop, too) */
526
553
  let columns;
527
554
  if (type === "hub") {
528
555
  if (!nodeSet.has(center))
529
556
  errors.push({ reason: `"hub" diagram center "${center.name}" is not part of the node set` });
530
557
  else {
531
- edges = edges.filter((edge) => (edge.source === center || edge.target === center)
532
- && edge.source !== edge.target);
558
+ edges = edges.filter((edge) => edge.source === center || edge.target === center);
533
559
  const connected = new Set([center]);
534
560
  for (const edge of edges) {
535
561
  connected.add(edge.source);
@@ -537,7 +563,7 @@ const deriveDiagram = (object, diagram, index, anchors, positions, parents, pare
537
563
  }
538
564
  nodes = nodes.filter((node) => connected.has(node));
539
565
  columns = 1 +
540
- (edges.some((edge) => edge.target === center) ? 1 : 0) +
566
+ (edges.some((edge) => edge.target === center && edge.source !== center) ? 1 : 0) +
541
567
  (edges.some((edge) => edge.source === center) ? 1 : 0);
542
568
  }
543
569
  }
@@ -578,7 +604,7 @@ const deriveDiagram = (object, diagram, index, anchors, positions, parents, pare
578
604
  if (nodes.length === 0
579
605
  || (diagram.collapse !== false && nodes.length === 1 && edges.length === 0))
580
606
  return { errors };
581
- return { spec: renderSpec(diagram, type, center, centerUrl, nodes, edges, nesting, index, anchors, positions, parenProps), config: diagram.config, columns, errors };
607
+ return { spec: renderSpec(diagram, type, center, centerUrl, nodes, edges, nesting, index, anchors, positions, parenProps), config: { ...diagramPresets, ...diagram.config }, columns, errors };
582
608
  };
583
609
  /* the memoized diagram derivations, keyed by specification, as the
584
610
  lint validation and every renderer of an export derive the very
@@ -610,6 +636,27 @@ export const specDiagrams = (specification, config) => {
610
636
  derivations.set(specification, { config, results });
611
637
  return results;
612
638
  };
639
+ /* determine the diagram-configured objects whose diagrams the given
640
+ aspects omit: the ones of a diagram type and the ones from an object
641
+ tree nesting level on (the top-level objects being level 1) */
642
+ export const omittedDiagrams = (specification, config, omit) => {
643
+ const omitted = new Set();
644
+ if (omit.size === 0)
645
+ return omitted;
646
+ const schemas = collectSchemas(specification, config);
647
+ for (const node of buildLinkIndex(specification)) {
648
+ const diagram = schemas.get(node.object)?.diagram;
649
+ if (diagram === undefined)
650
+ continue;
651
+ let level = 1;
652
+ for (let parent = node.parent; parent !== undefined; parent = parent.parent)
653
+ level++;
654
+ if (omit.has(`diagram:${diagram.type ?? "graph"}`)
655
+ || [1, 2, 3].some((n) => n <= level && omit.has(`diagram:${n}`)))
656
+ omitted.add(node.object);
657
+ }
658
+ return omitted;
659
+ };
613
660
  /* the in-memory cache of the rendered diagram SVGs, keyed by Gradia
614
661
  spec text plus rendering options, as a long-running process (the
615
662
  live preview, the export watch, the MCP service) and the multiple
@@ -622,13 +669,14 @@ let svgCache = new Map();
622
669
  detectable invalid situations are already reported as lint
623
670
  diagnostics), served from the cache where possible, which is
624
671
  afterwards swept to the diagrams of this very rendering, so it never
625
- grows beyond a single document */
626
- export const renderDiagrams = async (specification, config, verbose) => {
672
+ grows beyond a single document; the omitted diagrams are skipped */
673
+ export const renderDiagrams = async (specification, config, verbose, omit = new Set()) => {
627
674
  const cache = new Map();
628
675
  const rendered = new Map();
676
+ const omitted = omittedDiagrams(specification, config, omit);
629
677
  let cached = 0;
630
678
  for (const [object, result] of specDiagrams(specification, config)) {
631
- if (result.spec === undefined)
679
+ if (result.spec === undefined || omitted.has(object))
632
680
  continue;
633
681
  const key = result.spec + JSON.stringify(result.config ?? {});
634
682
  let svg = svgCache.get(key) ?? cache.get(key);
@@ -648,7 +696,10 @@ export const renderDiagrams = async (specification, config, verbose) => {
648
696
  rendered.set(object, { ...result, svg });
649
697
  }
650
698
  svgCache = cache;
651
- verbose?.(`rendering ${literal(rendered.size)} diagram(s) (${literal(cached)} cached)`);
699
+ if (cached > 0 && cached === rendered.size)
700
+ verbose?.(`reusing ${literal(cached)} cached diagram(s)`);
701
+ else
702
+ verbose?.(`rendering ${literal(rendered.size)} diagram(s) (${literal(cached)} cached)`);
652
703
  return rendered;
653
704
  };
654
705
  /* validate all configured diagrams of a specification, reporting
@@ -1,4 +1,5 @@
1
1
  import type { Spec } from "./specbook-format-spec.js";
2
2
  import type { Schema } from "./specbook-format-schema.js";
3
+ import type { Verbose } from "./specbook-verbose.js";
3
4
  export type AstFormat = "json" | "json5" | "yaml" | "toon";
4
- export declare const renderAst: (specification: Spec, format: AstFormat, config?: Schema) => Buffer;
5
+ export declare const renderAst: (specification: Spec, format: AstFormat, config?: Schema, verbose?: Verbose, omit?: Set<"diagram:graph" | "diagram:hub" | "diagram:grid" | "diagram:1" | "diagram:2" | "diagram:3" | "text:long">) => Promise<Buffer>;
@@ -6,29 +6,37 @@
6
6
  import JSON5 from "json5";
7
7
  import { stringify as stringifyYaml } from "yaml";
8
8
  import { encode as encodeToon } from "@toon-format/toon";
9
- import { specDiagrams } from "./specbook-diagram.js";
9
+ import { specDiagrams, omittedDiagrams } from "./specbook-diagram.js";
10
10
  import { specCoverage } from "./specbook-coverage.js";
11
11
  import { buildLinkIndex } from "./specbook-link.js";
12
12
  import { collectSchemas } from "./specbook-parse-semantic.js";
13
13
  import { isTitleObject } from "./specbook-export-common.js";
14
- /* render the specification AST into a serialization format */
15
- export const renderAst = (specification, format, config) => {
16
- /* reduce the specification to plain JSON values (ISO date strings) */
17
- const plain = JSON.parse(JSON.stringify(specification));
14
+ import { optimizeImages } from "./specbook-export-image.js";
15
+ /* render the specification AST into a serialization format,
16
+ where "omit" names the aspects whose diagrams are left out */
17
+ export const renderAst = async (specification, format, config, verbose, omit = new Set()) => {
18
+ /* reduce the specification to plain JSON values (ISO date strings),
19
+ with the embedded images (by far the bulk of the export)
20
+ optimized like the ones of the HTML export */
21
+ const optimized = await optimizeImages(specification, false, verbose);
22
+ const plain = JSON.parse(JSON.stringify(specification, (key, value) => key !== "embedding" ? value :
23
+ value.map((content) => optimized.get(content) ?? content)));
18
24
  /* attach the Gradia specs of the diagram-configured objects as
19
25
  "diagram" fields onto the corresponding plain object nodes
20
26
  (an invalid diagram situation omits the diagram, as it is
21
- already reported as a lint diagnostic, and the diagram of
22
- the title object is reserved for the HTML/PDF export), and
27
+ already reported as a lint diagnostic, the diagram of the
28
+ title object is reserved for the HTML/PDF export, and the
29
+ diagrams of the omitted aspects are left out), and
23
30
  the reference coverage of the coverage-configured objects as
24
31
  "coverage" fields (the counts only, as the export is intended
25
32
  for machine consumption) */
26
33
  if (config !== undefined) {
27
34
  const diagrams = specDiagrams(specification, config);
35
+ const omitted = omittedDiagrams(specification, config, omit);
28
36
  const coverages = specCoverage(buildLinkIndex(specification), collectSchemas(specification, config));
29
37
  const walk = (object, node) => {
30
38
  const result = diagrams.get(object);
31
- if (result?.spec !== undefined && !isTitleObject(object))
39
+ if (result?.spec !== undefined && !isTitleObject(object) && !omitted.has(object))
32
40
  node.diagram = result.spec;
33
41
  const coverage = coverages.get(object);
34
42
  if (coverage !== undefined)
@@ -4,6 +4,13 @@ export declare const escapeHtml: (text: string) => string;
4
4
  export declare const stylesheet: () => string;
5
5
  export declare const searchScript: () => string;
6
6
  export declare const fallbackLogo: (theme: (typeof embeddingThemes)[number]) => string;
7
+ export declare const omitAspects: readonly ["diagram:graph", "diagram:hub", "diagram:grid", "diagram:1", "diagram:2", "diagram:3", "text:long"];
8
+ export type OmitAspect = typeof omitAspects[number];
9
+ export declare const parseOmit: (omit?: string[]) => Set<OmitAspect>;
10
+ export interface ExportOptions {
11
+ realtime?: boolean;
12
+ omit?: Set<OmitAspect>;
13
+ }
7
14
  export declare const isTitleObject: (object: SpecObject) => boolean;
8
15
  export declare const titleObject: (specification: Spec) => SpecObject | undefined;
9
16
  export declare const documentLang: (specification: Spec) => string | undefined;