@rse/specbook 1.3.2 → 1.4.1
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 +9 -2
- package/dst/specbook-api.d.ts +5 -1
- package/dst/specbook-api.js +23 -9
- package/dst/specbook-cli.js +8 -5
- package/dst/specbook-cmd-export.js +2 -2
- package/dst/specbook-cmd-init.js +16 -2
- package/dst/specbook-coverage.js +1 -1
- package/dst/specbook-diagram.d.ts +1 -1
- package/dst/specbook-diagram.js +12 -10
- package/dst/specbook-export-ast.d.ts +1 -1
- package/dst/specbook-export-ast.js +21 -4
- package/dst/specbook-export-code.d.ts +14 -0
- package/dst/specbook-export-code.js +155 -0
- package/dst/specbook-export-common.d.ts +8 -2
- package/dst/specbook-export-common.js +126 -6
- package/dst/specbook-export-diagram.d.ts +15 -0
- package/dst/specbook-export-diagram.js +211 -0
- package/dst/specbook-export-html-fold.d.ts +20 -0
- package/dst/specbook-export-html-fold.js +458 -0
- package/dst/specbook-export-html-render.d.ts +51 -0
- package/dst/specbook-export-html-render.js +748 -0
- package/dst/specbook-export-html-search.js +6 -5
- package/dst/specbook-export-html-template.d.ts +21 -0
- package/dst/specbook-export-html-template.js +282 -0
- package/dst/specbook-export-html-widget.d.ts +12 -0
- package/dst/specbook-export-html-widget.js +605 -0
- package/dst/specbook-export-html.css +325 -50
- package/dst/specbook-export-html.js +62 -1931
- package/dst/specbook-export-image.d.ts +2 -0
- package/dst/specbook-export-image.js +161 -9
- package/dst/specbook-export-md.js +60 -12
- package/dst/specbook-format-schema.d.ts +1 -1
- package/dst/specbook-format-schema.js +1 -1
- package/dst/specbook-format-spec.d.ts +1 -0
- package/dst/specbook-format-spec.js +1 -0
- package/dst/specbook-format.md +173 -19
- package/dst/specbook-format.yaml +265 -24
- package/dst/specbook-ignore.js +2 -0
- package/dst/specbook-link.js +21 -5
- package/dst/specbook-mcp.js +9 -5
- package/dst/specbook-parse-common.d.ts +24 -0
- package/dst/specbook-parse-common.js +137 -10
- package/dst/specbook-parse-semantic.js +2 -0
- package/dst/specbook-parse-syntax.js +81 -20
- package/dst/specbook-parse.js +1 -1
- package/dst/specbook-project.d.ts +1 -0
- package/dst/specbook-project.js +10 -1
- package/dst/specbook-theme.d.ts +1 -0
- package/dst/specbook-theme.js +2 -0
- package/package.json +18 -12
|
@@ -4,8 +4,9 @@
|
|
|
4
4
|
** Licensed under Apache 2.0 <https://spdx.org/licenses/Apache-2.0>
|
|
5
5
|
*/
|
|
6
6
|
import fs from "node:fs";
|
|
7
|
-
import { embeddingThemes } from "./specbook-parse-common.js";
|
|
7
|
+
import { embeddingThemes, darkMark } from "./specbook-parse-common.js";
|
|
8
8
|
import { plainText } from "./specbook-link.js";
|
|
9
|
+
import { themeToneDefault } from "./specbook-theme.js";
|
|
9
10
|
/* escape a text for embedding into template HTML (text and attributes) */
|
|
10
11
|
export const escapeHtml = (text) => text.replace(/&/g, "&").replace(/</g, "<").replace(/>/g, ">")
|
|
11
12
|
.replace(/"/g, """).replace(/'/g, "'");
|
|
@@ -19,9 +20,13 @@ export const searchScript = () => fs.readFileSync(new URL("specbook-export-html-
|
|
|
19
20
|
export const fallbackLogo = (theme) => "data:image/svg+xml;base64," +
|
|
20
21
|
fs.readFileSync(new URL(`specbook-export-logo-${theme}.svg`, import.meta.url)).toString("base64");
|
|
21
22
|
/* the content aspects an export can omit, each matching the set of a
|
|
22
|
-
folding control of the HTML export: the diagrams of a type
|
|
23
|
-
|
|
23
|
+
folding control of the HTML export: the diagrams of a type (the
|
|
24
|
+
derived Gradia ones, the embedded Mermaid/D2 ones as "code", the
|
|
25
|
+
embedded images and PDF pages as "image", and the code listings as
|
|
26
|
+
"listing"), the diagrams from an object tree nesting level on, and the
|
|
27
|
+
long texts */
|
|
24
28
|
export const omitAspects = ["diagram:graph", "diagram:hub", "diagram:grid",
|
|
29
|
+
"diagram:code", "diagram:image", "diagram:listing",
|
|
25
30
|
"diagram:1", "diagram:2", "diagram:3", "text:long"];
|
|
26
31
|
/* parse the (comma-separated) aspects to omit, where the
|
|
27
32
|
plain "diagram" is the alias of "diagram:1", hence of all diagrams */
|
|
@@ -107,6 +112,21 @@ export const documentPaperSize = (specification) => {
|
|
|
107
112
|
`(expected ${paperSizes.join(", ")})`);
|
|
108
113
|
return paper;
|
|
109
114
|
};
|
|
115
|
+
/* the properties of the title object the exports interpret themselves,
|
|
116
|
+
each with the value "init" seeds it with (its rendering default, an
|
|
117
|
+
empty value counting as an absent property) */
|
|
118
|
+
export const titleProperties = [
|
|
119
|
+
{ name: "LOGO", init: "" },
|
|
120
|
+
{ name: "TITLE", init: "Specification" },
|
|
121
|
+
{ name: "SUBTITLE", init: "" },
|
|
122
|
+
{ name: "AUTHOR", init: "" },
|
|
123
|
+
{ name: "VERSION", init: "" },
|
|
124
|
+
{ name: "LANG", init: "en" },
|
|
125
|
+
{ name: "CHARSET", init: "UTF-8" },
|
|
126
|
+
{ name: "THEME-STYLE", init: "Light" },
|
|
127
|
+
{ name: "THEME-TONE", init: themeToneDefault },
|
|
128
|
+
{ name: "PAPER-SIZE", init: paperSizeDefault }
|
|
129
|
+
];
|
|
110
130
|
/* the vertical room (in rem) the introducing heading of a diagram
|
|
111
131
|
claims above it on the same page: without this reserve a maximally
|
|
112
132
|
sized diagram could not share its page with the heading, which
|
|
@@ -129,7 +149,7 @@ export const paperStylesheet = (paper) => {
|
|
|
129
149
|
const avail = setup.height - setup.margin.top - setup.margin.bottom;
|
|
130
150
|
return "@media print {\n" +
|
|
131
151
|
"div.diagram { break-inside: avoid; }\n" +
|
|
132
|
-
`div.diagram svg { max-height: calc(${paperLength(setup, avail)} - ${headingReserve}rem);` +
|
|
152
|
+
`div.diagram svg, div.diagram img { max-height: calc(${paperLength(setup, avail)} - ${headingReserve}rem);` +
|
|
133
153
|
" width: auto; height: auto; }\n" +
|
|
134
154
|
`nav.doc div.diagram svg { max-height: calc(${paperLength(setup, avail)} - ${docHeadingReserve}rem); }\n` +
|
|
135
155
|
"}\n";
|
|
@@ -223,6 +243,105 @@ export const documentTitle = (specification) => {
|
|
|
223
243
|
subtitle: subtitle !== undefined ? plainText(subtitle) : undefined
|
|
224
244
|
};
|
|
225
245
|
};
|
|
246
|
+
/* the luma (0-1, of the gamma-encoded channels) of a CSS color given as
|
|
247
|
+
"#rgb", "#rrggbb", "rgb(r, g, b)", "white", or "black" (undefined for
|
|
248
|
+
any other one) */
|
|
249
|
+
const luminance = (color) => {
|
|
250
|
+
const value = color.trim().toLowerCase();
|
|
251
|
+
const named = value === "white" ? "#ffffff" : value === "black" ? "#000000" : value;
|
|
252
|
+
const hex = named.match(/^#([0-9a-f]{3}|[0-9a-f]{6})$/);
|
|
253
|
+
const rgb = named.match(/^rgb\(\s*(\d+)\s*,\s*(\d+)\s*,\s*(\d+)\s*\)$/);
|
|
254
|
+
const parts = hex !== null ?
|
|
255
|
+
(hex[1].length === 3 ? hex[1].split("").map((c) => c + c) : hex[1].match(/../g) ?? [])
|
|
256
|
+
.map((c) => parseInt(c, 16)) :
|
|
257
|
+
rgb !== null ? rgb.slice(1, 4).map(Number) : undefined;
|
|
258
|
+
return parts !== undefined ? (0.2126 * parts[0] + 0.7152 * parts[1] + 0.0722 * parts[2]) / 255 : undefined;
|
|
259
|
+
};
|
|
260
|
+
/* whether an SVG image is most likely dark ink on a transparent or light
|
|
261
|
+
canvas and hence safe to invert on the dark theme ("dark=auto"): it
|
|
262
|
+
neither adapts itself (via "light-dark()" colors, "prefers-color-scheme"
|
|
263
|
+
rules, or a "color-scheme" declaration), nor embeds a raster image
|
|
264
|
+
(which would turn into a negative), nor paints a background of its own
|
|
265
|
+
which is not light (by the style of its root element or by the topmost
|
|
266
|
+
of the leading shapes covering the whole canvas -- rectangles or
|
|
267
|
+
rectangular paths, as a rendered PDF page paints its background over
|
|
268
|
+
the white paper of PDF.js --, an unknown color counting as not light) */
|
|
269
|
+
export const svgInvertible = (svg) => {
|
|
270
|
+
if (/light-dark\(|prefers-color-scheme|color-scheme|<image\b/.test(svg))
|
|
271
|
+
return false;
|
|
272
|
+
const root = svg.match(/<svg\b[^>]*>/)?.[0] ?? "";
|
|
273
|
+
const light = (color) => (luminance(color ?? "#000000") ?? 0) >= 0.5;
|
|
274
|
+
const back = root.match(/background(?:-color)?\s*:\s*([^;"]+)/)?.[1];
|
|
275
|
+
if (back !== undefined && !/^\s*(?:none|transparent)\s*$/.test(back) && !light(back))
|
|
276
|
+
return false;
|
|
277
|
+
/* the canvas size (in the user units of the viewBox, if any) */
|
|
278
|
+
const attr = (element, name) => element.match(new RegExp(`\\s${name}="([^"]*)"`))?.[1] ??
|
|
279
|
+
element.match(new RegExp(`[\\s;"]${name}\\s*:\\s*([^;"]+)`))?.[1];
|
|
280
|
+
const box = attr(root, "viewBox")?.trim().split(/[\s,]+/).map(Number) ?? [];
|
|
281
|
+
const width = box[2] || parseFloat(attr(root, "width") ?? "");
|
|
282
|
+
const height = box[3] || parseFloat(attr(root, "height") ?? "");
|
|
283
|
+
/* whether a shape covers the whole canvas: the corner points of a
|
|
284
|
+
rectangle or of a path of straight absolute segments only, mapped
|
|
285
|
+
through its "scale()" or "matrix()" transform (if any) */
|
|
286
|
+
const covers = (kind, element) => {
|
|
287
|
+
const num = (name) => parseFloat(attr(element, name) ?? "0") || 0;
|
|
288
|
+
let points;
|
|
289
|
+
if (kind === "rect") {
|
|
290
|
+
if (attr(element, "width") === "100%" && attr(element, "height") === "100%")
|
|
291
|
+
return true;
|
|
292
|
+
const [x, y] = [num("x"), num("y")];
|
|
293
|
+
points = [[x, y], [x + num("width"), y + num("height")]];
|
|
294
|
+
}
|
|
295
|
+
else {
|
|
296
|
+
const d = attr(element, "d") ?? "";
|
|
297
|
+
if (kind !== "path" || !/^[\sMLHVZ\d.,-]*$/.test(d))
|
|
298
|
+
return false;
|
|
299
|
+
points = [];
|
|
300
|
+
for (const [, cmd, args] of d.matchAll(/([MLHVZ])([^MLHVZ]*)/g)) {
|
|
301
|
+
const n = args.trim().split(/[\s,]+/).filter((a) => a !== "").map(Number);
|
|
302
|
+
const last = points.at(-1) ?? [0, 0];
|
|
303
|
+
if (cmd === "H")
|
|
304
|
+
points.push(...n.map((x) => [x, last[1]]));
|
|
305
|
+
else if (cmd === "V")
|
|
306
|
+
points.push(...n.map((y) => [last[0], y]));
|
|
307
|
+
else
|
|
308
|
+
for (let k = 0; k + 1 < n.length; k += 2)
|
|
309
|
+
points.push([n[k], n[k + 1]]);
|
|
310
|
+
}
|
|
311
|
+
}
|
|
312
|
+
const t = attr(element, "transform")?.trim() ?? "";
|
|
313
|
+
const f = t.match(/^(matrix|scale)\(([^)]*)\)$/);
|
|
314
|
+
const v = f?.[2].trim().split(/[\s,]+/).map(Number) ?? [];
|
|
315
|
+
const m = t === "" ? [1, 0, 0, 1, 0, 0] :
|
|
316
|
+
f?.[1] === "matrix" && v.length === 6 ? v :
|
|
317
|
+
f?.[1] === "scale" ? [v[0], 0, 0, v[1] ?? v[0], 0, 0] : undefined;
|
|
318
|
+
if (m === undefined || points.length === 0)
|
|
319
|
+
return false;
|
|
320
|
+
const xs = points.map(([x, y]) => m[0] * x + m[2] * y + m[4]);
|
|
321
|
+
const ys = points.map(([x, y]) => m[1] * x + m[3] * y + m[5]);
|
|
322
|
+
return Math.min(...xs) <= 1 && Math.min(...ys) <= 1
|
|
323
|
+
&& Math.max(...xs) >= width - 1 && Math.max(...ys) >= height - 1;
|
|
324
|
+
};
|
|
325
|
+
/* the topmost leading canvas-covering shape (outside of any
|
|
326
|
+
definition) forms the background */
|
|
327
|
+
let fill;
|
|
328
|
+
const body = svg.slice(svg.indexOf(root) + root.length)
|
|
329
|
+
.replace(/<(clipPath|defs|mask|pattern|symbol)\b[\s\S]*?<\/\1>/g, "");
|
|
330
|
+
for (const shape of body.matchAll(/<(rect|path|circle|ellipse|line|polyline|polygon|text|use)\b[^>]*>/g)) {
|
|
331
|
+
if (!covers(shape[1], shape[0]))
|
|
332
|
+
break;
|
|
333
|
+
fill = attr(shape[0], "fill") ?? "#000000";
|
|
334
|
+
}
|
|
335
|
+
return fill === undefined || fill === "none" || fill === "transparent" || light(fill);
|
|
336
|
+
};
|
|
337
|
+
/* the treatment of an embedded image on the dark theme: its explicit
|
|
338
|
+
mark, else the "auto" detection (for a raster image or a PDF page the
|
|
339
|
+
judgement of "analyzeImages", none where absent) */
|
|
340
|
+
export const imageDark = (content, rasters) => {
|
|
341
|
+
const { content: plain, dark } = darkMark(content);
|
|
342
|
+
return dark ?? ((plain.startsWith("data:") ? rasters?.get(plain) === true : svgInvertible(plain)) ?
|
|
343
|
+
"invert" : "none");
|
|
344
|
+
};
|
|
226
345
|
/* determine the document logo as a self-contained data: URL, taken from the
|
|
227
346
|
first embedded image of the LOGO property of the title object -- the light
|
|
228
347
|
variant of a "{theme}" reference -- and falling back onto the built-in
|
|
@@ -233,6 +352,7 @@ export const documentLogo = (specification) => {
|
|
|
233
352
|
?.properties.find((property) => property.key === "LOGO")?.embedding?.[0];
|
|
234
353
|
if (content === undefined || content === "")
|
|
235
354
|
return fallbackLogo("light");
|
|
236
|
-
|
|
237
|
-
|
|
355
|
+
const plain = darkMark(content).content;
|
|
356
|
+
return plain.startsWith("data:") ? plain :
|
|
357
|
+
`data:image/svg+xml;base64,${Buffer.from(plain, "utf8").toString("base64")}`;
|
|
238
358
|
};
|
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
import type { Spec, SpecObject } from "./specbook-format-spec.js";
|
|
2
|
+
import { type OmitAspect } from "./specbook-export-common.js";
|
|
3
|
+
import { type ThemeStyle } from "./specbook-theme.js";
|
|
4
|
+
import { type Verbose } from "./specbook-verbose.js";
|
|
5
|
+
export type DiagramLanguage = "mermaid" | "d2";
|
|
6
|
+
export type DiagramVariants = Record<ThemeStyle, string>;
|
|
7
|
+
export declare const fenceLanguage: (lang: string | undefined) => DiagramLanguage | undefined;
|
|
8
|
+
export declare const embeddedSource: (content: string) => {
|
|
9
|
+
language: DiagramLanguage;
|
|
10
|
+
source: string;
|
|
11
|
+
} | undefined;
|
|
12
|
+
export declare const diagramKey: (language: DiagramLanguage, source: string) => string;
|
|
13
|
+
export declare const omittedDiagram: (language: DiagramLanguage | "image" | "listing", level: number, omit: Set<OmitAspect>) => boolean;
|
|
14
|
+
export declare const objectLevels: (specification: Spec) => Map<SpecObject, number>;
|
|
15
|
+
export declare const renderEmbeddedDiagrams: (specification: Spec, verbose?: Verbose, omit?: Set<"diagram:graph" | "diagram:hub" | "diagram:grid" | "diagram:code" | "diagram:image" | "diagram:listing" | "diagram:1" | "diagram:2" | "diagram:3" | "text:long">) => Promise<Map<string, DiagramVariants>>;
|
|
@@ -0,0 +1,211 @@
|
|
|
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 { marked } from "marked";
|
|
7
|
+
import { documentThemeTone } from "./specbook-export-common.js";
|
|
8
|
+
import { themeColors, themeToneDefault } from "./specbook-theme.js";
|
|
9
|
+
import { plainText } from "./specbook-link.js";
|
|
10
|
+
import { literal } from "./specbook-verbose.js";
|
|
11
|
+
/* the fenced code block languages of the embedded diagrams */
|
|
12
|
+
const fenceLanguages = {
|
|
13
|
+
mermaid: "mermaid",
|
|
14
|
+
mmd: "mermaid",
|
|
15
|
+
d2: "d2"
|
|
16
|
+
};
|
|
17
|
+
/* map the language of a fenced code block onto its diagram language
|
|
18
|
+
(undefined for a regular code block) */
|
|
19
|
+
export const fenceLanguage = (lang) => fenceLanguages[(lang ?? "").trim().toLowerCase()];
|
|
20
|
+
/* decode an embedded diagram source file, which the parser loads as a
|
|
21
|
+
base64 data: URL of its diagram MIME type (undefined for an image) */
|
|
22
|
+
export const embeddedSource = (content) => {
|
|
23
|
+
const m = content.match(/^data:text\/vnd\.(mermaid|d2);base64,(.*)$/s);
|
|
24
|
+
return m !== null ?
|
|
25
|
+
{ language: m[1], source: Buffer.from(m[2], "base64").toString("utf8") } :
|
|
26
|
+
undefined;
|
|
27
|
+
};
|
|
28
|
+
/* the key of an embedded diagram in the map of the rendered ones */
|
|
29
|
+
export const diagramKey = (language, source) => `${language}\u0000${source}`;
|
|
30
|
+
/* whether an embedded diagram of a language (a Mermaid/D2 one being of
|
|
31
|
+
the type "code"), an image, or a code listing, carried by an object of
|
|
32
|
+
a nesting level, is omitted (exactly like a Gradia diagram: by its
|
|
33
|
+
type, or from an object tree nesting level on) */
|
|
34
|
+
export const omittedDiagram = (language, level, omit) => omit.has(language === "image" ? "diagram:image" : language === "listing" ? "diagram:listing" : "diagram:code")
|
|
35
|
+
|| [1, 2, 3].some((n) => n <= level && omit.has(`diagram:${n}`));
|
|
36
|
+
/* the object tree nesting levels of the objects of a specification
|
|
37
|
+
(the top-level objects of an artifact being level 1) */
|
|
38
|
+
export const objectLevels = (specification) => {
|
|
39
|
+
const levels = new Map();
|
|
40
|
+
const walk = (object, level) => {
|
|
41
|
+
levels.set(object, level);
|
|
42
|
+
for (const child of object.children)
|
|
43
|
+
walk(child, level + 1);
|
|
44
|
+
};
|
|
45
|
+
for (const artifact of specification.artifacts)
|
|
46
|
+
for (const object of artifact.objects)
|
|
47
|
+
walk(object, 1);
|
|
48
|
+
return levels;
|
|
49
|
+
};
|
|
50
|
+
/* collect the embedded diagrams of an object and its descendants: the
|
|
51
|
+
fenced code blocks of a diagram language inside its description
|
|
52
|
+
statement and elaboration and the diagram source files embedded into
|
|
53
|
+
its description and property values */
|
|
54
|
+
const collect = (object, level, diagrams) => {
|
|
55
|
+
for (const text of [object.description?.description ?? "", object.description?.elaboration ?? ""])
|
|
56
|
+
marked.walkTokens(marked.lexer(text), (token) => {
|
|
57
|
+
const language = token.type === "code" ? fenceLanguage(token.lang) : undefined;
|
|
58
|
+
if (language !== undefined)
|
|
59
|
+
diagrams.push({ language, source: token.text, object, level });
|
|
60
|
+
});
|
|
61
|
+
const contents = [...object.description?.embedding ?? [],
|
|
62
|
+
...object.properties.flatMap((property) => property.embedding ?? [])];
|
|
63
|
+
for (const content of contents) {
|
|
64
|
+
const embedded = embeddedSource(content);
|
|
65
|
+
if (embedded !== undefined)
|
|
66
|
+
diagrams.push({ ...embedded, object, level });
|
|
67
|
+
}
|
|
68
|
+
for (const child of object.children)
|
|
69
|
+
collect(child, level + 1, diagrams);
|
|
70
|
+
};
|
|
71
|
+
/* the colors of the Mermaid diagrams per theme style, picked out of the
|
|
72
|
+
theme color spreads like the layer-2 mapping of the HTML rendering
|
|
73
|
+
(Mermaid derives all further colors of a diagram from these) */
|
|
74
|
+
const mermaidColors = (colors, style) => {
|
|
75
|
+
const [b, a] = [colors.base, colors.accent];
|
|
76
|
+
return style === "dark" ?
|
|
77
|
+
{ bg: b[6], fg: b[28], line: b[21], accent: a[21], muted: b[21], surface: a[7], border: a[13] } :
|
|
78
|
+
{ bg: b[31], fg: b[5], line: b[21], accent: a[11], muted: b[21], surface: a[30], border: a[23] };
|
|
79
|
+
};
|
|
80
|
+
/* the colors of the D2 diagrams per theme style, picked out of the
|
|
81
|
+
theme color spreads after the shape of the D2 themes "Neutral
|
|
82
|
+
Default" (light) and "Dark Mauve" (dark): the neutrals "N1" (text)
|
|
83
|
+
to "N7" (background), the base colors "B1" (strokes) to "B6"
|
|
84
|
+
(fills), and the alternative accents "AA*"/"AB*" */
|
|
85
|
+
const d2Colors = (colors, style) => {
|
|
86
|
+
const [b, a, s] = [colors.base, colors.accent, colors.signal];
|
|
87
|
+
return style === "dark" ? {
|
|
88
|
+
N1: b[28], N2: b[25], N3: b[22], N4: b[13], N5: b[10], N6: b[8], N7: b[6],
|
|
89
|
+
B1: a[21], B2: a[21], B3: a[13], B4: a[10], B5: a[8], B6: a[7],
|
|
90
|
+
AA2: s[19], AA4: s[10], AA5: s[8], AB4: s[10], AB5: s[8]
|
|
91
|
+
} : {
|
|
92
|
+
N1: b[3], N2: b[13], N3: b[19], N4: b[25], N5: b[27], N6: b[29], N7: b[31],
|
|
93
|
+
B1: a[7], B2: a[11], B3: a[27], B4: a[28], B5: a[29], B6: a[30],
|
|
94
|
+
AA2: s[13], AA4: s[28], AA5: s[30], AB4: s[28], AB5: s[30]
|
|
95
|
+
};
|
|
96
|
+
};
|
|
97
|
+
/* render a Mermaid diagram, stripping the web font import the SVG
|
|
98
|
+
carries, as an SVG embedded through an <img> never loads it and it
|
|
99
|
+
would be the only external reference of the exported document */
|
|
100
|
+
const renderMermaid = async (source, colors) => {
|
|
101
|
+
const { renderMermaidSVG } = await import("beautiful-mermaid");
|
|
102
|
+
return renderMermaidSVG(source, { ...colors, transparent: true, padding: 4 })
|
|
103
|
+
.replace(/^\s*@import url\([^)]*\);\s*$/m, "");
|
|
104
|
+
};
|
|
105
|
+
/* the D2 compiler, created on first use (as it spins up its WebAssembly
|
|
106
|
+
build in a worker thread), and the chain of its requests, as it
|
|
107
|
+
resolves just the latest pending one and hence has to be fed one
|
|
108
|
+
request at a time: the worker is referenced during a request only,
|
|
109
|
+
as it would otherwise keep the process alive forever */
|
|
110
|
+
let d2 = null;
|
|
111
|
+
let d2Chain = Promise.resolve();
|
|
112
|
+
/* turn a D2 compile failure (a JSON-encoded list of the errors, whose
|
|
113
|
+
source lines are shifted by the prepended theme line) into a message */
|
|
114
|
+
const d2Error = (err) => {
|
|
115
|
+
const message = err instanceof Error ? err.message : String(err);
|
|
116
|
+
try {
|
|
117
|
+
return JSON.parse(message)
|
|
118
|
+
.map((error) => error.errmsg.replace(/^index:(\d+):/, (_, line) => `line ${Number(line) - 1}:`))
|
|
119
|
+
.join("; ");
|
|
120
|
+
}
|
|
121
|
+
catch {
|
|
122
|
+
return message;
|
|
123
|
+
}
|
|
124
|
+
};
|
|
125
|
+
/* render a D2 diagram, with the theme colors prepended as one line of
|
|
126
|
+
theme overrides (on the theme of the style), so the overrides of the
|
|
127
|
+
diagram itself still win */
|
|
128
|
+
const renderD2 = (source, colors, style) => {
|
|
129
|
+
const overrides = Object.entries(colors).map(([key, color]) => `${key}: "${color}"`).join("; ");
|
|
130
|
+
const request = d2Chain.then(async () => {
|
|
131
|
+
d2 ??= import("@terrastruct/d2").then((module) => new module.D2())
|
|
132
|
+
.catch((err) => {
|
|
133
|
+
d2 = null;
|
|
134
|
+
throw err;
|
|
135
|
+
});
|
|
136
|
+
const compiler = await d2;
|
|
137
|
+
const worker = () => compiler.worker;
|
|
138
|
+
worker()?.ref();
|
|
139
|
+
try {
|
|
140
|
+
const result = await compiler.compile({
|
|
141
|
+
fs: { index: `vars: { d2-config: { theme-overrides: { ${overrides} } } }\n${source}` },
|
|
142
|
+
options: { themeID: style === "dark" ? 200 : 0, scale: 1, pad: 0, noXMLTag: true }
|
|
143
|
+
});
|
|
144
|
+
return await compiler.render(result.diagram, result.renderOptions);
|
|
145
|
+
}
|
|
146
|
+
catch (err) {
|
|
147
|
+
throw new Error(d2Error(err), { cause: err });
|
|
148
|
+
}
|
|
149
|
+
finally {
|
|
150
|
+
worker()?.unref();
|
|
151
|
+
}
|
|
152
|
+
});
|
|
153
|
+
d2Chain = request.catch(() => undefined);
|
|
154
|
+
return request;
|
|
155
|
+
};
|
|
156
|
+
/* the in-memory cache of the rendered embedded diagrams, keyed by theme
|
|
157
|
+
tone and diagram, and swept to the diagrams of the latest rendering,
|
|
158
|
+
exactly like the one of the Gradia diagrams */
|
|
159
|
+
let diagramCache = new Map();
|
|
160
|
+
/* render the embedded diagrams of a specification (the fenced code blocks
|
|
161
|
+
of a diagram language and the embedded diagram source files) into their
|
|
162
|
+
theme variants, drawn in the theme colors of the document and keyed by
|
|
163
|
+
"diagramKey" (a rendering failure omits the diagram and is surfaced as
|
|
164
|
+
a notice only, like the one of a Gradia diagram), served from the cache
|
|
165
|
+
where possible; the omitted diagrams are skipped */
|
|
166
|
+
export const renderEmbeddedDiagrams = async (specification, verbose, omit = new Set()) => {
|
|
167
|
+
const diagrams = new Array();
|
|
168
|
+
for (const artifact of specification.artifacts)
|
|
169
|
+
for (const object of artifact.objects)
|
|
170
|
+
collect(object, 1, diagrams);
|
|
171
|
+
const tone = documentThemeTone(specification) ?? themeToneDefault;
|
|
172
|
+
const colors = themeColors(tone);
|
|
173
|
+
const cache = new Map();
|
|
174
|
+
const rendered = new Map();
|
|
175
|
+
const failed = new Set();
|
|
176
|
+
let cached = 0;
|
|
177
|
+
for (const { language, source, object, level } of diagrams) {
|
|
178
|
+
const key = diagramKey(language, source);
|
|
179
|
+
if (rendered.has(key) || failed.has(key) || omittedDiagram(language, level, omit))
|
|
180
|
+
continue;
|
|
181
|
+
let variants = diagramCache.get(`${tone}\u0000${key}`) ?? cache.get(`${tone}\u0000${key}`);
|
|
182
|
+
if (variants !== undefined)
|
|
183
|
+
cached++;
|
|
184
|
+
else {
|
|
185
|
+
try {
|
|
186
|
+
variants = language === "mermaid" ? {
|
|
187
|
+
light: await renderMermaid(source, mermaidColors(colors, "light")),
|
|
188
|
+
dark: await renderMermaid(source, mermaidColors(colors, "dark"))
|
|
189
|
+
} : {
|
|
190
|
+
light: await renderD2(source, d2Colors(colors, "light"), "light"),
|
|
191
|
+
dark: await renderD2(source, d2Colors(colors, "dark"), "dark")
|
|
192
|
+
};
|
|
193
|
+
}
|
|
194
|
+
catch (err) {
|
|
195
|
+
verbose?.(`rendering ${language} diagram of ${object.kind} ` +
|
|
196
|
+
`"${literal(plainText(object.name))}" failed: ` +
|
|
197
|
+
(err instanceof Error ? err.message : String(err)));
|
|
198
|
+
failed.add(key);
|
|
199
|
+
continue;
|
|
200
|
+
}
|
|
201
|
+
}
|
|
202
|
+
cache.set(`${tone}\u0000${key}`, variants);
|
|
203
|
+
rendered.set(key, variants);
|
|
204
|
+
}
|
|
205
|
+
diagramCache = cache;
|
|
206
|
+
if (rendered.size > 0)
|
|
207
|
+
verbose?.(cached === rendered.size ?
|
|
208
|
+
`reusing ${literal(cached)} cached embedded diagram(s)` :
|
|
209
|
+
`rendering ${literal(rendered.size)} embedded diagram(s) (${literal(cached)} cached)`);
|
|
210
|
+
return rendered;
|
|
211
|
+
};
|
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
import type nunjucks from "nunjucks";
|
|
2
|
+
import type { OmitAspect } from "./specbook-export-common.js";
|
|
3
|
+
export declare const foldScript: (standins: boolean, textless: boolean) => string;
|
|
4
|
+
export declare const omitLong: (omit: Set<OmitAspect> | null, cells: nunjucks.runtime.SafeString[], shares: number[], percent?: number) => nunjucks.runtime.SafeString[];
|
|
5
|
+
export declare const omittedControls: (omit: Set<OmitAspect>) => {
|
|
6
|
+
omitted: {
|
|
7
|
+
graph: boolean;
|
|
8
|
+
hub: boolean;
|
|
9
|
+
grid: boolean;
|
|
10
|
+
code: boolean;
|
|
11
|
+
image: boolean;
|
|
12
|
+
listing: boolean;
|
|
13
|
+
level1: boolean;
|
|
14
|
+
level2: boolean;
|
|
15
|
+
level3: boolean;
|
|
16
|
+
text: boolean;
|
|
17
|
+
all: boolean;
|
|
18
|
+
};
|
|
19
|
+
css: string;
|
|
20
|
+
};
|