@rse/specbook 0.9.2

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 (59) hide show
  1. package/README.md +199 -0
  2. package/dst/specbook-api.d.ts +47 -0
  3. package/dst/specbook-api.js +77 -0
  4. package/dst/specbook-cli.d.ts +2 -0
  5. package/dst/specbook-cli.js +156 -0
  6. package/dst/specbook-cmd-describe.d.ts +2 -0
  7. package/dst/specbook-cmd-describe.js +97 -0
  8. package/dst/specbook-cmd-edit.d.ts +9 -0
  9. package/dst/specbook-cmd-edit.js +69 -0
  10. package/dst/specbook-cmd-export.d.ts +9 -0
  11. package/dst/specbook-cmd-export.js +78 -0
  12. package/dst/specbook-cmd-import.d.ts +9 -0
  13. package/dst/specbook-cmd-import.js +73 -0
  14. package/dst/specbook-cmd-init.d.ts +7 -0
  15. package/dst/specbook-cmd-init.js +37 -0
  16. package/dst/specbook-cmd-lint.d.ts +15 -0
  17. package/dst/specbook-cmd-lint.js +46 -0
  18. package/dst/specbook-config.d.ts +6 -0
  19. package/dst/specbook-config.js +91 -0
  20. package/dst/specbook-diagnostic.d.ts +8 -0
  21. package/dst/specbook-diagnostic.js +23 -0
  22. package/dst/specbook-diagram.d.ts +9 -0
  23. package/dst/specbook-diagram.js +271 -0
  24. package/dst/specbook-export-ast.d.ts +4 -0
  25. package/dst/specbook-export-ast.js +36 -0
  26. package/dst/specbook-export-common.d.ts +33 -0
  27. package/dst/specbook-export-common.js +142 -0
  28. package/dst/specbook-export-html-search.js +272 -0
  29. package/dst/specbook-export-html.css +259 -0
  30. package/dst/specbook-export-html.d.ts +9 -0
  31. package/dst/specbook-export-html.js +452 -0
  32. package/dst/specbook-export-logo.svg +35 -0
  33. package/dst/specbook-export-md.d.ts +3 -0
  34. package/dst/specbook-export-md.js +74 -0
  35. package/dst/specbook-export-pdf.d.ts +7 -0
  36. package/dst/specbook-export-pdf.js +198 -0
  37. package/dst/specbook-link.d.ts +17 -0
  38. package/dst/specbook-link.js +113 -0
  39. package/dst/specbook-llm.d.ts +13 -0
  40. package/dst/specbook-llm.js +61 -0
  41. package/dst/specbook-mcp.d.ts +1 -0
  42. package/dst/specbook-mcp.js +163 -0
  43. package/dst/specbook-parse-common.d.ts +26 -0
  44. package/dst/specbook-parse-common.js +33 -0
  45. package/dst/specbook-parse-semantic.d.ts +5 -0
  46. package/dst/specbook-parse-semantic.js +254 -0
  47. package/dst/specbook-parse-syntax.d.ts +3 -0
  48. package/dst/specbook-parse-syntax.js +363 -0
  49. package/dst/specbook-parse-value.d.ts +19 -0
  50. package/dst/specbook-parse-value.js +123 -0
  51. package/dst/specbook-parse.d.ts +8 -0
  52. package/dst/specbook-parse.js +43 -0
  53. package/dst/specbook-struct-schema.d.ts +35 -0
  54. package/dst/specbook-struct-schema.js +47 -0
  55. package/dst/specbook-struct-spec.d.ts +31 -0
  56. package/dst/specbook-struct-spec.js +36 -0
  57. package/dst/specbook-theme.d.ts +16 -0
  58. package/dst/specbook-theme.js +38 -0
  59. package/package.json +77 -0
@@ -0,0 +1,9 @@
1
+ import type { Specification } from "./specbook-struct-spec.js";
2
+ import type { SchemaSpecification } from "./specbook-struct-schema.js";
3
+ export type OutlineEntry = {
4
+ title: string;
5
+ anchor: string;
6
+ childs: OutlineEntry[];
7
+ };
8
+ export declare const htmlOutline: (specification: Specification, maxColumns: number) => OutlineEntry[];
9
+ export declare const renderHtml: (specification: Specification, maxColumns: number, config?: SchemaSpecification, tocPages?: Map<string, number>, css?: string) => Promise<string>;
@@ -0,0 +1,452 @@
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 { markedSmartypants } from "marked-smartypants";
8
+ import nunjucks from "nunjucks";
9
+ import nunjucksAddons from "@rse/nunjucks-addons";
10
+ import textframe from "textframe";
11
+ import { Gradia } from "@rse/gradia";
12
+ import { buildLinkIndex, resolveUnique, expandReferences, anchorPaths } from "./specbook-link.js";
13
+ import { compileValueExpr, splitItems } from "./specbook-parse-value.js";
14
+ import { embeddingRegex, embeddingMimeType } from "./specbook-parse-common.js";
15
+ import { escapeHtml, stylesheet, searchScript, fallbackLogo, isTitleObject, documentTitle, documentLang, documentThemeStyle } from "./specbook-export-common.js";
16
+ import { specDiagrams } from "./specbook-diagram.js";
17
+ /* ==== Templates ==== */
18
+ /* the built-in Nunjucks templates for the HTML export */
19
+ const templates = {
20
+ /* <Document/> */
21
+ "Document": textframe `
22
+ <!DOCTYPE html>
23
+ <html{% if Document.lang %} lang="{{ Document.lang }}"{% endif %}{% if Document.theme %} class="theme-{{ Document.theme }}"{% endif %}>
24
+ <head>
25
+ <meta charset="utf-8"/>
26
+ <title>{{ Document.title }}</title>
27
+ <style>
28
+ {{ Document.css }}
29
+ </style>
30
+ <script>
31
+ (function () {
32
+ let style = null
33
+ try { style = localStorage.getItem("specbook-theme") } catch (ex) {}
34
+ if (style === null) {
35
+ const m = document.documentElement.className.match(/theme-(light|dark)/)
36
+ style = m !== null ? m[1] : null
37
+ }
38
+ if (style === null)
39
+ style = window.matchMedia("(prefers-color-scheme: dark)").matches ? "dark" : "light"
40
+ document.documentElement.className = "theme-" + style
41
+ })()
42
+ function themeSwitch () {
43
+ const style = document.documentElement.className === "theme-dark" ? "light" : "dark"
44
+ document.documentElement.className = "theme-" + style
45
+ try { localStorage.setItem("specbook-theme", style) } catch (ex) {}
46
+ }
47
+ </script>
48
+ </head>
49
+ <body>
50
+ <div class="theme-switch" onclick="themeSwitch()" title="switch color theme">&#x25D0;</div>
51
+ {{ Document.titlepage }}
52
+ {% if not Document.titlepage %}<div class="meta">created: {{ Document.created }}, modified: {{ Document.modified }}</div>{% endif %}
53
+ {{ Document.toc }}
54
+ {{ Document.artifacts }}
55
+ {% if Document.search %}<script>{{ Document.search }}</script>{% endif %}
56
+ </body>
57
+ </html>
58
+ `,
59
+ /* <TitlePage/> */
60
+ "TitlePage": textframe `
61
+ <div class="titlepage">
62
+ {% if TitlePage.logo %}<div class="logo">{{ TitlePage.logo }}</div>{% endif %}
63
+ <div class="title">{{ TitlePage.title }}</div>
64
+ {% if TitlePage.subtitle %}<div class="subtitle">{{ TitlePage.subtitle }}</div>{% endif %}
65
+ {% if TitlePage.author %}<div class="author">{{ TitlePage.author }}</div>{% endif %}
66
+ <table class="meta">
67
+ {% if TitlePage.version %}<tr><td class="label">Version:</td><td>{{ TitlePage.version }}</td></tr>{% endif %}
68
+ <tr><td class="label">Created:</td><td>{{ TitlePage.created }}</td></tr>
69
+ <tr><td class="label">Modified:</td><td>{{ TitlePage.modified }}</td></tr>
70
+ </table>
71
+ {{ TitlePage.description }}
72
+ {{ TitlePage.properties }}
73
+ <div class="search">
74
+ <input type="text" id="search-input" placeholder="Search&hellip;" autocomplete="off" spellcheck="false"/>
75
+ <span class="search-clear" id="search-clear" title="clear search">&#x00D7;</span>
76
+ </div>
77
+ </div>
78
+ `,
79
+ /* <Toc/> */
80
+ "Toc": textframe `
81
+ <nav class="toc">
82
+ <h1>Table of Contents</h1>
83
+ <table>
84
+ {% for entry in Toc.entries %}
85
+ <tr><td><a href="#{{ entry.id }}"><span class="object-kind">{{ entry.kind }}:</span> {{ entry.name }} <span class="link-symbol">&#x26AD;</span></a></td>{% if entry.page %}<td class="page"><a href="#{{ entry.id }}">{{ entry.page }}</a></td>{% endif %}</tr>
86
+ {% endfor %}
87
+ </table>
88
+ </nav>
89
+ `,
90
+ /* <Artifact/> */
91
+ "Artifact": textframe `
92
+ <article>
93
+ {{ Artifact.objects }}
94
+ </article>
95
+ `,
96
+ /* <Object/> */
97
+ "Object": textframe `
98
+ <section>
99
+ <h{{ Object.level }} id="{{ Object.id }}"><span class="object-kind">{{ Object.kind }}:</span> {{ Object.name }}{% if Object.primary %} <span class="primary-marker">&#x2318;</span>{% endif %}{% if Object.paren %} <span class="anchor-paren">({{ Object.paren }})</span>{% endif %} <a href="#{{ Object.id }}"><span class="anchor-symbol">&#x2693;&#xFE0E;</span></a></h{{ Object.level }}>
100
+ {{ Object.diagram }}
101
+ {{ Object.properties }}
102
+ {{ Object.description }}
103
+ {{ Object.childs }}
104
+ </section>
105
+ `,
106
+ /* <Properties/> */
107
+ "Properties": textframe `
108
+ <table class="props">
109
+ {% for property in Properties %}
110
+ <tr><td class="key property-name">{{ property.key }}</td><td>{{ property.value }}</td></tr>
111
+ {% endfor %}
112
+ </table>
113
+ `,
114
+ /* <Description/> */
115
+ "Description": textframe `
116
+ {%- if Description.block %}
117
+ <div class="description">{{ Description.description }}{% if Description.rationale %}
118
+ <p class="rationale">&mdash; <span class="keyword">BECAUSE</span> {{ Description.rationale }}</p>{% endif %}</div>
119
+ {%- else %}
120
+ <p class="description">{{ Description.description }}{% if Description.rationale %}
121
+ <span class="rationale">&mdash; <span class="keyword">BECAUSE</span> {{ Description.rationale }}</span>{% endif %}</p>
122
+ {%- endif %}
123
+ {%- for embedding in Description.embeddings %}
124
+ <div class="embedding">{{ embedding }}</div>
125
+ {%- endfor %}
126
+ `,
127
+ /* <Table/> */
128
+ "Table": textframe `
129
+ <table class="objects">
130
+ <thead>
131
+ <tr>
132
+ <th class="object-kind">{{ Table.head }}</th>
133
+ {%- for key in Table.keys %}<th class="property-name">{{ key }}</th>{% endfor %}
134
+ {%- if Table.desc %}<th class="description" style="width: {{ Table.width }}%">Description</th>{% endif %}
135
+ </tr>
136
+ </thead>
137
+ <tbody>
138
+ {% for row in Table.rows %}
139
+ <tr id="{{ row.id }}">
140
+ <td>{{ row.name }}{% if row.primary %} <span class="primary-marker">&#x2318;</span>{% endif %}{% if row.paren %} <span class="anchor-paren">({{ row.paren }})</span>{% endif %} <a href="#{{ row.id }}"><span class="anchor-symbol">&#x2693;&#xFE0E;</span></a></td>
141
+ {%- for value in row.values %}<td>{{ value }}</td>{% endfor %}
142
+ {%- if Table.desc %}<td>{{ row.description }}</td>{% endif %}
143
+ </tr>
144
+ {% endfor %}
145
+ </tbody>
146
+ </table>
147
+ `
148
+ };
149
+ /* ==== Rendering ==== */
150
+ /* the per-language double quote styles for the smart typography
151
+ (single quotes stay curly, as smartypants cannot distinguish a
152
+ closing single quote from an apostrophe) */
153
+ const quoteStyles = {
154
+ "en": ["&#8220;", "&#8221;"], /* “...” */
155
+ "nl": ["&#8220;", "&#8221;"], /* “...” */
156
+ "de": ["&#8222;", "&#8220;"], /* „...“ */
157
+ "cs": ["&#8222;", "&#8220;"], /* „...“ */
158
+ "sk": ["&#8222;", "&#8220;"], /* „...“ */
159
+ "pl": ["&#8222;", "&#8221;"], /* „...” */
160
+ "fr": ["&#171;&#160;", "&#160;&#187;"], /* « ... » */
161
+ "it": ["&#171;", "&#187;"], /* «...» */
162
+ "es": ["&#171;", "&#187;"], /* «...» */
163
+ "pt": ["&#171;", "&#187;"], /* «...» */
164
+ "no": ["&#171;", "&#187;"], /* «...» */
165
+ "da": ["&#187;", "&#171;"], /* »...« */
166
+ "sv": ["&#8221;", "&#8221;"], /* ”...” */
167
+ "fi": ["&#8221;", "&#8221;"] /* ”...” */
168
+ };
169
+ /* the active double quote style (set per document during rendering) */
170
+ let quotes = quoteStyles.en;
171
+ /* improve the typography of all rendered Markdown text (curly quotes,
172
+ "--" as the em dash, ellipsis); as smartypants requires unescaped text,
173
+ the extension switches off the text escaping of marked, so the stray
174
+ ampersands left behind have to be re-escaped afterwards; the English
175
+ double quotes of smartypants are remapped to the document language;
176
+ as marked runs later-registered pass-through hooks first, the
177
+ post-processing hook has to be registered before smartypants */
178
+ marked.use({ hooks: { postprocess: (html) => html.replace(/&#8220;/g, quotes[0]).replace(/&#8221;/g, quotes[1])
179
+ .replace(/&(?![a-zA-Z][a-zA-Z0-9]*;|#\d+;|#x[0-9a-fA-F]+;)/g, "&amp;") } });
180
+ marked.use(markedSmartypants({ config: 1 }));
181
+ /* the Nunjucks environment with the @rse/nunjucks-addons extensions */
182
+ const env = new nunjucks.Environment(null, { autoescape: true });
183
+ nunjucksAddons(env);
184
+ /* mark pre-rendered HTML as safe for template interpolation */
185
+ const safe = (html) => new nunjucks.runtime.SafeString(html);
186
+ const render = (name, context) => env.renderString(templates[name], context);
187
+ /* the active per-document reference expander, fully-qualified
188
+ anchor paths, enum/tags property value kinds, and pre-rendered
189
+ diagram SVGs (all set during HTML rendering) */
190
+ let linker = null;
191
+ let anchors = null;
192
+ let members = null;
193
+ let diagrams = null;
194
+ /* determine the fully-qualified anchor path of an object */
195
+ const anchorOf = (object) => anchors?.get(object) ?? object.id;
196
+ /* expand the inline Markdown of a text (code spans, emphasis, etc.),
197
+ with Wiki-style references expanded upfront */
198
+ const inline = (text) => safe(marked.parseInline(linker !== null ? linker(text) : text));
199
+ /* expand the full Markdown of a text, keeping its block-level
200
+ constructs (lists, quotes, code blocks, tables) intact */
201
+ const block = (text) => safe(marked.parse(linker !== null ? linker(text) : text));
202
+ /* check whether a text carries any block-level Markdown, i.e. is
203
+ anything else than the single paragraph a description usually is */
204
+ const isBlock = (text) => {
205
+ const tokens = marked.lexer(text).filter((token) => token.type !== "space");
206
+ return tokens.length > 0 && !(tokens.length === 1 && tokens[0].type === "paragraph");
207
+ };
208
+ /* render the embedded image files of a text into HTML (SVG inlined
209
+ as-is, PNG/JPEG placed onto <img> tags), taking the image alternate
210
+ texts from the corresponding "![alt](file)" markups */
211
+ const renderEmbeddings = (text, embedding) => {
212
+ const alts = [...text.matchAll(embeddingRegex)]
213
+ .filter((m) => embeddingMimeType(m[2].trim()) !== undefined)
214
+ .map((m) => m[1].trim());
215
+ return embedding.map((content, i) => content.startsWith("data:") ?
216
+ `<img src="${content}" alt="${escapeHtml(alts[i] ?? "")}"/>` :
217
+ content.replace(/^\s*<\?xml[^>]*\?>\s*(?:<!DOCTYPE[^>]*>\s*)?/i, ""));
218
+ };
219
+ /* render a description into HTML, expanding its inline Markdown and
220
+ moving the file embeddings to the end of the description */
221
+ const renderDescription = (description) => {
222
+ const text = description.description
223
+ .replace(embeddingRegex, (markup, _alt, reference) => embeddingMimeType(reference.trim()) !== undefined ? "" : markup)
224
+ .replace(/(?<=\S)[ \t]{2,}/g, " ").trim();
225
+ const embeddings = renderEmbeddings(description.description, description.embedding ?? []).map((content) => safe(content));
226
+ const blocked = isBlock(text);
227
+ return render("Description", { Description: {
228
+ block: blocked,
229
+ description: blocked ? block(text) : inline(text),
230
+ rationale: description.rationale !== undefined ?
231
+ inline(description.rationale) : undefined,
232
+ embeddings
233
+ } });
234
+ };
235
+ /* collect the "enum(...)"/"tags(...)" constrained properties of the
236
+ schema configuration, keyed by object kind and property name */
237
+ const collectMembers = (schemas, result) => {
238
+ for (const schema of schemas) {
239
+ for (const property of schema.props ?? []) {
240
+ if (property.value === undefined)
241
+ continue;
242
+ try {
243
+ const expr = compileValueExpr(property.value);
244
+ if (expr.kind === "enum" || expr.kind === "tags")
245
+ result.set(`${schema.kind}${property.name}`, expr.kind);
246
+ }
247
+ catch {
248
+ /* an invalid expression is the concern of lint */
249
+ }
250
+ }
251
+ collectMembers(schema.childs ?? [], result);
252
+ }
253
+ return result;
254
+ };
255
+ /* render a property value, badging the individual members of an
256
+ "enum(...)" (a single member) or "tags(...)" (a member set) value */
257
+ const inlineValue = (kind, key, value) => {
258
+ const member = members?.get(`${kind}${key.replace(/\s*\([^)]*\)\s*$/, "").trim()}`);
259
+ if (member === undefined)
260
+ return inline(value);
261
+ const items = member === "tags" ? splitItems(value) : [value.trim()];
262
+ return safe(items.map((item) => `<span class="value-member">${inline(item)}</span>`).join(" "));
263
+ };
264
+ /* expand the inline Markdown of the property values */
265
+ const inlineProperties = (kind, properties) => properties.map((property) => ({ key: property.key,
266
+ value: inlineValue(kind, property.key, property.value) }));
267
+ /* determine the column shape of a potential compact table */
268
+ const tableShape = (childs) => {
269
+ const keys = new Array();
270
+ for (const child of childs)
271
+ for (const property of child.properties)
272
+ if (!keys.includes(property.key))
273
+ keys.push(property.key);
274
+ return { keys, desc: childs.some((child) => child.description !== undefined) };
275
+ };
276
+ /* check whether the childs of an object form the deepest level and every
277
+ per-kind group is compact enough (at most maxColumns name/property/
278
+ description columns) for a tabular rendering */
279
+ const tabularChilds = (object, maxColumns) => {
280
+ if (object.childs.length === 0
281
+ || !object.childs.every((child) => child.childs.length === 0))
282
+ return false;
283
+ return groupChilds(object.childs).every((group) => {
284
+ const { keys, desc } = tableShape(group);
285
+ return 1 + keys.length + (desc ? 1 : 0) <= maxColumns;
286
+ });
287
+ };
288
+ /* group the childs of an object by their kind, preserving order */
289
+ const groupChilds = (childs) => {
290
+ const groups = new Map();
291
+ for (const child of childs) {
292
+ const group = groups.get(child.kind);
293
+ if (group === undefined)
294
+ groups.set(child.kind, [child]);
295
+ else
296
+ group.push(child);
297
+ }
298
+ return [...groups.values()];
299
+ };
300
+ /* render a single-kind group of leaf childs into one compact table:
301
+ the name first, then the property columns, then the description */
302
+ const renderTable = (childs) => {
303
+ const { keys, desc } = tableShape(childs);
304
+ return render("Table", { Table: {
305
+ head: childs[0].kind !== "" ? childs[0].kind : "Name",
306
+ keys,
307
+ desc,
308
+ /* under the fixed table layout the description column claims
309
+ twice the share of a regular column, compressing the others */
310
+ width: Math.round(200 / (keys.length + 3)),
311
+ rows: childs.map((child) => ({
312
+ id: anchorOf(child),
313
+ paren: child.paren,
314
+ primary: child.primary,
315
+ name: inline(child.name),
316
+ values: keys.map((key) => {
317
+ const value = child.properties.find((property) => property.key === key)?.value;
318
+ return value !== undefined ? inlineValue(child.kind, key, value) : "";
319
+ }),
320
+ description: child.description !== undefined ?
321
+ safe(renderDescription(child.description)) : ""
322
+ }))
323
+ } });
324
+ };
325
+ /* recursively render an object into HTML */
326
+ const renderObject = (object, level, maxColumns) => render("Object", { Object: {
327
+ level: Math.min(level, 6),
328
+ kind: object.kind,
329
+ id: anchorOf(object),
330
+ paren: object.paren,
331
+ primary: object.primary,
332
+ name: inline(object.name),
333
+ diagram: diagrams?.has(object) === true ?
334
+ safe(`<div class="diagram">${diagrams.get(object) ?? ""}</div>`) : "",
335
+ properties: object.properties.length > 0 ?
336
+ safe(render("Properties", { Properties: inlineProperties(object.kind, object.properties) })) : "",
337
+ description: object.description !== undefined ?
338
+ safe(renderDescription(object.description)) : "",
339
+ childs: tabularChilds(object, maxColumns) ?
340
+ safe(groupChilds(object.childs).map(renderTable).join("")) :
341
+ safe(object.childs.map((child) => renderObject(child, level + 1, maxColumns)).join(""))
342
+ } });
343
+ /* render the title object into a title page */
344
+ const renderTitlePage = (object, created, modified) => {
345
+ const prop = (name) => object.properties.find((property) => property.key === name)?.value;
346
+ const rest = object.properties.filter((property) => !["LOGO", "TITLE", "SUBTITLE", "AUTHOR", "VERSION",
347
+ "LANG", "CHARSET", "PAPER-SIZE", "THEME-STYLE", "THEME-TONE"].includes(property.key));
348
+ /* the logo is rendered above the title, from the embedded image content
349
+ of the LOGO property or, for a non-embeddable reference, as its inline
350
+ Markdown; without a LOGO property the built-in SpecBook logo is used */
351
+ const logo = object.properties.find((property) => property.key === "LOGO");
352
+ const image = logo !== undefined ? renderEmbeddings(logo.value, logo.embedding ?? []) : [];
353
+ return render("TitlePage", { TitlePage: {
354
+ logo: logo === undefined ? safe(`<img src="${fallbackLogo()}" alt="SpecBook"/>`) :
355
+ (image.length > 0 ? safe(image.join("")) : inline(logo.value)),
356
+ title: inline(prop("TITLE") ?? object.name),
357
+ subtitle: prop("SUBTITLE") !== undefined ? inline(prop("SUBTITLE") ?? "") : "",
358
+ author: prop("AUTHOR") !== undefined ? inline(prop("AUTHOR") ?? "") : "",
359
+ version: prop("VERSION") !== undefined ? inline(prop("VERSION") ?? "") : "",
360
+ description: object.description !== undefined ?
361
+ safe(renderDescription(object.description)) : "",
362
+ properties: rest.length > 0 ?
363
+ safe(render("Properties", { Properties: inlineProperties(object.kind, rest) })) : "",
364
+ created, modified
365
+ } });
366
+ };
367
+ /* render an artifact into HTML */
368
+ const renderArtifact = (artifact, maxColumns) => render("Artifact", { Artifact: {
369
+ objects: safe(artifact.objects.map((object) => renderObject(object, 1, maxColumns)).join(""))
370
+ } });
371
+ /* derive the hierarchy of the rendered object headings, skipping the
372
+ title page object and the leaf childs collapsing into compact tables */
373
+ export const htmlOutline = (specification, maxColumns) => {
374
+ const paths = anchorPaths(buildLinkIndex(specification));
375
+ const entry = (object) => ({
376
+ title: (object.kind !== "" ? `${object.kind}: ` : "") + object.name,
377
+ anchor: paths.get(object) ?? object.id,
378
+ childs: tabularChilds(object, maxColumns) ? [] : object.childs.map(entry)
379
+ });
380
+ return specification.artifacts
381
+ .filter((artifact) => !artifact.objects.some(isTitleObject))
382
+ .flatMap((artifact) => artifact.objects)
383
+ .map(entry);
384
+ };
385
+ /* render the entire specification into a self-contained HTML document,
386
+ with the build-time pre-assembled stylesheet embedded inline, the
387
+ artifact timestamps aggregated into min(Created)/max(Modified), and
388
+ optional per-anchor page numbers attached to the ToC entries */
389
+ export const renderHtml = async (specification, maxColumns, config, tocPages, css) => {
390
+ /* the document language selects the smart typography quote style */
391
+ const lang = documentLang(specification);
392
+ quotes = quoteStyles[lang?.toLowerCase().split(/[-_]/)[0] ?? "en"] ?? quoteStyles.en;
393
+ /* pre-render the configured diagrams as embeddable SVGs (a runtime
394
+ rendering failure omits the diagram, as the statically detectable
395
+ invalid situations are already reported as lint diagnostics),
396
+ displayed at a reduced coordinate scale, as the Gradia geometry
397
+ (node boxes, font sizes) is dimensioned for a stand-alone
398
+ canvas and would dwarf the document text at 1:1 */
399
+ const scale = 0.75;
400
+ diagrams = null;
401
+ if (config !== undefined) {
402
+ diagrams = new Map();
403
+ for (const [object, result] of specDiagrams(specification, config)) {
404
+ if (result.spec === undefined)
405
+ continue;
406
+ try {
407
+ const svg = await Gradia.render(result.spec, { format: "svg:embedded" });
408
+ diagrams.set(object, svg.replace(/(<svg[^>]*) width="([0-9.]+)" height="([0-9.]+)"/, (_, head, w, h) => `${head} width="${Number(w) * scale}" height="${Number(h) * scale}"`));
409
+ }
410
+ catch {
411
+ /* intentionally omitted */
412
+ }
413
+ }
414
+ }
415
+ /* expand "[[xxx]]" references into hyperlinks (an unresolvable or
416
+ ambiguous reference stays literal, marked as broken), targeting
417
+ the fully-qualified anchor paths of the objects */
418
+ const index = buildLinkIndex(specification);
419
+ anchors = anchorPaths(index);
420
+ members = config !== undefined ? collectMembers(config, new Map()) : null;
421
+ linker = (text) => expandReferences(text, (reference) => {
422
+ const target = resolveUnique(index, reference).target;
423
+ if (target === undefined)
424
+ return `<span class="link-broken">[[${escapeHtml(reference)}]]</span>`;
425
+ return `<a href="#${escapeHtml(anchorOf(target))}">` +
426
+ `<span class="object-kind">${escapeHtml(target.kind)}:</span> <strong>${target.name}</strong>` +
427
+ " <span class=\"link-symbol\">&#x26AD;</span></a>";
428
+ });
429
+ const created = new Date(Math.min(...specification.artifacts.map((artifact) => artifact.created.getTime())));
430
+ const modified = new Date(Math.max(...specification.artifacts.map((artifact) => artifact.modified.getTime())));
431
+ /* a "META: Title" object becomes the title page and leaves the regular flow */
432
+ const title = specification.artifacts
433
+ .flatMap((artifact) => artifact.objects).find(isTitleObject);
434
+ const artifacts = specification.artifacts
435
+ .filter((artifact) => !artifact.objects.some(isTitleObject));
436
+ const entries = artifacts.flatMap((artifact) => artifact.objects)
437
+ .map((object) => ({ id: anchorOf(object), kind: object.kind, name: inline(object.name),
438
+ page: tocPages?.get(anchorOf(object)) }));
439
+ return render("Document", { Document: {
440
+ title: documentTitle(specification).title,
441
+ lang,
442
+ theme: documentThemeStyle(specification)?.toLowerCase(),
443
+ css: safe(css ?? stylesheet()),
444
+ created: created.toISOString(),
445
+ modified: modified.toISOString(),
446
+ titlepage: title !== undefined ?
447
+ safe(renderTitlePage(title, created.toISOString().slice(0, 10), modified.toISOString().slice(0, 10))) : "",
448
+ search: title !== undefined ? safe(searchScript()) : "",
449
+ toc: entries.length > 0 ? safe(render("Toc", { Toc: { entries } })) : "",
450
+ artifacts: safe(artifacts.map((artifact) => renderArtifact(artifact, maxColumns)).join(""))
451
+ } });
452
+ };
@@ -0,0 +1,35 @@
1
+ <?xml version="1.0" encoding="UTF-8"?>
2
+ <svg id="Layer_1" xmlns="http://www.w3.org/2000/svg" version="1.1" viewBox="0 0 260.81 110.54">
3
+ <!-- Generator: Adobe Illustrator 30.7.0, SVG Export Plug-In . SVG Version: 2.1.4 Build 114) -->
4
+ <defs>
5
+ <style>
6
+ .st0 {
7
+ fill: none;
8
+ }
9
+
10
+ .st1 {
11
+ fill: #231f20;
12
+ }
13
+
14
+ .st2 {
15
+ fill: #666766;
16
+ }
17
+ </style>
18
+ </defs>
19
+ <g>
20
+ <rect class="st0" x="128.02" y="5.89" width="297.69" height="245.38"/>
21
+ <path class="st1" d="M132.56,41.28c2.62,1.34,6.66,2.69,10.82,2.69,4.48,0,6.85-1.86,6.85-4.67s-2.05-4.22-7.23-6.08c-7.17-2.5-11.84-6.46-11.84-12.73,0-7.36,6.14-12.99,16.32-12.99,4.86,0,8.45,1.02,11.01,2.18l-2.18,7.87c-1.73-.83-4.8-2.05-9.02-2.05s-6.27,1.92-6.27,4.16c0,2.75,2.43,3.97,8,6.08,7.62,2.82,11.2,6.78,11.2,12.86,0,7.23-5.57,13.38-17.41,13.38-4.93,0-9.79-1.28-12.22-2.62l1.98-8.06Z"/>
22
+ <path class="st1" d="M163.28,30.53c0-4.1-.13-7.62-.26-10.5h8.45l.45,4.35h.13c2.3-3.33,5.89-5.06,10.43-5.06,6.85,0,12.99,5.95,12.99,15.93,0,11.39-7.23,16.77-14.21,16.77-3.78,0-6.72-1.54-8.13-3.58h-.13v15.55h-9.73V30.53ZM173.01,37.89c0,.77.06,1.47.19,2.05.64,2.62,2.88,4.61,5.7,4.61,4.22,0,6.72-3.52,6.72-8.83,0-4.99-2.24-8.77-6.59-8.77-2.75,0-5.18,2.05-5.82,4.93-.13.51-.19,1.15-.19,1.73v4.29Z"/>
23
+ <path class="st1" d="M206.03,38.78c.32,4.03,4.29,5.95,8.83,5.95,3.33,0,6.02-.45,8.64-1.28l1.28,6.59c-3.2,1.28-7.1,1.92-11.33,1.92-10.62,0-16.7-6.14-16.7-15.94,0-7.94,4.93-16.7,15.81-16.7,10.11,0,13.95,7.87,13.95,15.61,0,1.66-.19,3.14-.32,3.84h-20.16ZM217.48,32.13c0-2.37-1.02-6.34-5.5-6.34-4.1,0-5.76,3.71-6.02,6.34h11.52Z"/>
24
+ <path class="st1" d="M252.62,50.56c-1.73.77-4.99,1.41-8.7,1.41-10.11,0-16.57-6.14-16.57-16,0-9.15,6.27-16.64,17.92-16.64,2.56,0,5.38.45,7.42,1.22l-1.54,7.23c-1.15-.51-2.88-.96-5.44-.96-5.12,0-8.45,3.65-8.38,8.77,0,5.76,3.84,8.77,8.58,8.77,2.3,0,4.1-.38,5.57-.96l1.15,7.17Z"/>
25
+ </g>
26
+ <path d="M25.37,18.54h0c-1.03,0-2,.4-2.73,1.13-.74.74-1.15,1.72-1.15,2.77v58.42c0,2.14,1.75,3.89,3.9,3.9,9.07.02,24.26,1.91,34.75,12.88v-61.14c0-.73-.19-1.41-.54-1.97-8.6-13.85-25.14-15.96-34.23-15.98Z"/>
27
+ <path d="M104.82,80.86V22.43c0-1.05-.41-2.03-1.15-2.77-.73-.73-1.7-1.13-2.73-1.13h0c-9.09.02-25.62,2.13-34.23,15.98-.35.56-.54,1.25-.54,1.97v61.14c10.48-10.97,25.68-12.86,34.75-12.88,2.15,0,3.9-1.75,3.9-3.89Z"/>
28
+ <path class="st2" d="M113.7,32.01h-2.83v48.85c0,5.47-4.46,9.93-9.94,9.94-7.69.02-20.38,1.52-29.36,10.02,15.54-3.8,31.91-1.33,41.25.8,1.17.27,2.37,0,3.3-.75.93-.74,1.46-1.85,1.46-3.04v-61.92c0-2.15-1.75-3.9-3.9-3.9Z"/>
29
+ <path class="st2" d="M15.43,80.86v-48.85h-2.83c-2.15,0-3.9,1.75-3.9,3.9v61.92c0,1.19.53,2.3,1.46,3.04.93.74,2.14,1.02,3.3.75,9.33-2.13,25.71-4.6,41.25-.8-8.98-8.5-21.67-10.01-29.36-10.02-5.48-.01-9.94-4.47-9.94-9.94Z"/>
30
+ <g>
31
+ <path class="st2" d="M131.67,55.5c2.63-.55,6.77-.97,10.99-.97,6.01,0,9.88,1.04,12.78,3.39,2.42,1.8,3.87,4.56,3.87,8.22,0,4.49-2.97,8.43-7.88,10.23v.14c4.42,1.11,9.6,4.77,9.6,11.68,0,4.01-1.59,7.05-3.94,9.33-3.25,2.97-8.5,4.35-16.1,4.35-4.15,0-7.32-.28-9.33-.55v-45.81ZM137.68,74.57h5.46c6.36,0,10.09-3.32,10.09-7.81,0-5.46-4.15-7.6-10.23-7.6-2.76,0-4.35.21-5.32.41v14.99ZM137.68,96.89c1.17.21,2.9.28,5.04.28,6.22,0,11.95-2.28,11.95-9.05,0-6.36-5.46-8.98-12.02-8.98h-4.97v17.76Z"/>
32
+ <path class="st2" d="M206.14,66.9c-9.26,0-16.58,6.56-16.58,17.75,0,.12.01.24.01.36h-.04c-.27,7.16-4.51,12.28-10.13,12.28-5.94,0-10.16-5.53-10.16-12.85,0-6.36,3.11-12.99,10.29-12.99,4.29,0,7.02,2.57,8.53,5.85.79-2.15,1.94-3.99,3.37-5.51-2.83-3.1-6.89-4.9-11.76-4.9-9.26,0-16.58,6.56-16.58,17.75,0,10.57,6.98,17.2,16.03,17.2,7.88,0,16.19-5.14,16.61-16.84h0c0-.19-.02-.38-.02-.57,0-6.36,3.11-12.99,10.29-12.99s10.02,7.19,10.02,12.85c0,7.53-4.35,12.99-10.15,12.99-4.05,0-7.29-2.57-8.94-6.51-.75,2.31-1.89,4.24-3.29,5.8,2.9,3.37,7.13,5.27,11.96,5.27,8.08,0,16.65-5.39,16.65-17.75,0-10.22-6.49-17.2-16.1-17.2Z"/>
33
+ <path class="st2" d="M230.65,83.76h.14c.83-1.17,2-2.63,2.97-3.8l9.81-11.54h7.32l-12.92,13.75,14.72,19.69h-7.39l-11.54-16.03-3.11,3.45v12.57h-6.01v-49.06h6.01v30.95Z"/>
34
+ </g>
35
+ </svg>
@@ -0,0 +1,3 @@
1
+ import type { Specification } from "./specbook-struct-spec.js";
2
+ import type { SchemaSpecification } from "./specbook-struct-schema.js";
3
+ export declare const renderMarkdown: (specification: Specification, config?: SchemaSpecification) => Promise<string>;
@@ -0,0 +1,74 @@
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 { specDiagrams } from "./specbook-diagram.js";
7
+ /* format a timestamp in the frontmatter format */
8
+ const formatTimestamp = (date) => {
9
+ const pad = (n) => String(n).padStart(2, "0");
10
+ return `${date.getFullYear()}-${pad(date.getMonth() + 1)}-${pad(date.getDate())} ` +
11
+ `${pad(date.getHours())}:${pad(date.getMinutes())}`;
12
+ };
13
+ /* render the properties as a value-aligned key/value list */
14
+ const renderKeyValuesMd = (properties) => {
15
+ const width = Math.max(...properties.map((property) => property.key.length)) + 1;
16
+ return properties.map((property) => `- ${`${property.key}:`.padEnd(width)} ${property.value}`).join("\n");
17
+ };
18
+ /* render a description statement with its optional rationale */
19
+ const renderDescriptionMd = (description) => description.description + (description.rationale !== undefined ?
20
+ `, BECAUSE ${description.rationale}` : "");
21
+ /* render the "(*)" primary marker plus the implicit "(xxx)"
22
+ and/or explicit "{{xxx}}" anchor suffix */
23
+ const nameSuffixMd = (object) => (object.primary === true ? " (*)" : "") +
24
+ (object.paren !== undefined ? ` (${object.paren})` : "") +
25
+ (object.anchor !== undefined ? ` {{${object.anchor}}}` : "");
26
+ /* render an object in the Concise Format (level 4 and deeper),
27
+ with its childs as nested list items */
28
+ const renderConciseMd = (object, level) => {
29
+ const indent = " ".repeat((level - 4) * 4);
30
+ const segments = [`${object.kind}: ${object.name}${nameSuffixMd(object)}`];
31
+ segments.push(...object.properties.map((property) => `${property.key}: ${property.value}`));
32
+ if (object.description !== undefined)
33
+ segments.push(renderDescriptionMd(object.description));
34
+ const item = `${indent}- ${segments.join("; ").replace(/\.?$/, ".")}`;
35
+ return [item, ...object.childs.map((child) => renderConciseMd(child, level + 1))].join("\n");
36
+ };
37
+ /* render an object in the Complex Format (levels 1-3),
38
+ with childs from level 4 upwards in the Concise Format
39
+ and the optionally derived Gradia diagram spec embedded
40
+ as a "gradia" fenced code block below the heading */
41
+ const renderObjectMd = (object, level, diagrams) => {
42
+ const heading = level === 1 ?
43
+ `# ${object.kind}: ${object.name}${nameSuffixMd(object)}` :
44
+ `${"#".repeat(level)}${" ".repeat(4 - level)}${object.kind}: ${object.name}${nameSuffixMd(object)}`;
45
+ const parts = [heading];
46
+ const spec = diagrams?.get(object);
47
+ if (spec !== undefined)
48
+ parts.push(`\`\`\`gradia\n${spec}\`\`\``);
49
+ if (object.properties.length > 0)
50
+ parts.push(renderKeyValuesMd(object.properties));
51
+ if (object.description !== undefined)
52
+ parts.push(renderDescriptionMd(object.description));
53
+ if (level >= 3)
54
+ parts.push(object.childs.map((child) => renderConciseMd(child, level + 1)).join("\n"));
55
+ else
56
+ parts.push(...object.childs.map((child) => renderObjectMd(child, level + 1, diagrams)));
57
+ return parts.filter((part) => part !== "").join("\n\n");
58
+ };
59
+ /* render the entire specification into normalized Markdown */
60
+ export const renderMarkdown = async (specification, config) => {
61
+ /* derive the Gradia specs of the diagram-configured objects
62
+ (an invalid diagram situation omits the diagram, as it is
63
+ already reported as a lint diagnostic) */
64
+ const diagrams = new Map();
65
+ if (config !== undefined)
66
+ for (const [object, result] of specDiagrams(specification, config))
67
+ if (result.spec !== undefined)
68
+ diagrams.set(object, result.spec);
69
+ return specification.artifacts.map((artifact) => artifact.objects.map((object) => "---\n" +
70
+ `Created: ${formatTimestamp(artifact.created)}\n` +
71
+ `Modified: ${formatTimestamp(artifact.modified)}\n` +
72
+ "---\n\n" +
73
+ renderObjectMd(object, 1, diagrams)).join("\n\n")).join("\n\n") + "\n";
74
+ };
@@ -0,0 +1,7 @@
1
+ import type { OutlineEntry } from "./specbook-export-html.js";
2
+ import type { ThemeMapping } from "./specbook-theme.js";
3
+ export declare const htmlToPdf: (renderHtmlPass: (tocPages?: Map<string, number>) => Promise<string>, heading: {
4
+ title: string;
5
+ subtitle?: string;
6
+ logo: string;
7
+ }, outline: OutlineEntry[], verbose: (msg: string) => void, css: string, theme: ThemeMapping, paper?: string) => Promise<Buffer>;