@rse/specbook 1.3.1 → 1.4.0
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 +5 -2
- package/dst/specbook-api.js +7 -5
- package/dst/specbook-cli.js +0 -0
- package/dst/specbook-cmd-export.js +2 -2
- package/dst/specbook-coverage.js +1 -1
- package/dst/specbook-diagram.d.ts +1 -1
- package/dst/specbook-diagram.js +9 -3
- 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 +4 -2
- package/dst/specbook-export-common.js +110 -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 +749 -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 +169 -17
- package/dst/specbook-format.yaml +224 -23
- package/dst/specbook-ignore.js +2 -0
- package/dst/specbook-link.js +21 -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-theme.d.ts +1 -0
- package/dst/specbook-theme.js +2 -0
- package/package.json +17 -11
|
@@ -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
|
+
};
|