@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.
- package/README.md +98 -11
- package/dst/specbook-api.d.ts +22 -21
- package/dst/specbook-api.js +80 -27
- package/dst/specbook-cli.js +50 -23
- package/dst/specbook-cmd-describe.js +8 -4
- package/dst/specbook-cmd-export.d.ts +4 -1
- package/dst/specbook-cmd-export.js +22 -12
- package/dst/specbook-cmd-init.js +2 -8
- package/dst/specbook-cmd-lint.d.ts +2 -1
- package/dst/specbook-cmd-lint.js +31 -28
- package/dst/specbook-config.d.ts +7 -0
- package/dst/specbook-config.js +6 -1
- package/dst/specbook-coverage.js +3 -4
- package/dst/specbook-diagram.d.ts +3 -1
- package/dst/specbook-diagram.js +78 -27
- package/dst/specbook-export-ast.d.ts +2 -1
- package/dst/specbook-export-ast.js +16 -8
- package/dst/specbook-export-common.d.ts +7 -0
- package/dst/specbook-export-common.js +36 -3
- package/dst/specbook-export-html-search.js +27 -24
- package/dst/specbook-export-html.css +64 -18
- package/dst/specbook-export-html.d.ts +2 -1
- package/dst/specbook-export-html.js +519 -277
- package/dst/specbook-export-image.d.ts +3 -0
- package/dst/specbook-export-image.js +121 -0
- package/dst/specbook-export-md.d.ts +8 -2
- package/dst/specbook-export-md.js +67 -6
- package/dst/specbook-export-pdf.js +7 -4
- package/dst/specbook-format.md +35 -8
- package/dst/specbook-format.yaml +12 -9
- package/dst/specbook-ignore.js +5 -5
- package/dst/specbook-link.js +1 -1
- package/dst/specbook-mcp.js +51 -34
- package/dst/specbook-parse-common.d.ts +4 -1
- package/dst/specbook-parse-common.js +15 -3
- package/dst/specbook-parse-semantic.d.ts +1 -1
- package/dst/specbook-parse-syntax.d.ts +1 -1
- package/dst/specbook-parse-syntax.js +28 -8
- package/dst/specbook-parse.js +6 -2
- package/dst/specbook-project.d.ts +7 -0
- package/dst/specbook-project.js +93 -0
- package/dst/specbook-theme.d.ts +1 -1
- package/dst/specbook-theme.js +9 -8
- package/dst/specbook-verbose.js +4 -9
- 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"
|
|
24
|
-
additionally without its "desc" fields of
|
|
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
|
-
`) +
|
|
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,
|
|
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 "
|
|
145
|
-
|
|
146
|
-
|
|
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,
|
|
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
|
-
|
|
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,
|
|
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,
|
|
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;
|
package/dst/specbook-cmd-init.js
CHANGED
|
@@ -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
|
-
|
|
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 =
|
|
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;
|
package/dst/specbook-cmd-lint.js
CHANGED
|
@@ -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
|
|
91
|
-
|
|
92
|
-
|
|
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
|
};
|
package/dst/specbook-config.d.ts
CHANGED
|
@@ -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 {};
|
package/dst/specbook-config.js
CHANGED
|
@@ -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"])
|
package/dst/specbook-coverage.js
CHANGED
|
@@ -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
|
|
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
|
-
|
|
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
|
|
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;
|
package/dst/specbook-diagram.js
CHANGED
|
@@ -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
|
-
|
|
225
|
-
|
|
226
|
-
|
|
227
|
-
|
|
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}
|
|
461
|
-
|
|
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,
|
|
522
|
-
|
|
523
|
-
|
|
524
|
-
|
|
525
|
-
|
|
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) =>
|
|
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
|
-
|
|
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
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
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,
|
|
22
|
-
|
|
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;
|