@rse/specbook 1.3.2 → 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.
Files changed (45) hide show
  1. package/README.md +5 -2
  2. package/dst/specbook-api.js +7 -5
  3. package/dst/specbook-cli.js +0 -0
  4. package/dst/specbook-cmd-export.js +2 -2
  5. package/dst/specbook-coverage.js +1 -1
  6. package/dst/specbook-diagram.d.ts +1 -1
  7. package/dst/specbook-diagram.js +9 -3
  8. package/dst/specbook-export-ast.d.ts +1 -1
  9. package/dst/specbook-export-ast.js +21 -4
  10. package/dst/specbook-export-code.d.ts +14 -0
  11. package/dst/specbook-export-code.js +155 -0
  12. package/dst/specbook-export-common.d.ts +4 -2
  13. package/dst/specbook-export-common.js +110 -6
  14. package/dst/specbook-export-diagram.d.ts +15 -0
  15. package/dst/specbook-export-diagram.js +211 -0
  16. package/dst/specbook-export-html-fold.d.ts +20 -0
  17. package/dst/specbook-export-html-fold.js +458 -0
  18. package/dst/specbook-export-html-render.d.ts +51 -0
  19. package/dst/specbook-export-html-render.js +749 -0
  20. package/dst/specbook-export-html-search.js +6 -5
  21. package/dst/specbook-export-html-template.d.ts +21 -0
  22. package/dst/specbook-export-html-template.js +282 -0
  23. package/dst/specbook-export-html-widget.d.ts +12 -0
  24. package/dst/specbook-export-html-widget.js +605 -0
  25. package/dst/specbook-export-html.css +325 -50
  26. package/dst/specbook-export-html.js +62 -1931
  27. package/dst/specbook-export-image.d.ts +2 -0
  28. package/dst/specbook-export-image.js +161 -9
  29. package/dst/specbook-export-md.js +60 -12
  30. package/dst/specbook-format-schema.d.ts +1 -1
  31. package/dst/specbook-format-schema.js +1 -1
  32. package/dst/specbook-format-spec.d.ts +1 -0
  33. package/dst/specbook-format-spec.js +1 -0
  34. package/dst/specbook-format.md +169 -17
  35. package/dst/specbook-format.yaml +224 -23
  36. package/dst/specbook-ignore.js +2 -0
  37. package/dst/specbook-link.js +21 -5
  38. package/dst/specbook-parse-common.d.ts +24 -0
  39. package/dst/specbook-parse-common.js +137 -10
  40. package/dst/specbook-parse-semantic.js +2 -0
  41. package/dst/specbook-parse-syntax.js +81 -20
  42. package/dst/specbook-parse.js +1 -1
  43. package/dst/specbook-theme.d.ts +1 -0
  44. package/dst/specbook-theme.js +2 -0
  45. package/package.json +13 -7
@@ -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
+ };