@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.
Files changed (50) hide show
  1. package/README.md +9 -2
  2. package/dst/specbook-api.d.ts +5 -1
  3. package/dst/specbook-api.js +23 -9
  4. package/dst/specbook-cli.js +8 -5
  5. package/dst/specbook-cmd-export.js +2 -2
  6. package/dst/specbook-cmd-init.js +16 -2
  7. package/dst/specbook-coverage.js +1 -1
  8. package/dst/specbook-diagram.d.ts +1 -1
  9. package/dst/specbook-diagram.js +12 -10
  10. package/dst/specbook-export-ast.d.ts +1 -1
  11. package/dst/specbook-export-ast.js +21 -4
  12. package/dst/specbook-export-code.d.ts +14 -0
  13. package/dst/specbook-export-code.js +155 -0
  14. package/dst/specbook-export-common.d.ts +8 -2
  15. package/dst/specbook-export-common.js +126 -6
  16. package/dst/specbook-export-diagram.d.ts +15 -0
  17. package/dst/specbook-export-diagram.js +211 -0
  18. package/dst/specbook-export-html-fold.d.ts +20 -0
  19. package/dst/specbook-export-html-fold.js +458 -0
  20. package/dst/specbook-export-html-render.d.ts +51 -0
  21. package/dst/specbook-export-html-render.js +748 -0
  22. package/dst/specbook-export-html-search.js +6 -5
  23. package/dst/specbook-export-html-template.d.ts +21 -0
  24. package/dst/specbook-export-html-template.js +282 -0
  25. package/dst/specbook-export-html-widget.d.ts +12 -0
  26. package/dst/specbook-export-html-widget.js +605 -0
  27. package/dst/specbook-export-html.css +325 -50
  28. package/dst/specbook-export-html.js +62 -1931
  29. package/dst/specbook-export-image.d.ts +2 -0
  30. package/dst/specbook-export-image.js +161 -9
  31. package/dst/specbook-export-md.js +60 -12
  32. package/dst/specbook-format-schema.d.ts +1 -1
  33. package/dst/specbook-format-schema.js +1 -1
  34. package/dst/specbook-format-spec.d.ts +1 -0
  35. package/dst/specbook-format-spec.js +1 -0
  36. package/dst/specbook-format.md +173 -19
  37. package/dst/specbook-format.yaml +265 -24
  38. package/dst/specbook-ignore.js +2 -0
  39. package/dst/specbook-link.js +21 -5
  40. package/dst/specbook-mcp.js +9 -5
  41. package/dst/specbook-parse-common.d.ts +24 -0
  42. package/dst/specbook-parse-common.js +137 -10
  43. package/dst/specbook-parse-semantic.js +2 -0
  44. package/dst/specbook-parse-syntax.js +81 -20
  45. package/dst/specbook-parse.js +1 -1
  46. package/dst/specbook-project.d.ts +1 -0
  47. package/dst/specbook-project.js +10 -1
  48. package/dst/specbook-theme.d.ts +1 -0
  49. package/dst/specbook-theme.js +2 -0
  50. package/package.json +18 -12
@@ -0,0 +1,748 @@
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 { resolveUnique, expandReferences } from "./specbook-link.js";
9
+ import { compileValueExpr, splitItems } from "./specbook-parse-value.js";
10
+ import { embeddingRegex, embeddingCount, embeddingThemes, darkMark } from "./specbook-parse-common.js";
11
+ import { escapeHtml, fallbackLogo, imageDark, isTitleObject, titleProperties } from "./specbook-export-common.js";
12
+ import { isDocument } from "./specbook-export-image.js";
13
+ import { embeddedSource, fenceLanguage, diagramKey, omittedDiagram } from "./specbook-export-diagram.js";
14
+ import { embeddedListing, fencedListing } from "./specbook-export-code.js";
15
+ import { coverageRatio } from "./specbook-coverage.js";
16
+ import { safe, render } from "./specbook-export-html-template.js";
17
+ import { omitLong } from "./specbook-export-html-fold.js";
18
+ /* ==== Rendering ==== */
19
+ /* the per-language double quote styles for the smart typography
20
+ (single quotes stay curly, as smartypants cannot distinguish a
21
+ closing single quote from an apostrophe) */
22
+ const quoteStyles = {
23
+ "en": ["&#8220;", "&#8221;"], /* “...” */
24
+ "nl": ["&#8220;", "&#8221;"], /* “...” */
25
+ "de": ["&#8222;", "&#8220;"], /* „...“ */
26
+ "cs": ["&#8222;", "&#8220;"], /* „...“ */
27
+ "sk": ["&#8222;", "&#8220;"], /* „...“ */
28
+ "pl": ["&#8222;", "&#8221;"], /* „...” */
29
+ "fr": ["&#171;&#160;", "&#160;&#187;"], /* « ... » */
30
+ "it": ["&#171;", "&#187;"], /* «...» */
31
+ "es": ["&#171;", "&#187;"], /* «...» */
32
+ "pt": ["&#171;", "&#187;"], /* «...» */
33
+ "no": ["&#171;", "&#187;"], /* «...» */
34
+ "da": ["&#187;", "&#171;"], /* »...« */
35
+ "sv": ["&#8221;", "&#8221;"], /* ”...” */
36
+ "fi": ["&#8221;", "&#8221;"] /* ”...” */
37
+ };
38
+ /* the active double quote style (set per document during rendering) */
39
+ let quotes = quoteStyles.en;
40
+ /* the private Markdown renderer instance, as the hooks below must not
41
+ leak into the "marked" singleton shared with the parser and with the
42
+ application embedding SpecBook as a library */
43
+ const marked = new Marked();
44
+ /* improve the typography of all rendered Markdown text (curly quotes,
45
+ "--" as the em dash, ellipsis); as smartypants requires unescaped text,
46
+ the extension switches off the text escaping of marked, so the stray
47
+ ampersands left behind have to be re-escaped afterwards; the English
48
+ double quotes of smartypants are remapped to the document language;
49
+ as marked runs later-registered pass-through hooks first, the
50
+ post-processing hook has to be registered before smartypants */
51
+ marked.use({ hooks: { postprocess: (html) => html.replace(/&#8220;/g, quotes[0]).replace(/&#8221;/g, quotes[1])
52
+ .replace(/&(?![a-zA-Z][a-zA-Z0-9]*;|#\d+;|#x[0-9a-fA-F]+;)/g, "&amp;") } });
53
+ marked.use(markedSmartypants({ config: 1 }));
54
+ /* the switched-off text escaping of marked also leaves the angle
55
+ brackets of the inline text unescaped, which a browser then takes
56
+ for tags ("Map<String, Object>"), so they are escaped upfront in
57
+ the inline text tokenizer (registered after smartypants, as the
58
+ later-registered tokenizer takes precedence) */
59
+ marked.use({ tokenizer: { inlineText(src) {
60
+ const cap = this.rules.inline.text.exec(src);
61
+ if (cap === null)
62
+ return undefined;
63
+ return { type: "text", raw: cap[0], escaped: true,
64
+ text: cap[0].replace(/</g, "&lt;").replace(/>/g, "&gt;") };
65
+ } } });
66
+ /* the active per-document reference expander, fully-qualified
67
+ anchor paths, member-carrying property value constraints, object
68
+ schema nodes, pre-rendered diagram blocks, optimized embedded images,
69
+ dark theme judgements of the images, pre-rendered embedded diagrams,
70
+ code listing renderer, object nesting levels, reference coverages,
71
+ description popup keys of the schema nodes, description popup keys
72
+ of the objects, and omitted aspects (all set by "withDocument") */
73
+ let linker = null;
74
+ let anchors = null;
75
+ let members = null;
76
+ let schemas = null;
77
+ let diagrams = null;
78
+ let images = null;
79
+ let rasters = null;
80
+ let embedded = null;
81
+ let listings = null;
82
+ let levels = null;
83
+ let coverages = null;
84
+ let infoKeys = null;
85
+ let infoObjects = null;
86
+ let omits = null;
87
+ /* establish the per-document rendering state around the rendering of a
88
+ document and release it again afterwards (also on a rendering failure),
89
+ as it would otherwise retain the specification (and its embedded
90
+ images) until the next rendering */
91
+ export const withDocument = (state, body) => {
92
+ quotes = quoteStyles[state.lang?.toLowerCase().split(/[-_]/)[0] ?? "en"] ?? quoteStyles.en;
93
+ linker = state.linker;
94
+ anchors = state.anchors;
95
+ members = state.members;
96
+ schemas = state.schemas;
97
+ diagrams = state.diagrams;
98
+ images = state.images;
99
+ rasters = state.rasters;
100
+ embedded = state.embedded;
101
+ listings = state.listings;
102
+ levels = state.levels;
103
+ coverages = state.coverages;
104
+ infoKeys = state.infoKeys;
105
+ infoObjects = state.infoObjects;
106
+ omits = state.omits;
107
+ try {
108
+ return body();
109
+ }
110
+ finally {
111
+ linker = null;
112
+ anchors = null;
113
+ members = null;
114
+ schemas = null;
115
+ diagrams = null;
116
+ images = null;
117
+ rasters = null;
118
+ embedded = null;
119
+ listings = null;
120
+ levels = null;
121
+ coverages = null;
122
+ infoKeys = null;
123
+ infoObjects = null;
124
+ omits = null;
125
+ }
126
+ };
127
+ /* the object whose texts are currently rendered, scoping the
128
+ resolution of the references inside them (nearest object wins),
129
+ established around every object rendering and restored afterwards */
130
+ let scope = null;
131
+ const scoped = (object, body) => {
132
+ const outer = scope;
133
+ scope = object;
134
+ try {
135
+ return body();
136
+ }
137
+ finally {
138
+ scope = outer;
139
+ }
140
+ };
141
+ /* determine the fully-qualified anchor path of an object */
142
+ const anchorOf = (object) => anchors?.get(object) ?? object.id;
143
+ /* determine the schema description popup key of an object (the
144
+ INFO table index of its schema node) */
145
+ const infoKeyOf = (object) => {
146
+ const schema = schemas?.get(object);
147
+ return schema !== undefined ? infoKeys?.get(schema) : undefined;
148
+ };
149
+ /* determine the corpus description popup key of an object (its SPEC
150
+ table index), which a schema popup references for its title path,
151
+ too: "named = false" keeps the last path segment kind-only there,
152
+ as the kind popups (and the group headers of the compact tables)
153
+ describe the object class instead of the single instance */
154
+ const infoRefOf = (object, named = true) => {
155
+ const key = infoObjects?.get(object);
156
+ return key !== undefined ? key + (named ? "" : "^") : undefined;
157
+ };
158
+ /* render the schema description popup key and kind-ending title path
159
+ reference of an object as "data-info" attributes for the manually
160
+ assembled hyperlink markup (the kind popup of the full hyperlinks) */
161
+ const infoAttr = (object) => {
162
+ const key = infoKeyOf(object);
163
+ const ref = infoRefOf(object, false);
164
+ return key !== undefined && ref !== undefined ?
165
+ ` data-info="${key}" data-info-path="${ref}"` : "";
166
+ };
167
+ /* render the corpus description popup key of an object as the
168
+ "data-info-spec" attribute for the manually assembled hyperlink
169
+ markup (the instance popup of the compact and full hyperlinks) */
170
+ const specAttr = (object) => {
171
+ const ref = infoRefOf(object);
172
+ return ref !== undefined ? ` data-info-spec="${ref}"` : "";
173
+ };
174
+ /* create the reference expander of a document: "[[xxx]]" references
175
+ expand into hyperlinks (an unresolvable or ambiguous reference stays
176
+ literal, marked as broken), resolved from the object currently
177
+ rendered and targeting the fully-qualified anchor paths of the
178
+ objects: in the full form (kind, name, and link symbol), or in the
179
+ compact form for prose (the object icon and the name only, via CSS
180
+ and markup, with the full form shown by the description popups) */
181
+ export const makeLinker = (index) => (text, compact) => expandReferences(text, (reference) => {
182
+ const target = resolveUnique(index, reference, scope ?? undefined).target;
183
+ if (target === undefined)
184
+ return `<span class="link-broken">[[${escapeHtml(reference)}]]</span>`;
185
+ /* the full form carries the popup attributes on its kind and
186
+ name, with the kind bold in the accent color and the name
187
+ bold in the regular text color (via the stylesheet) */
188
+ const full = `<strong class="object-kind"${infoAttr(target)}>${escapeHtml(target.kind)}:</strong>` +
189
+ ` <span class="object-name"${specAttr(target)}>${target.name}</span>` +
190
+ " <span class=\"link-symbol\">&#x26AD;</span>";
191
+ return compact ?
192
+ `<a href="#${escapeHtml(anchorOf(target))}" class="link-compact"${specAttr(target)}>${target.name}</a>` :
193
+ `<a href="#${escapeHtml(anchorOf(target))}" class="link-full">${full}</a>`;
194
+ });
195
+ /* collect the description popup table of the schema nodes and their
196
+ keys (the table indices), with the description Markdown of the
197
+ objects and properties pre-rendered to HTML */
198
+ export const collectInfo = (nodes, keys, info) => {
199
+ for (const schema of nodes) {
200
+ keys.set(schema, String(info.length));
201
+ const entry = {};
202
+ if (schema.desc !== undefined)
203
+ entry.d = marked.parse(schema.desc, { async: false }).trim();
204
+ for (const property of schema.props ?? []) {
205
+ if (property.desc === undefined)
206
+ continue;
207
+ entry.p ??= {};
208
+ entry.p[property.name] = marked.parse(property.desc, { async: false }).trim();
209
+ }
210
+ info.push(entry);
211
+ collectInfo(schema.children ?? [], keys, info);
212
+ }
213
+ };
214
+ /* expand the inline Markdown of a text (code spans, emphasis, etc.),
215
+ with Wiki-style references expanded upfront (in their compact form
216
+ for prose, i.e. descriptions, in their full form otherwise) */
217
+ const inline = (text, compact = false) => safe(marked.parseInline(linker !== null ? linker(text, compact) : text, { async: false }));
218
+ /* expand the full Markdown of a text, keeping its block-level
219
+ constructs (lists, quotes, code blocks, tables) intact */
220
+ const block = (text, compact = false) => safe(marked.parse(linker !== null ? linker(text, compact) : text, { async: false }));
221
+ /* check whether a text carries any block-level Markdown, i.e. is
222
+ anything other than the single paragraph a description usually is */
223
+ const isBlock = (text) => {
224
+ const tokens = marked.lexer(text).filter((token) => token.type !== "space");
225
+ return tokens.length > 0 && !(tokens.length === 1 && tokens[0].type === "paragraph");
226
+ };
227
+ /* render a single embedded image file (in its optimized form) onto an
228
+ <img> tag with a self-contained data: URL (converting the SVG text
229
+ into one, as an SVG inlined as-is would leak its document-global
230
+ <style> rules into all other inlined SVGs sharing the same class names),
231
+ where an image inverted on the dark theme (as given for a diagram or a
232
+ theme variant, else as the image itself says) is marked by a class */
233
+ const renderImage = (original, alt, dark = imageDark(original, rasters ?? undefined)) => {
234
+ const content = darkMark(images?.get(original) ?? original).content;
235
+ const url = content.startsWith("data:") ? content :
236
+ `data:image/svg+xml;base64,${Buffer.from(content, "utf8").toString("base64")}`;
237
+ return `<img src="${url}"${dark === "invert" ? " class=\"dark-invert\"" : ""} alt="${escapeHtml(alt)}"/>`;
238
+ };
239
+ /* wrap the theme variants of an image into their layout-neutral theme
240
+ containers, of which the stylesheet shows just the one matching the
241
+ color theme currently active in the document */
242
+ const renderThemed = (variants) => variants.map((variant, i) => `<span class="theme-${embeddingThemes[i]}-only">${variant}</span>`).join("");
243
+ /* render an embedded diagram (a fenced code block of a diagram language
244
+ or an embedded diagram source file) into a diagram block like the one
245
+ of a Gradia diagram (of the type "code" and carrying the nesting level
246
+ of the object rendered), so it folds and maximizes alike, with its
247
+ pre-rendered theme variants as images (as an SVG inlined as-is would
248
+ leak its document-global <style> rules); an omitted or failed diagram
249
+ leaves nothing behind */
250
+ const renderEmbedded = (language, source, alt) => {
251
+ const variants = embedded?.get(diagramKey(language, source));
252
+ const level = (scope !== null ? levels?.get(scope) : undefined) ?? 1;
253
+ if (variants === undefined || (omits !== null && omittedDiagram(language, level, omits)))
254
+ return "";
255
+ return `<div class="diagram" data-type="code" data-level="${level}">` +
256
+ renderThemed(embeddingThemes.map((theme) => renderImage(variants[theme], alt, "none"))) + "</div>";
257
+ };
258
+ /* render a code listing (a fenced code block of a known language or an
259
+ embedded source code file) into a diagram block (of the type "listing"
260
+ and carrying the nesting level of the object rendered), so it folds,
261
+ maximizes, and is omitted like a diagram */
262
+ const renderListingBlock = (renderer, listing) => {
263
+ const level = (scope !== null ? levels?.get(scope) : undefined) ?? 1;
264
+ if (omits !== null && omittedDiagram("listing", level, omits))
265
+ return "";
266
+ return `<div class="diagram" data-type="listing" data-level="${level}">${renderer(listing)}</div>`;
267
+ };
268
+ /* render a fenced code block of a diagram language as its embedded
269
+ diagram and one of a known language as its code listing instead of
270
+ as plain code (outside of an HTML rendering, too) */
271
+ marked.use({ renderer: { code(token) {
272
+ const language = fenceLanguage(token.lang);
273
+ if (language !== undefined)
274
+ return renderEmbedded(language, token.text, `${language} diagram`);
275
+ const listing = fencedListing(token);
276
+ return listing !== undefined && listings !== null ? renderListingBlock(listings, listing) : false;
277
+ } } });
278
+ /* strip the fenced code blocks of a diagram language or a code listing
279
+ off a text */
280
+ const stripDiagrams = (text) => marked.lexer(text)
281
+ .filter((token) => !(token.type === "code" && (fenceLanguage(token.lang) !== undefined
282
+ || fencedListing(token) !== undefined)))
283
+ .map((token) => token.raw).join("");
284
+ /* render the embedded image files of a text into HTML, taking the image
285
+ alternate texts from the corresponding "![alt](file)" markups and
286
+ pairing up the consecutive theme variants of a "{theme}" markup, where
287
+ an embedded diagram source file renders as its diagram block, an
288
+ embedded source code file as its code listing block, and an
289
+ image or PDF page (unless unframed, like the title page logo) sits in a
290
+ diagram block of type "image", so it folds, maximizes, and is omitted
291
+ like a diagram (the empty entries of unreadable files, the failed PDF
292
+ pages, and the omitted diagrams and images are skipped) */
293
+ const renderEmbeddings = (text, embedding, framed = true) => {
294
+ const result = new Array();
295
+ const level = (scope !== null ? levels?.get(scope) : undefined) ?? 1;
296
+ const omitted = framed && omits !== null && omittedDiagram("image", level, omits);
297
+ const frame = (html) => framed ?
298
+ `<div class="diagram" data-type="image" data-level="${level}">${html}</div>` : html;
299
+ let i = 0;
300
+ for (const m of text.matchAll(embeddingRegex)) {
301
+ const count = embeddingCount(m[2]);
302
+ if (count === 0)
303
+ continue;
304
+ const contents = embedding.slice(i, i + count).filter((content) => content !== "");
305
+ i += count;
306
+ if (contents.some((content) => embeddedSource(content) !== undefined || embeddedListing(content) !== undefined))
307
+ result.push(...contents.map((content) => {
308
+ const diagram = embeddedSource(content);
309
+ const listing = embeddedListing(content);
310
+ return diagram !== undefined ? renderEmbedded(diagram.language, diagram.source, m[1].trim()) :
311
+ listing !== undefined && listings !== null ? renderListingBlock(listings, listing) : "";
312
+ }).filter((html) => html !== ""));
313
+ else if (!omitted) {
314
+ const variants = contents.filter((content) => !isDocument(content) || images?.has(content) === true)
315
+ .map((content) => renderImage(content, m[1].trim(), count > 1 ? "none" : undefined));
316
+ result.push(...(count > 1 && variants.length === count ? [renderThemed(variants)] : variants).map(frame));
317
+ }
318
+ }
319
+ return result;
320
+ };
321
+ /* the image embedding markup including the horizontal whitespace
322
+ before it, so that its removal leaves no double space behind */
323
+ const embeddingMarkup = new RegExp(`[ \\t]*${embeddingRegex.source}`, "g");
324
+ /* strip the markup of the file embeddings off a description text */
325
+ const stripEmbeddings = (text) => text.replace(embeddingMarkup, (markup, _alt, reference) => embeddingCount(reference) > 0 ? "" : markup).trim();
326
+ /* render a description into HTML, expanding its inline Markdown,
327
+ rendering its elaboration as blocks below the statement and the
328
+ rationale, and moving the file embeddings to the end of it all */
329
+ const renderDescription = (description) => {
330
+ const text = stripEmbeddings(description.description);
331
+ const elaboration = stripEmbeddings(description.elaboration ?? "");
332
+ const embeddings = renderEmbeddings(`${description.description}\n\n${description.elaboration ?? ""}`, description.embedding ?? []).map((content) => safe(content));
333
+ const blocked = isBlock(text);
334
+ return render("Description", { Description: {
335
+ block: blocked,
336
+ description: text !== "" ? (blocked ? block(text, true) : inline(text, true)) : "",
337
+ rationale: description.rationale !== undefined ?
338
+ inline(description.rationale, true) : undefined,
339
+ elaboration: elaboration !== "" ? block(elaboration, true) : undefined,
340
+ embeddings
341
+ } });
342
+ };
343
+ /* collect the description popups of the object instances: the corpus
344
+ description Markdown of every object, pre-rendered to HTML into
345
+ its SPEC table entry (the embedded images and diagrams and the
346
+ rationale are left out, as the popup shows the prose alone) */
347
+ export const collectSpec = (objects, spec) => {
348
+ for (const object of objects) {
349
+ const text = stripEmbeddings(stripDiagrams([object.description?.description ?? "", object.description?.elaboration ?? ""].join("\n\n")));
350
+ const key = infoObjects?.get(object);
351
+ if (key !== undefined && text !== "")
352
+ spec[Number(key)][4] = scoped(object, () => block(text, true).toString().trim());
353
+ collectSpec(object.children, spec);
354
+ }
355
+ };
356
+ /* the literal members of an "enum(...)"/"tags(...)" expression, or
357
+ of the "enum(...)"/"tags(...)" alternatives of a "list(...)" one
358
+ (empty for every other expression) */
359
+ const literalMembers = (expr) => expr.kind === "enum" || expr.kind === "tags" ? expr.members :
360
+ expr.kind === "list" ? expr.alternatives.flatMap(literalMembers) : [];
361
+ /* collect the properties of the schema configuration constrained by
362
+ literal members (an "enum(...)", a "tags(...)", or a "list(...)"
363
+ with such alternatives), keyed by object kind and property name */
364
+ export const collectMembers = (nodes, result) => {
365
+ for (const schema of nodes) {
366
+ for (const property of schema.props ?? []) {
367
+ if (property.value === undefined)
368
+ continue;
369
+ try {
370
+ const expr = compileValueExpr(property.value);
371
+ if (literalMembers(expr).length > 0)
372
+ result.set(`${schema.kind} ${property.name}`, expr);
373
+ }
374
+ catch {
375
+ /* an invalid expression is the concern of lint */
376
+ }
377
+ }
378
+ collectMembers(schema.children ?? [], result);
379
+ }
380
+ return result;
381
+ };
382
+ /* render a property value, badging the individual members of an
383
+ "enum(...)" (a single member) or "tags(...)" (a member set) value
384
+ and the literal member items of a "list(...)" value (the other items,
385
+ like references, stay prose), moving its image embeddings behind the
386
+ value, rendered from the embedded image contents (as for a
387
+ description), not from the markup; an absent property renders as the
388
+ marker telling it apart from a property given with an empty value */
389
+ const inlineValue = (kind, property) => {
390
+ if (property === undefined)
391
+ return safe("<span class=\"value-absent\"></span>");
392
+ const { key, value, embedding } = property;
393
+ const expr = members?.get(`${kind} ${key}`);
394
+ const text = stripEmbeddings(value);
395
+ const embeddings = renderEmbeddings(value, embedding ?? [])
396
+ .map((content) => `<div class="embedding">${content}</div>`).join("");
397
+ if (expr === undefined || text === "")
398
+ return safe(`${inline(text)}${embeddings}`);
399
+ const badge = (item) => `<span class="value-member">${inline(item)}</span>`;
400
+ if (expr.kind === "enum")
401
+ return safe(badge(text) + embeddings);
402
+ const items = splitItems(text).filter((item) => item !== "");
403
+ if (expr.kind === "tags")
404
+ return safe(items.map(badge).join(" ") + embeddings);
405
+ const literals = new Set(literalMembers(expr));
406
+ return safe(items.map((item) => literals.has(item) ? badge(item) : inline(item)).join(", ") + embeddings);
407
+ };
408
+ /* expand the inline Markdown of the property values, with the
409
+ entries injected for unused properties (foreign to the object)
410
+ rendered as absent */
411
+ const inlineProperties = (object, properties) => properties.map((property) => ({ key: property.key,
412
+ info: infoKeyOf(object), infopath: infoRefOf(object),
413
+ value: inlineValue(object.kind, object.properties.includes(property) ? property : undefined) }));
414
+ /* resolve the format configuration of the kind of an object */
415
+ const formatOf = (object) => schemas?.get(object)?.format;
416
+ /* determine the maximum table columns configured on the kind of an
417
+ object (at least two, as a single column cannot carry a name and a
418
+ value) */
419
+ const maxColumnsOf = (object) => Math.max(2, formatOf(object)?.maxTableColumns ?? 4);
420
+ /* determine the effective properties of an object in schema order
421
+ (unknown keys appended in document order), with "withUnusedProps"
422
+ injecting the defined but still unused schema properties as empty
423
+ key/value entries */
424
+ const effectiveProperties = (object) => {
425
+ const schema = schemas?.get(object);
426
+ if (schema === undefined)
427
+ return object.properties;
428
+ const unused = schema.format?.withUnusedProps === true;
429
+ const merged = (schema.props ?? []).flatMap((prop) => {
430
+ const present = object.properties.filter((property) => property.key === prop.name);
431
+ return present.length > 0 ? present : (unused ? [{ key: prop.name, value: "" }] : []);
432
+ });
433
+ return [...merged, ...object.properties.filter((property) => !merged.includes(property))];
434
+ };
435
+ /* determine the column shape of a potential compact table in schema
436
+ order (unknown keys appended in occurrence order), with
437
+ "withUnusedProps" injecting the defined but still unused schema
438
+ properties as additional columns */
439
+ const tableShape = (children) => {
440
+ const used = [...new Set(children.flatMap((child) => child.properties.map((property) => property.key)))];
441
+ const schema = schemas?.get(children[0]);
442
+ const names = (schema?.props ?? []).map((prop) => prop.name);
443
+ const known = schema?.format?.withUnusedProps === true ?
444
+ names : names.filter((name) => used.includes(name));
445
+ const keys = [...known, ...used.filter((key) => !names.includes(key))];
446
+ return { keys, desc: children.some((child) => child.description !== undefined || child.children.length > 0) };
447
+ };
448
+ /* provide the children of an object taking part in the regular document
449
+ flow: a nested title object leaves the flow, as it is rendered as the
450
+ title page instead (a top-level one instead removes its whole artifact) */
451
+ export const flowChildren = (object) => object.children.filter((child) => !isTitleObject(child));
452
+ /* decide whether a single-kind group of children collapses into the
453
+ concise or compact (tabular) rendering: an explicit "format" type of
454
+ the kind wins, "auto" collapses the deepest level only, and inside an
455
+ already concise rendering context "auto" groups implicitly stay concise, too */
456
+ export const conciseGroup = (group, schemaMap, concise) => {
457
+ const type = schemaMap?.get(group[0])?.format?.type ?? "auto";
458
+ if (type !== "auto")
459
+ return type !== "complex";
460
+ return concise || group.every((child) => child.children.length === 0);
461
+ };
462
+ /* group the children of an object by their kind, preserving order */
463
+ export const groupChildren = (children) => {
464
+ const groups = new Map();
465
+ for (const child of children) {
466
+ const group = groups.get(child.kind);
467
+ if (group === undefined)
468
+ groups.set(child.kind, [child]);
469
+ else
470
+ group.push(child);
471
+ }
472
+ return [...groups.values()];
473
+ };
474
+ /* render the children of an object group-wise by kind: a concise group
475
+ as one compact table (a "compact" one with a single content column),
476
+ a complex group as regular nested object renderings */
477
+ const renderChildren = (object, level, concise) => groupChildren(flowChildren(object)).map((group) => !conciseGroup(group, schemas, concise) ?
478
+ group.map((child) => renderObject(child, level, concise)).join("") :
479
+ formatOf(group[0])?.type === "compact" ?
480
+ renderCompact(group) :
481
+ renderTable(group, maxColumnsOf(group[0]))).join("");
482
+ /* render the description cell of a table row: the description of the
483
+ object followed by its recursively rendered children (implicitly or
484
+ explicitly concise groups as nested sub-tables, explicitly complex
485
+ ones as regular nested object renderings pressed into the cell);
486
+ a cell left without any content renders as the absent marker,
487
+ exactly like a property cell of a not given property */
488
+ const renderCell = (child) => {
489
+ let html = child.description !== undefined ? renderDescription(child.description) : "";
490
+ html += renderChildren(child, 6, true);
491
+ return html.trim() !== "" ? html : "<span class=\"value-absent\"></span>";
492
+ };
493
+ /* the pre-rendered diagram of an object as an embeddable block
494
+ (empty for an object without a configured or renderable diagram) */
495
+ const diagramOf = (object) => {
496
+ const diagram = diagrams?.get(object);
497
+ return diagram !== undefined ? safe(diagram) : "";
498
+ };
499
+ /* the reference coverage of an object as a table: one row per
500
+ configured pattern, labeled by the kinds of the matching objects,
501
+ marked as object kinds and carrying the description popup of the
502
+ first matching object of each kind (by the bare pattern, if none
503
+ matches), with the counts and the ratio as a bar (empty for an
504
+ object without a configured coverage) */
505
+ const coverageOf = (object) => {
506
+ const coverage = coverages?.get(object);
507
+ if (coverage === undefined)
508
+ return "";
509
+ const kindLabel = (entry, kind) => {
510
+ const first = [...entry.covered, ...entry.uncovered].find((target) => target.kind === kind);
511
+ return `<span class="object-kind"${first !== undefined ? infoAttr(first) : ""}>${escapeHtml(kind)}</span>`;
512
+ };
513
+ return safe(render("Coverage", { Coverage: coverage.map((entry, i) => ({
514
+ label: entry.kinds.length > 0 ?
515
+ safe(entry.kinds.map((kind) => kindLabel(entry, kind)).join(", ")) :
516
+ entry.pattern.replace(/^\[\[(.*)\]\]$/s, "$1"),
517
+ covered: entry.covered.length,
518
+ total: entry.covered.length + entry.uncovered.length,
519
+ ratio: coverageRatio(entry.covered.length, entry.covered.length + entry.uncovered.length),
520
+ even: i % 2 === 1
521
+ })) }));
522
+ };
523
+ /* render a single-kind group of children into one compact table:
524
+ the name first, then the property columns, then the description;
525
+ a group wider than maxColumns, or carrying diagrams, instead chunks
526
+ the property and description cells (and the leading diagram) of
527
+ every object into an embedded per-object table, as a diagram is
528
+ identifiable only under a header of its own, not under the column
529
+ headers of a plain table */
530
+ const renderTable = (children, maxColumns) => {
531
+ const { keys, desc } = tableShape(children);
532
+ const diagrammed = children.some((child) => diagrams?.has(child) === true);
533
+ const fold = formatOf(children[0])?.maxCellHeight;
534
+ if (!diagrammed && 1 + keys.length + (desc ? 1 : 0) <= maxColumns)
535
+ return render("Table", { Table: {
536
+ head: children[0].kind !== "" ? children[0].kind : "Name",
537
+ info: infoKeyOf(children[0]),
538
+ infopath: infoRefOf(children[0], false),
539
+ keys,
540
+ desc,
541
+ fold,
542
+ /* under the fixed table layout the name column takes a fixed
543
+ 20% (via CSS) and the description column claims twice the
544
+ share of a property column of the remaining 80% */
545
+ width: Math.round(1600 / (keys.length + 2)) / 10,
546
+ rows: children.map((child, i) => scoped(child, () => {
547
+ const values = keys.map((key) => inlineValue(child.kind, child.properties.find((property) => property.key === key)));
548
+ const shares = desc ? [...keys.map(() => 1), 2] : keys.map(() => 1);
549
+ const cells = omitLong(omits, desc ? [...values, safe(renderCell(child))] : values, shares.map((share) => 0.8 * share / (keys.length + (desc ? 2 : 0))), fold);
550
+ return {
551
+ id: anchorOf(child),
552
+ anchor: child.anchor,
553
+ paren: child.paren,
554
+ primary: child.primary,
555
+ spec: infoRefOf(child),
556
+ name: inline(child.name),
557
+ even: i % 2 === 1,
558
+ values: cells.slice(0, keys.length),
559
+ description: cells[keys.length]
560
+ };
561
+ }))
562
+ } });
563
+ /* the embedded rows hold at most maxColumns - 1 cells (of the
564
+ group-wide union of property keys, plus the trailing description),
565
+ with the last cell spanning the leftover columns of the final row,
566
+ all of them sharing the 80% beside the fixed 20% name column */
567
+ const size = Math.max(1, maxColumns - 1);
568
+ return render("TableChunked", { Table: {
569
+ head: children[0].kind !== "" ? children[0].kind : "Name",
570
+ info: infoKeyOf(children[0]),
571
+ infopath: infoRefOf(children[0], false),
572
+ desc,
573
+ fold,
574
+ rows: children.map((child, i) => scoped(child, () => {
575
+ const cells = keys.map((key) => ({ key, desc: false, span: 1,
576
+ value: inlineValue(child.kind, child.properties.find((property) => property.key === key)) }));
577
+ if (desc)
578
+ cells.push({ key: "Description", desc: true, span: 1,
579
+ value: safe(renderCell(child)) });
580
+ const chunks = new Array();
581
+ for (let pos = 0; pos < cells.length; pos += size)
582
+ chunks.push(cells.slice(pos, pos + size));
583
+ if (chunks.length > 0) {
584
+ const last = chunks[chunks.length - 1];
585
+ last[last.length - 1].span = size - last.length + 1;
586
+ }
587
+ for (const chunk of chunks)
588
+ omitLong(omits, chunk.map((cell) => cell.value), chunk.map((cell) => 0.8 * cell.span / size), fold)
589
+ .forEach((value, k) => { chunk[k].value = value; });
590
+ /* the diagram leads the chunks as a full-width chunk of its
591
+ own, headed like the description */
592
+ const diagram = diagramOf(child);
593
+ if (diagram !== "")
594
+ chunks.unshift([{ key: "Diagram", desc: true, span: size, value: diagram }]);
595
+ return {
596
+ id: anchorOf(child),
597
+ anchor: child.anchor,
598
+ paren: child.paren,
599
+ primary: child.primary,
600
+ spec: infoRefOf(child),
601
+ name: inline(child.name),
602
+ even: i % 2 === 1,
603
+ chunks
604
+ };
605
+ }))
606
+ } });
607
+ };
608
+ /* render a single-kind group of children into one compact table of the
609
+ "compact" format: the name first, then a single cell rendering the
610
+ object like a complex one, but without its heading (diagram,
611
+ key/value properties, description, coverage, and children), for the
612
+ objects whose many properties a table row could not hold, the first
613
+ three led by sub-headers like the chunk headers of "TableChunked" */
614
+ const renderCompact = (children) => {
615
+ const { keys, desc } = tableShape(children);
616
+ const head = (label, html) => html.trim() !== "" ? `<div class="compact-head">${label}</div>${html}` : "";
617
+ return render("TableCompact", { Table: {
618
+ head: children[0].kind !== "" ? children[0].kind : "Name",
619
+ info: infoKeyOf(children[0]),
620
+ infopath: infoRefOf(children[0], false),
621
+ label: keys.length > 0 && desc ? "Properties & Description" :
622
+ (desc ? "Description" : "Properties"),
623
+ fold: formatOf(children[0])?.maxCellHeight,
624
+ rows: children.map((child, i) => scoped(child, () => {
625
+ const properties = effectiveProperties(child);
626
+ let html = head("Diagram", diagramOf(child).toString());
627
+ if (properties.length > 0)
628
+ html += head("Properties", render("Properties", { Properties: inlineProperties(child, properties),
629
+ Fold: formatOf(child)?.maxCellHeight }));
630
+ if (child.description !== undefined)
631
+ html += head("Description", renderDescription(child.description));
632
+ html += coverageOf(child).toString();
633
+ html += renderChildren(child, 6, true);
634
+ return {
635
+ id: anchorOf(child),
636
+ anchor: child.anchor,
637
+ paren: child.paren,
638
+ primary: child.primary,
639
+ spec: infoRefOf(child),
640
+ name: inline(child.name),
641
+ even: i % 2 === 1,
642
+ content: safe(html.trim() !== "" ? html : "<span class=\"value-absent\"></span>")
643
+ };
644
+ }))
645
+ } });
646
+ };
647
+ /* recursively render an object into HTML */
648
+ const renderObject = (object, level, concise) => {
649
+ const properties = effectiveProperties(object);
650
+ return scoped(object, () => render("Object", { Object: {
651
+ level: Math.min(level, 6),
652
+ kind: object.kind,
653
+ info: infoKeyOf(object),
654
+ infopath: infoRefOf(object, false),
655
+ spec: infoRefOf(object),
656
+ id: anchorOf(object),
657
+ anchor: object.anchor,
658
+ paren: object.paren,
659
+ primary: object.primary,
660
+ name: inline(object.name),
661
+ diagram: diagramOf(object),
662
+ properties: properties.length > 0 ?
663
+ safe(render("Properties", { Properties: inlineProperties(object, properties),
664
+ Fold: formatOf(object)?.maxCellHeight })) : "",
665
+ description: object.description !== undefined ?
666
+ safe(renderDescription(object.description)) : "",
667
+ coverage: coverageOf(object),
668
+ children: safe(renderChildren(object, level + 1, concise))
669
+ } }));
670
+ };
671
+ /* format a timestamp as its calendar date in local time (the
672
+ frontmatter timestamps are parsed in local time, too, so the UTC
673
+ date of toISOString() could shift the day) */
674
+ export const formatDate = (date) => `${date.getFullYear()}-${String(date.getMonth() + 1).padStart(2, "0")}-` +
675
+ String(date.getDate()).padStart(2, "0");
676
+ /* render the title object into a title page */
677
+ export const renderTitlePage = (object, created, modified) => {
678
+ const prop = (name) => object.properties.find((property) => property.key === name)?.value;
679
+ const inlineProp = (name) => {
680
+ const value = prop(name);
681
+ return value !== undefined && value.trim() !== "" ? inline(value) : "";
682
+ };
683
+ const rest = object.properties.filter((property) => !titleProperties.some((prop) => prop.name === property.key));
684
+ /* the logo is rendered above the title, from the embedded image content
685
+ of the LOGO property or, for a non-embeddable reference, as its inline
686
+ Markdown; without a (non-empty) LOGO property the built-in SpecBook
687
+ logo is used, in both its theme variants */
688
+ const logo = object.properties.find((property) => property.key === "LOGO" && property.value.trim() !== "");
689
+ const image = logo !== undefined ? renderEmbeddings(logo.value, logo.embedding ?? [], false) : [];
690
+ return scoped(object, () => render("TitlePage", { TitlePage: {
691
+ logo: logo === undefined ?
692
+ safe(renderThemed(embeddingThemes.map((theme) => `<img src="${fallbackLogo(theme)}" alt="SpecBook"/>`))) :
693
+ (image.length > 0 ? safe(image.join("")) : inline(logo.value)),
694
+ title: inline(prop("TITLE") ?? object.name),
695
+ subtitle: inlineProp("SUBTITLE"),
696
+ author: inlineProp("AUTHOR"),
697
+ version: inlineProp("VERSION"),
698
+ description: object.description !== undefined ?
699
+ safe(renderDescription(object.description)) : "",
700
+ properties: rest.length > 0 ?
701
+ safe(render("Properties", { Properties: inlineProperties(object, rest) })) : "",
702
+ created, modified
703
+ } }));
704
+ };
705
+ /* render an artifact into HTML */
706
+ export const renderArtifact = (artifact) => render("Artifact", { Artifact: {
707
+ objects: safe(artifact.objects.map((object) => renderObject(object, 1, false)).join(""))
708
+ } });
709
+ /* provide the children of an object rendered with headings of their own
710
+ (skipping the child groups collapsing into compact tables), which
711
+ are the ones taking part in the table of contents and its side panel */
712
+ const headingChildren = (object) => groupChildren(flowChildren(object))
713
+ .filter((group) => !conciseGroup(group, schemas, false))
714
+ .flat();
715
+ /* flatten the hierarchy of the rendered object headings (exactly like
716
+ the PDF outline) into the entries of the table of contents, each
717
+ carrying its nesting level and its optional page number */
718
+ export const tocEntries = (objects, pages) => {
719
+ const entries = [];
720
+ const collect = (objects, level) => {
721
+ for (const object of objects) {
722
+ const id = anchorOf(object);
723
+ entries.push({ id, kind: object.kind,
724
+ info: infoKeyOf(object), infopath: infoRefOf(object, false), spec: infoRefOf(object),
725
+ name: inline(object.name), level: Math.min(level, 6), page: pages?.get(id) });
726
+ collect(headingChildren(object), level + 1);
727
+ }
728
+ };
729
+ collect(objects, 1);
730
+ return entries;
731
+ };
732
+ /* render the side panel of the table of contents (its behavior is
733
+ driven by its client-side script): the unnumbered entries of
734
+ the front matter (title page, table and diagram of contents, as far
735
+ as present) are followed by the hierarchical entries of the rendered
736
+ object headings, exactly like the table of contents itself */
737
+ export const renderTocPanel = (objects, title, doc) => {
738
+ const entries = (objects) => objects.length === 0 ? "" : render("TocPanelEntries", { Entries: objects.map((object) => ({
739
+ id: anchorOf(object),
740
+ kind: object.kind,
741
+ info: infoKeyOf(object),
742
+ infopath: infoRefOf(object, false),
743
+ spec: infoRefOf(object),
744
+ name: inline(object.name),
745
+ children: safe(entries(headingChildren(object)))
746
+ })) });
747
+ return render("TocPanel", { TocPanel: { title, doc, entries: safe(entries(objects)) } });
748
+ };