@rse/specbook 1.2.15 → 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.css +64 -18
- package/dst/specbook-export-html.d.ts +2 -1
- package/dst/specbook-export-html.js +517 -275
- 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
|
@@ -0,0 +1,121 @@
|
|
|
1
|
+
/*
|
|
2
|
+
** Specification Book (SpecBook)
|
|
3
|
+
** Copyright (c) 2026 Dr. Ralf S. Engelschall <rse@engelschall.com>
|
|
4
|
+
** Licensed under Apache 2.0 <https://spdx.org/licenses/Apache-2.0>
|
|
5
|
+
*/
|
|
6
|
+
import { literal } from "./specbook-verbose.js";
|
|
7
|
+
/* the pixel width the raster images are capped to: the 60rem content
|
|
8
|
+
width of the document (at 16px per rem) at a 2x device pixel ratio */
|
|
9
|
+
const maxWidth = 60 * 16 * 2;
|
|
10
|
+
/* the lossy encoding quality of the re-encoded raster images */
|
|
11
|
+
const quality = 85;
|
|
12
|
+
/* the in-memory caches of the optimized images (one per target medium,
|
|
13
|
+
as the two optimize a PNG differently), keyed by the embedded image
|
|
14
|
+
content and swept to the images of the latest optimization for the
|
|
15
|
+
medium, so they serve the repeated renderings of a process (watch,
|
|
16
|
+
preview, MCP, and the passes and formats of a single export) without
|
|
17
|
+
growing over a long-running one */
|
|
18
|
+
const imageCaches = {
|
|
19
|
+
screen: new Map(),
|
|
20
|
+
print: new Map()
|
|
21
|
+
};
|
|
22
|
+
/* optimize a single SVG image with SVGO (the identifiers and classes
|
|
23
|
+
are free to be minified, as the image is rendered in isolation
|
|
24
|
+
through an <img> element), after stripping the "content" attribute
|
|
25
|
+
draw.io leaves on the root element: it carries the entire
|
|
26
|
+
entity-escaped diagram source, which is needless for the rendering
|
|
27
|
+
and exceeds the entity limit of the SVGO parser (an SVG which SVGO
|
|
28
|
+
still rejects stays just stripped) */
|
|
29
|
+
const optimizeSvg = async (content) => {
|
|
30
|
+
const stripped = content.replace(/(<svg\b[^>]*?)\s+content="[^"]*"/, "$1");
|
|
31
|
+
try {
|
|
32
|
+
const { optimize } = await import("svgo");
|
|
33
|
+
return optimize(stripped, { multipass: true }).data;
|
|
34
|
+
}
|
|
35
|
+
catch {
|
|
36
|
+
return stripped;
|
|
37
|
+
}
|
|
38
|
+
};
|
|
39
|
+
/* optimize a single raster image given as a base64 data: URL with
|
|
40
|
+
Sharp: an image wider than the cap is downscaled and a JPEG stays a
|
|
41
|
+
JPEG, while a PNG is converted to WebP for the screen and to JPEG
|
|
42
|
+
for print, as Chromium passes just this format through into the PDF
|
|
43
|
+
unchanged and embeds every other one losslessly (as JPEG knows no
|
|
44
|
+
transparency, the image is flattened onto the white of the paper); a
|
|
45
|
+
WebP is treated like a PNG, but for the screen one within the cap
|
|
46
|
+
stays untouched, as re-encoding it again would just degrade it */
|
|
47
|
+
const optimizeRaster = async (content, print) => {
|
|
48
|
+
const m = content.match(/^data:(image\/(?:png|jpeg|webp));base64,(.*)$/s);
|
|
49
|
+
if (m === null)
|
|
50
|
+
return content;
|
|
51
|
+
const { default: sharp } = await import("sharp");
|
|
52
|
+
const source = sharp(Buffer.from(m[2], "base64"));
|
|
53
|
+
if (m[1] === "image/webp" && !print && ((await source.metadata()).width ?? 0) <= maxWidth)
|
|
54
|
+
return content;
|
|
55
|
+
const image = source
|
|
56
|
+
.rotate()
|
|
57
|
+
.resize({ width: maxWidth, withoutEnlargement: true });
|
|
58
|
+
const webp = m[1] !== "image/jpeg" && !print;
|
|
59
|
+
const data = webp ?
|
|
60
|
+
await image.webp({ quality }).toBuffer() :
|
|
61
|
+
await image.flatten({ background: "#ffffff" }).jpeg({ quality, mozjpeg: true }).toBuffer();
|
|
62
|
+
const type = webp ? "image/webp" : "image/jpeg";
|
|
63
|
+
return `data:${type};base64,${data.toString("base64")}`;
|
|
64
|
+
};
|
|
65
|
+
/* collect the embedded image contents of an object and its descendants
|
|
66
|
+
(the empty entries of the unreadable files left out) */
|
|
67
|
+
const collect = (object, contents) => {
|
|
68
|
+
for (const content of object.description?.embedding ?? [])
|
|
69
|
+
if (content !== "")
|
|
70
|
+
contents.add(content);
|
|
71
|
+
for (const property of object.properties)
|
|
72
|
+
for (const content of property.embedding ?? [])
|
|
73
|
+
if (content !== "")
|
|
74
|
+
contents.add(content);
|
|
75
|
+
for (const child of object.children)
|
|
76
|
+
collect(child, contents);
|
|
77
|
+
};
|
|
78
|
+
/* optimize the embedded images of a specification for the HTML export
|
|
79
|
+
(the screen) or the PDF export (print), yielding the map from the
|
|
80
|
+
embedded image contents onto their optimized ones, served from the
|
|
81
|
+
cache where possible: an optimization which fails (an image the
|
|
82
|
+
libraries cannot process, or a platform Sharp provides no binary
|
|
83
|
+
for) or which does not shrink the image keeps the original, so the
|
|
84
|
+
export never depends on it */
|
|
85
|
+
export const optimizeImages = async (specification, print, verbose) => {
|
|
86
|
+
const medium = print ? "print" : "screen";
|
|
87
|
+
const contents = new Set();
|
|
88
|
+
for (const artifact of specification.artifacts)
|
|
89
|
+
for (const object of artifact.objects)
|
|
90
|
+
collect(object, contents);
|
|
91
|
+
const cache = new Map();
|
|
92
|
+
let cached = 0;
|
|
93
|
+
let before = 0;
|
|
94
|
+
let after = 0;
|
|
95
|
+
for (const content of contents) {
|
|
96
|
+
let optimized = imageCaches[medium].get(content);
|
|
97
|
+
if (optimized !== undefined)
|
|
98
|
+
cached++;
|
|
99
|
+
else {
|
|
100
|
+
try {
|
|
101
|
+
optimized = content.startsWith("data:") ?
|
|
102
|
+
await optimizeRaster(content, print) : await optimizeSvg(content);
|
|
103
|
+
}
|
|
104
|
+
catch {
|
|
105
|
+
optimized = content;
|
|
106
|
+
}
|
|
107
|
+
if (optimized.length >= content.length)
|
|
108
|
+
optimized = content;
|
|
109
|
+
}
|
|
110
|
+
cache.set(content, optimized);
|
|
111
|
+
before += content.length;
|
|
112
|
+
after += optimized.length;
|
|
113
|
+
}
|
|
114
|
+
imageCaches[medium] = cache;
|
|
115
|
+
if (contents.size > 0)
|
|
116
|
+
verbose?.((cached === contents.size ?
|
|
117
|
+
`reusing ${literal(cached)} cached image(s) for ${medium}: ` :
|
|
118
|
+
`optimizing ${literal(contents.size)} image(s) for ${medium} (${literal(cached)} cached): `) +
|
|
119
|
+
`${literal(Math.round(before / 1024))} KB -> ${literal(Math.round(after / 1024))} KB`);
|
|
120
|
+
return cache;
|
|
121
|
+
};
|
|
@@ -1,3 +1,9 @@
|
|
|
1
|
-
import type { Spec } from "./specbook-format-spec.js";
|
|
1
|
+
import type { Spec, SpecArtifact } from "./specbook-format-spec.js";
|
|
2
2
|
import type { Schema } from "./specbook-format-schema.js";
|
|
3
|
-
|
|
3
|
+
import type { Verbose } from "./specbook-verbose.js";
|
|
4
|
+
export declare const formatTimestamp: (date: Date) => string;
|
|
5
|
+
export interface MarkdownRebase {
|
|
6
|
+
origins: Map<SpecArtifact, string>;
|
|
7
|
+
dir: string;
|
|
8
|
+
}
|
|
9
|
+
export declare const renderMarkdown: (specification: Spec, config?: Schema, rebase?: MarkdownRebase, verbose?: Verbose) => Promise<string>;
|
|
@@ -3,11 +3,13 @@
|
|
|
3
3
|
** Copyright (c) 2026 Dr. Ralf S. Engelschall <rse@engelschall.com>
|
|
4
4
|
** Licensed under Apache 2.0 <https://spdx.org/licenses/Apache-2.0>
|
|
5
5
|
*/
|
|
6
|
+
import * as path from "node:path";
|
|
6
7
|
import { specDiagrams } from "./specbook-diagram.js";
|
|
7
|
-
import { becauseRegex } from "./specbook-parse-common.js";
|
|
8
|
+
import { becauseRegex, embeddingRegex, embeddingCount } from "./specbook-parse-common.js";
|
|
8
9
|
import { isTitleObject } from "./specbook-export-common.js";
|
|
10
|
+
import { optimizeImages } from "./specbook-export-image.js";
|
|
9
11
|
/* format a timestamp in the frontmatter format */
|
|
10
|
-
const formatTimestamp = (date) => {
|
|
12
|
+
export const formatTimestamp = (date) => {
|
|
11
13
|
const pad = (n) => String(n).padStart(2, "0");
|
|
12
14
|
return `${date.getFullYear()}-${pad(date.getMonth() + 1)}-${pad(date.getDate())} ` +
|
|
13
15
|
`${pad(date.getHours())}:${pad(date.getMinutes())}`;
|
|
@@ -91,8 +93,19 @@ const renderObjectMd = (object, level, diagrams) => {
|
|
|
91
93
|
parts.push(...object.children.map((child) => renderObjectMd(child, level + 1, diagrams)));
|
|
92
94
|
return parts.filter((part) => part !== "").join("\n\n");
|
|
93
95
|
};
|
|
94
|
-
/*
|
|
95
|
-
|
|
96
|
+
/* re-base the local relative image references of a text from the
|
|
97
|
+
directory of their source file onto the one of the output file */
|
|
98
|
+
const rebaseMd = (text, from, to) => text.replace(embeddingRegex, (match, alt, reference) => {
|
|
99
|
+
const file = reference?.trim();
|
|
100
|
+
if (file === undefined || /^[a-z][a-z0-9+.-]+:/i.test(file) || path.isAbsolute(file))
|
|
101
|
+
return match;
|
|
102
|
+
const rebased = path.relative(to, path.resolve(from, file)).split(path.sep).join("/");
|
|
103
|
+
return ``;
|
|
104
|
+
});
|
|
105
|
+
/* render the entire specification into normalized Markdown, embedding
|
|
106
|
+
the images (so the export stands alone) and optionally re-basing the
|
|
107
|
+
remaining image references onto the output directory */
|
|
108
|
+
export const renderMarkdown = async (specification, config, rebase, verbose) => {
|
|
96
109
|
/* derive the Gradia specs of the diagram-configured objects
|
|
97
110
|
(an invalid diagram situation omits the diagram, as it is
|
|
98
111
|
already reported as a lint diagnostic, and the diagram of
|
|
@@ -102,6 +115,54 @@ export const renderMarkdown = (specification, config) => {
|
|
|
102
115
|
for (const [object, result] of specDiagrams(specification, config))
|
|
103
116
|
if (result.spec !== undefined && !isTitleObject(object))
|
|
104
117
|
diagrams.set(object, result.spec);
|
|
118
|
+
/* embed the images of a text, optimized like the ones of the HTML
|
|
119
|
+
export: an embeddable "" becomes the reference-style
|
|
120
|
+
"![alt][img-N]", whose "[img-N]: data:..." definition ends the
|
|
121
|
+
document (a "{theme}" one takes its light variant, as Markdown
|
|
122
|
+
knows no themes), and only the remaining ones are re-based */
|
|
123
|
+
const optimized = await optimizeImages(specification, false, verbose);
|
|
124
|
+
const labels = new Map();
|
|
125
|
+
const embedMd = (text, embedding, from, to) => {
|
|
126
|
+
let i = 0;
|
|
127
|
+
const embedded = text.replace(embeddingRegex, (match, alt, reference) => {
|
|
128
|
+
const count = embeddingCount(reference);
|
|
129
|
+
const content = count > 0 ? embedding[i] : undefined;
|
|
130
|
+
i += count;
|
|
131
|
+
if (content === undefined || content === "")
|
|
132
|
+
return match;
|
|
133
|
+
const image = optimized.get(content) ?? content;
|
|
134
|
+
const url = image.startsWith("data:") ? image :
|
|
135
|
+
`data:image/svg+xml;base64,${Buffer.from(image, "utf8").toString("base64")}`;
|
|
136
|
+
const label = labels.get(url) ?? `img-${labels.size + 1}`;
|
|
137
|
+
labels.set(url, label);
|
|
138
|
+
return `![${alt}][${label}]`;
|
|
139
|
+
});
|
|
140
|
+
return from !== undefined && to !== undefined && from !== to ?
|
|
141
|
+
rebaseMd(embedded, from, to) : embedded;
|
|
142
|
+
};
|
|
143
|
+
/* embed and re-base on clones of the objects (as the AST is shared
|
|
144
|
+
with the other exports), moving the diagrams along */
|
|
145
|
+
const localized = (object, from, to) => {
|
|
146
|
+
const clone = {
|
|
147
|
+
...object,
|
|
148
|
+
properties: object.properties.map((property) => ({ ...property, value: embedMd(property.value, property.embedding ?? [], from, to) })),
|
|
149
|
+
children: object.children.map((child) => localized(child, from, to))
|
|
150
|
+
};
|
|
151
|
+
if (object.description !== undefined)
|
|
152
|
+
clone.description = { ...object.description,
|
|
153
|
+
description: embedMd(object.description.description, object.description.embedding ?? [], from, to) };
|
|
154
|
+
const spec = diagrams.get(object);
|
|
155
|
+
if (spec !== undefined)
|
|
156
|
+
diagrams.set(clone, spec);
|
|
157
|
+
return clone;
|
|
158
|
+
};
|
|
159
|
+
const objects = specification.artifacts.flatMap((artifact) => {
|
|
160
|
+
const origin = rebase?.origins.get(artifact);
|
|
161
|
+
const from = origin !== undefined ? path.resolve(path.dirname(origin)) : undefined;
|
|
162
|
+
const to = rebase !== undefined ? path.resolve(rebase.dir) : undefined;
|
|
163
|
+
return artifact.objects.map((object) => localized(object, from, to));
|
|
164
|
+
});
|
|
165
|
+
const definitions = Array.from(labels, ([url, label]) => `[${label}]: ${url}\n`).join("");
|
|
105
166
|
/* the single frontmatter block (only recognized at the start of a
|
|
106
167
|
file on re-parse) carries the earliest creation and the latest
|
|
107
168
|
modification timestamp of all artifacts */
|
|
@@ -111,6 +172,6 @@ export const renderMarkdown = (specification, config) => {
|
|
|
111
172
|
`Created: ${formatTimestamp(created)}\n` +
|
|
112
173
|
`Modified: ${formatTimestamp(modified)}\n` +
|
|
113
174
|
"---\n\n" +
|
|
114
|
-
|
|
115
|
-
|
|
175
|
+
objects.map((object) => renderObjectMd(object, 1, diagrams)).join("\n\n") + "\n" +
|
|
176
|
+
(definitions !== "" ? `\n${definitions}` : "");
|
|
116
177
|
};
|
|
@@ -13,6 +13,7 @@ const anchorPages = async (pdf) => {
|
|
|
13
13
|
const doc = await PDFDocument.load(pdf);
|
|
14
14
|
const indexes = new Map();
|
|
15
15
|
doc.getPages().forEach((page, index) => indexes.set(page.ref.toString(), index + 1));
|
|
16
|
+
/* map every named destination onto the page number of its target */
|
|
16
17
|
const pages = new Map();
|
|
17
18
|
const dests = doc.catalog.lookupMaybe(PDFName.of("Dests"), PDFDict);
|
|
18
19
|
if (dests !== undefined) {
|
|
@@ -46,7 +47,7 @@ const addOutline = async (doc, entries) => {
|
|
|
46
47
|
targets.set(name.decodeText(), dest);
|
|
47
48
|
}
|
|
48
49
|
}
|
|
49
|
-
const resolve = (
|
|
50
|
+
const resolve = (list) => list.flatMap((entry) => {
|
|
50
51
|
const dest = targets.get(entry.anchor);
|
|
51
52
|
const children = resolve(entry.children);
|
|
52
53
|
return dest !== undefined ? [{ title: entry.title, dest, children }] : children;
|
|
@@ -56,9 +57,9 @@ const addOutline = async (doc, entries) => {
|
|
|
56
57
|
return;
|
|
57
58
|
/* recursively materialize the outline items, wiring up the
|
|
58
59
|
Parent/Prev/Next/First/Last cross-references of the tree */
|
|
59
|
-
const materialize = (
|
|
60
|
-
const refs =
|
|
61
|
-
|
|
60
|
+
const materialize = (list, parent) => {
|
|
61
|
+
const refs = list.map(() => context.nextRef());
|
|
62
|
+
list.forEach((item, i) => {
|
|
62
63
|
const children = item.children.length > 0 ? materialize(item.children, refs[i]) : undefined;
|
|
63
64
|
context.assign(refs[i], context.obj({
|
|
64
65
|
Title: PDFHexString.fromText(item.title),
|
|
@@ -163,6 +164,8 @@ const resolveBrowser = async (verbose) => {
|
|
|
163
164
|
`"${literal("SPECBOOK_BROWSER")}"`);
|
|
164
165
|
return options;
|
|
165
166
|
}
|
|
167
|
+
/* prefer the downloaded Playwright Chromium and
|
|
168
|
+
else probe a system-installed Google Chrome */
|
|
166
169
|
let executable = "";
|
|
167
170
|
try {
|
|
168
171
|
executable = chromium.executablePath();
|
package/dst/specbook-format.md
CHANGED
|
@@ -151,6 +151,9 @@ type SchemaGradiaConfig = Partial<{
|
|
|
151
151
|
"color-node-ghost-name": string
|
|
152
152
|
"color-node-ghost-box": string
|
|
153
153
|
"color-node-ghost-border": string
|
|
154
|
+
"color-node-self-name": string
|
|
155
|
+
"color-node-self-box": string
|
|
156
|
+
"color-node-self-border": string
|
|
154
157
|
"color-group-name": string
|
|
155
158
|
"color-group-box": string
|
|
156
159
|
"color-group-border": string
|
|
@@ -251,11 +254,13 @@ type SchemaGradiaConfig = Partial<{
|
|
|
251
254
|
livelocks are ruled out
|
|
252
255
|
|
|
253
256
|
- `SchemaObject.diagram?: SchemaDiagram`:
|
|
254
|
-
diagram generated for every object of this kind
|
|
257
|
+
diagram generated for every object of this kind (left out when the
|
|
258
|
+
configuration is emitted compressed by level 2 or higher),
|
|
255
259
|
BECAUSE relations buried in references are graspable only when drawn
|
|
256
260
|
|
|
257
261
|
- `SchemaObject.format?: SchemaFormat`:
|
|
258
|
-
HTML/PDF rendering format of the objects of this kind
|
|
262
|
+
HTML/PDF rendering format of the objects of this kind (left out when
|
|
263
|
+
the configuration is emitted compressed by level 2 or higher),
|
|
259
264
|
BECAUSE rich objects need sections, uniform ones read as tables
|
|
260
265
|
|
|
261
266
|
- `SchemaObject.props?: SchemaProperty[]`:
|
|
@@ -417,7 +422,7 @@ type SchemaGradiaConfig = Partial<{
|
|
|
417
422
|
BECAUSE the node name alone rarely makes the nodes comparable
|
|
418
423
|
|
|
419
424
|
- `SchemaDiagram.config?: SchemaGradiaConfig`:
|
|
420
|
-
arbitrary Gradia rendering options (e.g. `grid-columns-max:
|
|
425
|
+
arbitrary Gradia rendering options (e.g. `grid-columns-max: 2`),
|
|
421
426
|
BECAUSE layout tuning is cosmetics, not a schema concern
|
|
422
427
|
|
|
423
428
|
- `SchemaFormat`:
|
|
@@ -498,7 +503,8 @@ type SchemaGradiaConfig = Partial<{
|
|
|
498
503
|
BECAUSE an embedded font renders alike where it is not installed
|
|
499
504
|
|
|
500
505
|
- `SchemaGradiaConfig."color-node-*": string`:
|
|
501
|
-
colors of the node boxes in their regular, primary, and
|
|
506
|
+
colors of the node boxes in their regular, primary, ghost, and self
|
|
507
|
+
role,
|
|
502
508
|
BECAUSE the role of a node has to be readable at a glance
|
|
503
509
|
|
|
504
510
|
- `SchemaGradiaConfig."color-group-*", "color-edge-*": string`:
|
|
@@ -557,7 +563,9 @@ that kind. Its `type` selects the diagram shape (`graph`, `hub`, or
|
|
|
557
563
|
`grid`), `nodes` and `edges` select the participating objects through
|
|
558
564
|
comma-separated `[[xxx]]` reference patterns (`nodes` defaults to the
|
|
559
565
|
object itself plus all objects below it), and `center` names the object
|
|
560
|
-
a `hub` is projected onto (default: `self`).
|
|
566
|
+
a `hub` is projected onto (default: `self`). An edge object of the
|
|
567
|
+
center targeting the center itself (a self-reference) places the center
|
|
568
|
+
a second time among its outputs, rendered as a dashed "self" node.
|
|
561
569
|
|
|
562
570
|
Instead of naming an existing object, `center` can also declare a
|
|
563
571
|
*synthetic* center node, which represents something no specification
|
|
@@ -577,7 +585,15 @@ node only, `qualified` labels every node with its object kind,
|
|
|
577
585
|
`properties` lists the property names whose values are attached to the
|
|
578
586
|
nodes as key/value annotations (with every `[[xxx]]` reference stripped
|
|
579
587
|
to its target object name), and `config` passes arbitrary Gradia
|
|
580
|
-
rendering options (e.g. `grid-columns-max:
|
|
588
|
+
rendering options (e.g. `grid-columns-max: 2`) through to the diagram.
|
|
589
|
+
As the Gradia defaults are dimensioned for a stand-alone canvas,
|
|
590
|
+
SpecBook presets a compact geometry for every diagram, which `config`
|
|
591
|
+
overrides: `size-canvas-margin: 8`, `size-node-height-scale: 2`,
|
|
592
|
+
`hub-node-gap: 10`, `hub-node-count-max: 6` (a `hub` column of more
|
|
593
|
+
nodes wraps into two staggered sub-columns), `grid-gap-horizontal: 24`,
|
|
594
|
+
`grid-gap-vertical: 12`, and `grid-columns-min: 4` (the square
|
|
595
|
+
derivation of the column count otherwise leaves a third of the document
|
|
596
|
+
width unused).
|
|
581
597
|
|
|
582
598
|
`ordered` replaces the layered layout of a `graph` by the document
|
|
583
599
|
order of the objects: the top-level nodes stack top-down in that order
|
|
@@ -1026,8 +1042,11 @@ constrain reference-valued properties (e.g. `[[PERSONA:*]]` or
|
|
|
1026
1042
|
|
|
1027
1043
|
A Markdown image `` inside a description or property
|
|
1028
1044
|
value embeds a local image file, resolved relative to the artifact file:
|
|
1029
|
-
SVG files are inlined as-is; PNG/JPEG files are embedded as base64
|
|
1030
|
-
`data:` URLs. URLs and other file types are left untouched.
|
|
1045
|
+
SVG files are inlined as-is; PNG/JPEG/WebP files are embedded as base64
|
|
1046
|
+
`data:` URLs. URLs and other file types are left untouched. The
|
|
1047
|
+
HTML/PDF export optimizes the embedded images on its own (downscaled
|
|
1048
|
+
and re-encoded PNG/JPEG/WebP, minified SVG), so the files can stay in their
|
|
1049
|
+
original resolution.
|
|
1031
1050
|
|
|
1032
1051
|
A reference carrying the `{theme}` placeholder -- e.g.
|
|
1033
1052
|
`` -- is a *theme-aware* embedding: it expands
|
|
@@ -1036,3 +1055,11 @@ both of which are embedded, as two consecutive entries in this order. The
|
|
|
1036
1055
|
HTML export shows just the variant matching the color theme currently
|
|
1037
1056
|
active in the document, while the PDF export (like print in general)
|
|
1038
1057
|
always uses the `light` variant.
|
|
1058
|
+
|
|
1059
|
+
The reference-style image `![<alt/>][<label/>]` embeds the image of its
|
|
1060
|
+
definition `[<label/>]: data:image/<type/>;base64,<data/>` (SVG, PNG,
|
|
1061
|
+
JPEG, or WebP) in the same file, the only supported link definition.
|
|
1062
|
+
This is the form the normalized Markdown export emits for every embedded
|
|
1063
|
+
image (a theme-aware one by its `light` variant), with the definitions
|
|
1064
|
+
at the end of the document, so it stands alone without the image files.
|
|
1065
|
+
Author the inline form; the reference-style one is for re-parsing only.
|
package/dst/specbook-format.yaml
CHANGED
|
@@ -154,8 +154,8 @@
|
|
|
154
154
|
value: enum(Dark,Light)
|
|
155
155
|
optional: true
|
|
156
156
|
- name: THEME-TONE
|
|
157
|
-
desc: The theme color tone.
|
|
158
|
-
value: /^#[0-9a-fA-F]{6}
|
|
157
|
+
desc: The theme color tone, optionally followed by the complement color tone (else derived).
|
|
158
|
+
value: /^#[0-9a-fA-F]{6}(\s+#[0-9a-fA-F]{6})?$/
|
|
159
159
|
optional: true
|
|
160
160
|
|
|
161
161
|
- kind: REQS
|
|
@@ -250,7 +250,7 @@
|
|
|
250
250
|
config:
|
|
251
251
|
size-node-width-max: 440
|
|
252
252
|
size-node-height-scale: 1.75
|
|
253
|
-
grid-columns-max:
|
|
253
|
+
grid-columns-max: 4
|
|
254
254
|
children:
|
|
255
255
|
- kind: PREMISE
|
|
256
256
|
desc: >
|
|
@@ -643,7 +643,7 @@
|
|
|
643
643
|
- PRIORITY
|
|
644
644
|
config:
|
|
645
645
|
size-node-width-max: 440
|
|
646
|
-
grid-columns-max:
|
|
646
|
+
grid-columns-max: 4
|
|
647
647
|
children:
|
|
648
648
|
- kind: REQUIREMENT
|
|
649
649
|
desc: >
|
|
@@ -702,7 +702,7 @@
|
|
|
702
702
|
- CATEGORY
|
|
703
703
|
config:
|
|
704
704
|
size-node-width-max: 440
|
|
705
|
-
grid-columns-max:
|
|
705
|
+
grid-columns-max: 4
|
|
706
706
|
children:
|
|
707
707
|
- kind: REQUIREMENT
|
|
708
708
|
desc: >
|
|
@@ -799,7 +799,7 @@
|
|
|
799
799
|
- SOURCE
|
|
800
800
|
config:
|
|
801
801
|
size-node-width-max: 440
|
|
802
|
-
grid-columns-max:
|
|
802
|
+
grid-columns-max: 4
|
|
803
803
|
children:
|
|
804
804
|
- kind: RULE
|
|
805
805
|
desc: >
|
|
@@ -1426,7 +1426,7 @@
|
|
|
1426
1426
|
config:
|
|
1427
1427
|
size-node-width-max: 440
|
|
1428
1428
|
size-node-height-scale: 2
|
|
1429
|
-
grid-columns-max:
|
|
1429
|
+
grid-columns-max: 4
|
|
1430
1430
|
children:
|
|
1431
1431
|
- kind: LIFECYCLE
|
|
1432
1432
|
desc: >
|
|
@@ -1582,8 +1582,11 @@
|
|
|
1582
1582
|
onlyConnected: true
|
|
1583
1583
|
qualified: true
|
|
1584
1584
|
config:
|
|
1585
|
+
size-node-width-min: 300
|
|
1585
1586
|
size-node-width-max: 440
|
|
1586
|
-
graph-channel-width-max:
|
|
1587
|
+
graph-channel-width-max: 700
|
|
1588
|
+
graph-node-degree-max: 8
|
|
1589
|
+
size-edge-port-gap: 18
|
|
1587
1590
|
children:
|
|
1588
1591
|
- kind: PERMISSION
|
|
1589
1592
|
desc: >
|
|
@@ -3900,7 +3903,7 @@
|
|
|
3900
3903
|
- STATUS
|
|
3901
3904
|
config:
|
|
3902
3905
|
size-node-width-max: 440
|
|
3903
|
-
grid-columns-max:
|
|
3906
|
+
grid-columns-max: 4
|
|
3904
3907
|
children:
|
|
3905
3908
|
- kind: DECISION
|
|
3906
3909
|
desc: |
|
package/dst/specbook-ignore.js
CHANGED
|
@@ -34,8 +34,8 @@ const compileRule = (line, base) => {
|
|
|
34
34
|
if (pattern.startsWith("/"))
|
|
35
35
|
pattern = pattern.slice(1);
|
|
36
36
|
const glob = anchored ? (base === "" ? pattern : `${base}/${pattern}`) : `**/${pattern}`;
|
|
37
|
-
const isMatch = picomatch(glob, { dot: true });
|
|
38
|
-
return { matcher:
|
|
37
|
+
const isMatch = picomatch(glob, { dot: true, nobrace: true, noextglob: true, nonegate: true });
|
|
38
|
+
return { matcher: isMatch, negated, dirOnly };
|
|
39
39
|
};
|
|
40
40
|
/* load the rules of a single exclude file, given as an absolute "file"
|
|
41
41
|
and the working-tree-relative "base" its anchored patterns resolve
|
|
@@ -73,10 +73,10 @@ const workingTree = (dir) => {
|
|
|
73
73
|
/* resolve the global Git excludes file: the configured
|
|
74
74
|
"core.excludesFile", or else the XDG location Git falls back onto
|
|
75
75
|
when that configuration value is unset */
|
|
76
|
-
const globalFile = () => {
|
|
76
|
+
const globalFile = (root) => {
|
|
77
77
|
let file = "";
|
|
78
78
|
try {
|
|
79
|
-
file = execFileSync("git", ["config", "--get", "core.excludesFile"], { encoding: "utf8", stdio: ["ignore", "pipe", "ignore"] }).trim();
|
|
79
|
+
file = execFileSync("git", ["config", "--get", "core.excludesFile"], { cwd: root, encoding: "utf8", stdio: ["ignore", "pipe", "ignore"] }).trim();
|
|
80
80
|
}
|
|
81
81
|
catch {
|
|
82
82
|
/* no such configuration value, or no Git at all */
|
|
@@ -123,7 +123,7 @@ export const excluder = (dir) => {
|
|
|
123
123
|
return () => false;
|
|
124
124
|
/* the rules which no directory of the working tree owns and which
|
|
125
125
|
hence govern it as a whole, in ascending Git precedence */
|
|
126
|
-
const base = [...loadRules(globalFile(), ""), ...loadRules(infoFile(root), "")];
|
|
126
|
+
const base = [...loadRules(globalFile(root), ""), ...loadRules(infoFile(root), "")];
|
|
127
127
|
/* the per-directory ".gitignore" rules, loaded on first use, as one
|
|
128
128
|
run queries many files sharing the very same directories */
|
|
129
129
|
const cache = new Map();
|
package/dst/specbook-link.js
CHANGED
|
@@ -142,7 +142,7 @@ const nearest = (index, matches, from) => {
|
|
|
142
142
|
return i;
|
|
143
143
|
};
|
|
144
144
|
const proximities = matches.map((object) => proximity(object));
|
|
145
|
-
const best = Math.max(
|
|
145
|
+
const best = proximities.reduce((max, p) => Math.max(max, p), 0);
|
|
146
146
|
return matches.filter((_, i) => proximities[i] === best);
|
|
147
147
|
};
|
|
148
148
|
/* resolve a reference into a unique target: a single segment tries the
|