@rse/specbook 1.2.15 → 1.2.16

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 (44) hide show
  1. package/README.md +98 -11
  2. package/dst/specbook-api.d.ts +22 -21
  3. package/dst/specbook-api.js +80 -27
  4. package/dst/specbook-cli.js +50 -23
  5. package/dst/specbook-cmd-describe.js +8 -4
  6. package/dst/specbook-cmd-export.d.ts +4 -1
  7. package/dst/specbook-cmd-export.js +22 -12
  8. package/dst/specbook-cmd-init.js +2 -8
  9. package/dst/specbook-cmd-lint.d.ts +2 -1
  10. package/dst/specbook-cmd-lint.js +31 -28
  11. package/dst/specbook-config.d.ts +7 -0
  12. package/dst/specbook-config.js +6 -1
  13. package/dst/specbook-coverage.js +3 -4
  14. package/dst/specbook-diagram.d.ts +3 -1
  15. package/dst/specbook-diagram.js +78 -27
  16. package/dst/specbook-export-ast.d.ts +2 -1
  17. package/dst/specbook-export-ast.js +16 -8
  18. package/dst/specbook-export-common.d.ts +7 -0
  19. package/dst/specbook-export-common.js +36 -3
  20. package/dst/specbook-export-html.css +64 -18
  21. package/dst/specbook-export-html.d.ts +2 -1
  22. package/dst/specbook-export-html.js +517 -275
  23. package/dst/specbook-export-image.d.ts +3 -0
  24. package/dst/specbook-export-image.js +121 -0
  25. package/dst/specbook-export-md.d.ts +8 -2
  26. package/dst/specbook-export-md.js +67 -6
  27. package/dst/specbook-export-pdf.js +7 -4
  28. package/dst/specbook-format.md +35 -8
  29. package/dst/specbook-format.yaml +12 -9
  30. package/dst/specbook-ignore.js +5 -5
  31. package/dst/specbook-link.js +1 -1
  32. package/dst/specbook-mcp.js +51 -34
  33. package/dst/specbook-parse-common.d.ts +4 -1
  34. package/dst/specbook-parse-common.js +15 -3
  35. package/dst/specbook-parse-semantic.d.ts +1 -1
  36. package/dst/specbook-parse-syntax.d.ts +1 -1
  37. package/dst/specbook-parse-syntax.js +28 -8
  38. package/dst/specbook-parse.js +6 -2
  39. package/dst/specbook-project.d.ts +7 -0
  40. package/dst/specbook-project.js +93 -0
  41. package/dst/specbook-theme.d.ts +1 -1
  42. package/dst/specbook-theme.js +9 -8
  43. package/dst/specbook-verbose.js +4 -9
  44. package/package.json +14 -6
@@ -11,10 +11,11 @@ import textframe from "textframe";
11
11
  import { Gradia } from "@rse/gradia";
12
12
  import { buildLinkIndex, resolveUnique, expandReferences, anchorPaths, plainText } from "./specbook-link.js";
13
13
  import { compileValueExpr, splitItems } from "./specbook-parse-value.js";
14
- import { embeddingRegex, embeddingMimeType, embeddingThemes, embeddingVariants } from "./specbook-parse-common.js";
14
+ import { embeddingRegex, embeddingCount, embeddingThemes } from "./specbook-parse-common.js";
15
15
  import { escapeHtml, stylesheet, searchScript, fallbackLogo, isTitleObject, titleObject, documentTitle, documentLang, documentThemeStyle } from "./specbook-export-common.js";
16
16
  import { collectSchemas } from "./specbook-parse-semantic.js";
17
17
  import { renderDiagrams } from "./specbook-diagram.js";
18
+ import { optimizeImages } from "./specbook-export-image.js";
18
19
  import { specCoverage, coverageRatio } from "./specbook-coverage.js";
19
20
  /* ==== Templates ==== */
20
21
  /* the built-in Nunjucks templates for the HTML export */
@@ -44,13 +45,18 @@ const templates = {
44
45
  <span class="search-clear" id="search-clear" title="clear search">&#x00D7;</span>
45
46
  </div>
46
47
  </div>
48
+ {%- if not Document.omitted.all %}
47
49
  <div class="fold-switch">
48
50
  <div class="fold-toggle" title="toggle folding controls"><svg class="fold-icon" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5" stroke-linecap="round" stroke-linejoin="round"><path d="M7 3.5l5 5 5-5"/><path d="M7 20.5l5-5 5 5"/></svg></div>
49
51
  <div class="fold-controls">
50
- <div class="fold-diagrams" title="fold/unfold all diagrams"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5" stroke-linecap="round" stroke-linejoin="round"><circle cx="5.5" cy="5.5" r="3"/><circle cx="18.5" cy="5.5" r="3"/><circle cx="12" cy="18.5" r="3"/><path d="M7.3 8.1 10.6 15.9"/><path d="M16.7 8.1 13.4 15.9"/></svg></div>
51
- <div class="fold-texts" title="fold/unfold all cell texts"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5" stroke-linecap="round" stroke-linejoin="round"><path d="M4.5 7V4.5h15V7"/><path d="M12 4.5v15"/><path d="M8.5 19.5h7"/></svg></div>
52
+ {% if not Document.omitted.graph %}<div class="fold-graphs" title="fold/unfold all graph diagrams"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5" stroke-linecap="round" stroke-linejoin="round"><circle cx="5.5" cy="5.5" r="3"/><circle cx="18.5" cy="5.5" r="3"/><circle cx="12" cy="18.5" r="3"/><path d="M7.3 8.1 10.6 15.9"/><path d="M16.7 8.1 13.4 15.9"/></svg></div>{% endif %}
53
+ {% if not Document.omitted.hub %}<div class="fold-hubs" title="fold/unfold all hub diagrams"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5" stroke-linecap="round" stroke-linejoin="round"><circle cx="12" cy="12" r="3.5"/><circle cx="3.5" cy="4.5" r="2"/><circle cx="3.5" cy="19.5" r="2"/><circle cx="20.5" cy="4.5" r="2"/><circle cx="20.5" cy="19.5" r="2"/><path d="M5.5 4.5H10V9.1"/><path d="M18.5 4.5H14V9.1"/><path d="M5.5 19.5H10V14.9"/><path d="M18.5 19.5H14V14.9"/></svg></div>{% endif %}
54
+ {% if not Document.omitted.grid %}<div class="fold-grids" title="fold/unfold all grid diagrams"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5" stroke-linecap="round" stroke-linejoin="round"><rect x="3.5" y="3.5" width="7" height="7" rx="1"/><rect x="13.5" y="3.5" width="7" height="7" rx="1"/><rect x="3.5" y="13.5" width="7" height="7" rx="1"/><rect x="13.5" y="13.5" width="7" height="7" rx="1"/></svg></div>{% endif %}
55
+ {% for level in [ 1, 2, 3 ] %}{% if not Document.omitted["level" ~ level] %}<div class="fold-level{{ level }}" title="fold/unfold all diagrams from nesting level {{ level }} on"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5" stroke-linecap="round" stroke-linejoin="round"><rect x="2.5" y="2.5" width="11" height="11" rx="1.5"/><rect x="6.5" y="6.5" width="3" height="3" rx="0.5"/><text x="19.5" y="23" text-anchor="middle" font-size="14.5" font-weight="bold" fill="currentColor" stroke="none">{{ level }}</text></svg></div>{% endif %}{% endfor %}
56
+ {% if not Document.omitted.text %}<div class="fold-texts" title="fold/unfold all cell texts"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5" stroke-linecap="round" stroke-linejoin="round"><path d="M4.5 7V4.5h15V7"/><path d="M12 4.5v15"/><path d="M8.5 19.5h7"/></svg></div>{% endif %}
52
57
  </div>
53
58
  </div>
59
+ {%- endif %}
54
60
  <div class="scroll-progress" title="scroll to top"><svg class="scroll-ring" viewBox="0 0 44 44" fill="none" stroke-width="2.5"><circle class="scroll-todo" cx="22" cy="22" r="20"/><circle class="scroll-done" cx="22" cy="22" r="20" stroke-linecap="round" pathLength="1" stroke-dasharray="1" stroke-dashoffset="1"/></svg><svg class="scroll-icon" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5" stroke-linecap="round" stroke-linejoin="round"><path d="M12 19V5"/><path d="M5 12l7-7 7 7"/></svg></div>
55
61
  {{ Document.tocpanel }}
56
62
  {{ Document.titlepage }}
@@ -109,7 +115,7 @@ const templates = {
109
115
  <h1>Table of Contents</h1>
110
116
  <table>
111
117
  {% for entry in Toc.entries %}
112
- <tr class="level-{{ entry.level }}"><td><a href="#{{ entry.id }}"><span class="object-kind"{% if entry.info %} data-info="{{ entry.info }}" data-info-path="{{ entry.infopath }}"{% endif %}>{{ entry.kind }}:</span> <span class="object-name"{% if entry.spec %} data-info-spec="{{ entry.spec }}" data-info-id="{{ entry.specid }}" data-info-path="{{ entry.specpath }}"{% endif %}>{{ entry.name }}</span> <span class="link-symbol">&#x26AD;</span></a></td>{% if entry.page %}<td class="page"><a href="#{{ entry.id }}">{{ entry.page }}</a></td>{% endif %}</tr>
118
+ <tr class="level-{{ entry.level }}"><td><a href="#{{ entry.id }}"><span class="object-kind"{% if entry.info %} data-info="{{ entry.info }}" data-info-path="{{ entry.infopath }}"{% endif %}>{{ entry.kind }}:</span> <span class="object-name"{% if entry.spec %} data-info-spec="{{ entry.spec }}"{% endif %}>{{ entry.name }}</span> <span class="link-symbol">&#x26AD;</span></a></td>{% if entry.page %}<td class="page"><a href="#{{ entry.id }}">{{ entry.page }}</a></td>{% endif %}</tr>
113
119
  {% endfor %}
114
120
  </table>
115
121
  </nav>
@@ -132,7 +138,7 @@ const templates = {
132
138
  "TocPanelEntries": textframe `
133
139
  <ul>
134
140
  {% for entry in Entries %}
135
- <li><a href="#{{ entry.id }}"><span class="entry"><span class="object-kind"{% if entry.info %} data-info="{{ entry.info }}" data-info-path="{{ entry.infopath }}"{% endif %}>{{ entry.kind }}:</span> <span class="object-name"{% if entry.spec %} data-info-spec="{{ entry.spec }}" data-info-id="{{ entry.specid }}" data-info-path="{{ entry.specpath }}"{% endif %}>{{ entry.name }}</span></span></a>{{ entry.children }}</li>
141
+ <li><a href="#{{ entry.id }}"><span class="entry"><span class="object-kind"{% if entry.info %} data-info="{{ entry.info }}" data-info-path="{{ entry.infopath }}"{% endif %}>{{ entry.kind }}:</span> <span class="object-name"{% if entry.spec %} data-info-spec="{{ entry.spec }}"{% endif %}>{{ entry.name }}</span></span></a>{{ entry.children }}</li>
136
142
  {% endfor %}
137
143
  </ul>
138
144
  `,
@@ -140,7 +146,7 @@ const templates = {
140
146
  "Doc": textframe `
141
147
  <nav class="doc" id="doc">
142
148
  <h1>Diagram of Contents</h1>
143
- <div class="diagram">{{ Doc.diagram }}</div>
149
+ {{ Doc.diagram }}
144
150
  </nav>
145
151
  `,
146
152
  /* <Artifact/> */
@@ -152,7 +158,7 @@ const templates = {
152
158
  /* <Object/> */
153
159
  "Object": textframe `
154
160
  <section>
155
- <h{{ Object.level }} id="{{ Object.id }}"{% if Object.anchor %} data-id="{{ Object.anchor }}"{% endif %}><span class="object-kind"{% if Object.info %} data-info="{{ Object.info }}" data-info-path="{{ Object.infopath }}"{% endif %}>{{ Object.kind }}:</span> <span class="object-name"{% if Object.spec %} data-info-spec="{{ Object.spec }}" data-info-id="{{ Object.specid }}" data-info-path="{{ Object.specpath }}"{% endif %}>{{ Object.name }}</span>{% 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 }}>
161
+ <h{{ Object.level }} id="{{ Object.id }}"{% if Object.anchor %} data-id="{{ Object.anchor }}"{% endif %}><span class="object-kind"{% if Object.info %} data-info="{{ Object.info }}" data-info-path="{{ Object.infopath }}"{% endif %}>{{ Object.kind }}:</span> <span class="object-name"{% if Object.spec %} data-info-spec="{{ Object.spec }}"{% endif %}>{{ Object.name }}</span>{% 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 }}>
156
162
  {{ Object.diagram }}
157
163
  {{ Object.properties }}
158
164
  {{ Object.description }}
@@ -207,7 +213,7 @@ const templates = {
207
213
  <tbody>
208
214
  {% for row in Table.rows %}
209
215
  <tr id="{{ row.id }}"{% if row.anchor %} data-id="{{ row.anchor }}"{% endif %}{% if row.even %} class="even"{% endif %}>
210
- <td><span{% if row.spec %} data-info-spec="{{ row.spec }}" data-info-id="{{ row.specid }}" data-info-path="{{ row.specpath }}"{% endif %}>{{ row.name }}</span>{% 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>
216
+ <td><span{% if row.spec %} data-info-spec="{{ row.spec }}"{% endif %}>{{ row.name }}</span>{% 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>
211
217
  {%- for value in row.values %}<td>{{ value }}</td>{% endfor %}
212
218
  {%- if Table.desc %}<td>{{ row.description }}</td>{% endif %}
213
219
  </tr>
@@ -221,17 +227,17 @@ const templates = {
221
227
  <thead>
222
228
  <tr>
223
229
  <th class="object-kind" style="width: {{ Table.width }}%"><span{% if Table.info %} data-info="{{ Table.info }}" data-info-path="{{ Table.infopath }}"{% endif %}>{{ Table.head }}</span></th>
224
- <th class="description">Properties</th>
230
+ <th class="description">Properties{% if Table.desc %} &amp; Description{% endif %}</th>
225
231
  </tr>
226
232
  </thead>
227
233
  <tbody>
228
234
  {% for row in Table.rows %}
229
235
  <tr id="{{ row.id }}"{% if row.anchor %} data-id="{{ row.anchor }}"{% endif %}{% if row.even %} class="even"{% endif %}>
230
- <td><span{% if row.spec %} data-info-spec="{{ row.spec }}" data-info-id="{{ row.specid }}" data-info-path="{{ row.specpath }}"{% endif %}>{{ row.name }}</span>{% 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>
236
+ <td><span{% if row.spec %} data-info-spec="{{ row.spec }}"{% endif %}>{{ row.name }}</span>{% 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>
231
237
  <td class="chunks">
232
238
  <table class="chunks">
233
239
  {% for chunk in row.chunks %}
234
- <tr>{% for cell in chunk %}<th{% if not cell.desc %} class="property-name"{% endif %}{% if cell.span > 1 %} colspan="{{ cell.span }}"{% endif %}><span{% if not cell.desc and Table.info %} data-info="{{ Table.info }}" data-info-path="{{ row.specpath }}" data-info-prop="{{ cell.key }}"{% endif %}>{{ cell.key }}</span></th>{% endfor %}</tr>
240
+ <tr>{% for cell in chunk %}<th{% if not cell.desc %} class="property-name"{% endif %}{% if cell.span > 1 %} colspan="{{ cell.span }}"{% endif %}><span{% if not cell.desc and Table.info %} data-info="{{ Table.info }}" data-info-path="{{ row.spec }}" data-info-prop="{{ cell.key }}"{% endif %}>{{ cell.key }}</span></th>{% endfor %}</tr>
235
241
  <tr>{% for cell in chunk %}<td{% if cell.span > 1 %} colspan="{{ cell.span }}"{% endif %}>{{ cell.value }}</td>{% endfor %}</tr>
236
242
  {% endfor %}
237
243
  </table>
@@ -306,17 +312,17 @@ const realtimeScript = textframe `
306
312
  /* a superseded update (an overlapping newer one started
307
313
  meanwhile) is dropped, as its stale page could arrive last */
308
314
  const mine = ++seq
309
- let response
315
+ let html
310
316
  try {
311
- response = await fetch(window.location.pathname, { cache: "no-store" })
317
+ const response = await fetch(window.location.pathname, { cache: "no-store" })
318
+ if (!response.ok)
319
+ return
320
+ html = await response.text()
312
321
  }
313
322
  catch {
314
323
  /* an unreachable server just skips the update */
315
324
  return
316
325
  }
317
- if (!response.ok)
318
- return
319
- const html = await response.text()
320
326
  if (mine !== seq)
321
327
  return
322
328
  const doc = new DOMParser().parseFromString(html, "text/html")
@@ -328,6 +334,9 @@ const realtimeScript = textframe `
328
334
  if (style !== null && fresh !== null && style.textContent !== fresh.textContent)
329
335
  style.replaceWith(fresh)
330
336
  document.body.replaceWith(doc.body)
337
+
338
+ /* a swapped-out maximization overlay can no longer unlock the scrolling */
339
+ document.documentElement.classList.remove("maximized")
331
340
  for (const script of document.body.querySelectorAll("script:not(.realtime)")) {
332
341
  const clone = document.createElement("script")
333
342
  clone.textContent = script.textContent
@@ -408,25 +417,37 @@ const scrollProgressScript = textframe `
408
417
  `;
409
418
  /* the client-side script of the folding: it wraps every diagram of the
410
419
  content into a fold container carrying a chevron mark at its top
411
- left corner, folds the
412
- text of every table cell towering over the other cells of its row
413
- behind a chevron mark of its own, and lets the two
414
- controls of the fold tab fold and unfold all diagrams and all cell
415
- texts at once, with their state persisted across page loads and
416
- their icons (plus the tab icon) marked while anything of their kind
417
- is folded. The script
418
- runs at the end of the body, as the content it wraps has to exist
419
- already, and a live preview body swap replaces the containers and
420
- their listeners along with the body */
421
- const foldScript = textframe `
420
+ left corner, folds the text of every table cell towering over the
421
+ other cells of its row behind a chevron mark of its own, and lets
422
+ the controls of the fold tab fold and unfold all diagrams of a type
423
+ ("graph", "hub", "grid"), all diagrams from an object tree nesting
424
+ level on (1, 2, 3), and all cell texts at once, with their state
425
+ persisted across page loads and their icons marked while they are
426
+ active (the diagram ones, OR-combined) or any cell text is folded
427
+ (the tab icon while anything at all is). The script runs at
428
+ the end of the body, as the content it wraps has to exist already,
429
+ and a live preview body swap replaces the containers and their
430
+ listeners along with the body. An export omitting aspects lacks
431
+ their controls ("standins" lets detached elements take their place)
432
+ and, with the long texts omitted, folds no cell text ("textless") */
433
+ const foldScript = (standins, textless) => textframe `
422
434
  (function () {
423
435
  const tab = document.querySelector("div.fold-switch")
424
436
  if (tab === null)
425
437
  return
426
- const controls = { diagram: tab.querySelector("div.fold-diagrams"),
427
- text: tab.querySelector("div.fold-texts") }
438
+ const controls = {
439
+ graph: tab.querySelector("div.fold-graphs"),
440
+ hub: tab.querySelector("div.fold-hubs"),
441
+ grid: tab.querySelector("div.fold-grids"),
442
+ level1: tab.querySelector("div.fold-level1"),
443
+ level2: tab.querySelector("div.fold-level2"),
444
+ level3: tab.querySelector("div.fold-level3"),
445
+ text: tab.querySelector("div.fold-texts")
446
+ }${standins ? `
447
+ for (const kind of Object.keys(controls))
448
+ controls[kind] ??= document.createElement("div")` : ""}
428
449
 
429
- /* let the fold icon slide the two controls out of the tab (and
450
+ /* let the fold icon slide the controls out of the tab (and
430
451
  back in again), remembering the choice across page loads */
431
452
  try { if (localStorage.getItem("specbook-fold") === "open") tab.classList.add("open") }
432
453
  catch { /* an inaccessible storage just means no stored state */ }
@@ -465,23 +486,29 @@ const foldScript = textframe `
465
486
 
466
487
  /* wrap every diagram into its fold container, carrying the
467
488
  chevron mark plus the muted placeholder the diagram leaves
468
- behind while folded, which is the very icon of its tab
469
- control */
470
- const folds = { diagram: [], text: [] }
489
+ behind while folded, which is the very icon of its type
490
+ control; a diagram joins the set of its type plus the sets
491
+ of all nesting levels up to its own one (3 standing for all
492
+ deeper ones), so the sets of the controls overlap */
493
+ const folds = { graph: [], hub: [], grid: [], level1: [], level2: [], level3: [], text: [] }
471
494
  document.querySelectorAll("article div.diagram, nav.doc div.diagram").forEach((el) => {
472
- const fold = document.createElement("div")
495
+ const type = el.getAttribute("data-type") ?? "graph"
496
+ const level = Math.min(Number(el.getAttribute("data-level")) || 1, 3)
497
+ const fold = document.createElement("div")
473
498
  fold.className = "fold"
474
499
  el.parentNode.insertBefore(fold, el)
475
500
  const chevron = mark("fold-mark", "fold/unfold this diagram")
476
501
  const label = document.createElement("span")
477
502
  label.className = "fold-label"
478
- label.appendChild(controls.diagram.querySelector("svg").cloneNode(true))
503
+ label.appendChild(controls[type].querySelector("svg").cloneNode(true))
479
504
  fold.append(chevron, label, el)
480
505
  chevron.addEventListener("click", () => {
481
506
  fold.classList.toggle("folded")
482
507
  sync()
483
508
  })
484
- folds.diagram.push(fold)
509
+ folds[type].push(fold)
510
+ for (let n = 1; n <= level; n++)
511
+ folds["level" + n].push(fold)
485
512
  })
486
513
 
487
514
  /* the rendered height of the content of a table cell, or of its
@@ -525,31 +552,6 @@ const foldScript = textframe `
525
552
  && cell.querySelector("div.diagram") === null
526
553
  && (cell.textContent.trim() !== "" || cell.querySelector("img") !== null)
527
554
 
528
- /* decide which table cells fold: one which is taller than the
529
- configured percentage above every other cell of its row (the
530
- leading name column of a compact table excluded, as it is no
531
- text of its own). All rows are measured before the first cut,
532
- as every cut shifts the layout of the ones still to come */
533
- const cuts = []
534
- document.querySelectorAll("article table tr").forEach((row) => {
535
- const table = row.closest("table")
536
- let cells = Array.from(row.children).filter((el) => el.tagName === "TD")
537
- if (!table.classList.contains("chunks"))
538
- cells = cells.slice(1)
539
- cells = cells.filter(candidate)
540
- if (cells.length < 2)
541
- return
542
- const configured = row.closest("table[data-fold-height]")
543
- const percent = Number(configured?.getAttribute("data-fold-height")) || cellHeight
544
- const heights = cells.map((cell) => height(cell))
545
- cells.forEach((cell, i) => {
546
- const other = Math.max(...heights.filter((_, j) => j !== i))
547
- const limit = other * (1 + percent / 100)
548
- if (heights[i] > limit)
549
- cuts.push({ cell, limit, full: heights[i] })
550
- })
551
- })
552
-
553
555
  /* hide a node of the folded remainder of a table cell: an
554
556
  element carries the class itself, a text node gets wrapped */
555
557
  const rest = (node) => {
@@ -557,102 +559,215 @@ const foldScript = textframe `
557
559
  node.classList.add("fold-rest")
558
560
  else if (node.nodeType === Node.TEXT_NODE) {
559
561
  const span = document.createElement("span")
560
- span.className = "fold-rest"
562
+ span.className = "fold-rest fold-wrap"
561
563
  node.parentNode.insertBefore(span, node)
562
564
  span.appendChild(node)
563
565
  }
564
566
  }
565
567
 
568
+ /* take the cut of a table cell back again: the chevron mark
569
+ leaves, the remainder sheds its class or wrapper, and the
570
+ split text nodes merge */
571
+ const uncut = (cell) => {
572
+ cell.classList.remove("folded")
573
+ cell.querySelectorAll("span.fold-more").forEach((el) => { el.remove() })
574
+ cell.querySelectorAll(".fold-rest").forEach((el) => {
575
+ if (el.classList.contains("fold-wrap"))
576
+ el.replaceWith(...el.childNodes)
577
+ else
578
+ el.classList.remove("fold-rest")
579
+ })
580
+ cell.normalize()
581
+ }
582
+
566
583
  /* the minimum percentage of its own height a cell has to gain
567
584
  by folding, as hiding less than that is no visible relief
568
585
  and only costs the reader a chevron to click (measured after
569
586
  the cut, as the chevron itself can claim a line of its own) */
570
587
  const cellGain = 25
571
588
 
572
- /* fold the decided cells back onto their height limit: the last
573
- word position still fitting is found by a binary search over
574
- the measured word positions, the text node is split there and
575
- the chevron mark takes its place, followed by the remainder
576
- hidden up the ancestor chain of the cell */
577
- for (const cut of cuts) {
578
- const list = positions(cut.cell)
579
- if (list.length === 0)
580
- continue
581
- let lo = 0
582
- let hi = list.length - 1
583
- while (lo < hi) {
584
- const mid = Math.ceil((lo + hi) / 2)
585
- if (height(cut.cell, list[mid]) <= cut.limit)
586
- lo = mid
587
- else
588
- hi = mid - 1
589
- }
590
- const tail = list[lo].node.splitText(list[lo].offset)
591
- const chevron = mark("fold-more", "fold/unfold the remaining text")
592
- tail.parentNode.insertBefore(chevron, tail)
593
- let current = chevron
594
- while (current !== cut.cell) {
595
- let next = current.nextSibling
596
- while (next !== null) {
597
- const following = next.nextSibling
598
- rest(next)
599
- next = following
589
+ /* lay out the text folds, which holds for the current line
590
+ breaks only and hence is redone on every change of them: the
591
+ previous cuts are taken back, with their fold state carried
592
+ over (a cell folding anew follows the others) */
593
+ const layout = () => {${textless ? `
594
+ return` : ""}
595
+ const all = folds.text.length > 0 && folds.text.every((cell) => cell.classList.contains("folded"))
596
+ const state = new Map(folds.text.map((cell) => [ cell, cell.classList.contains("folded") ]))
597
+ folds.text.forEach(uncut)
598
+ folds.text.length = 0
599
+
600
+ /* decide which table cells fold: one which is taller than the
601
+ configured percentage above every other cell of its row (the
602
+ leading name column of a compact table excluded, as it is no
603
+ text of its own). All rows are measured before the first cut,
604
+ as every cut shifts the layout of the ones still to come */
605
+ const cuts = []
606
+ document.querySelectorAll("article table tr").forEach((row) => {
607
+ const table = row.closest("table")
608
+ let cells = Array.from(row.children).filter((el) => el.tagName === "TD")
609
+ if (!table.classList.contains("chunks"))
610
+ cells = cells.slice(1)
611
+ cells = cells.filter(candidate)
612
+ if (cells.length < 2)
613
+ return
614
+ const configured = row.closest("table[data-fold-height]")
615
+ const percent = Number(configured?.getAttribute("data-fold-height")) || cellHeight
616
+ const heights = cells.map((cell) => height(cell))
617
+ cells.forEach((cell, i) => {
618
+ const other = Math.max(...heights.filter((_, j) => j !== i))
619
+ const limit = other * (1 + percent / 100)
620
+ if (heights[i] > limit)
621
+ cuts.push({ cell, limit, full: heights[i] })
622
+ })
623
+ })
624
+
625
+ /* fold the decided cells back onto their height limit: the last
626
+ word position still fitting is found by a binary search over
627
+ the measured word positions, the text node is split there and
628
+ the chevron mark takes its place, followed by the remainder
629
+ hidden up the ancestor chain of the cell */
630
+ for (const cut of cuts) {
631
+ const list = positions(cut.cell)
632
+ if (list.length === 0)
633
+ continue
634
+ let lo = 0
635
+ let hi = list.length - 1
636
+ while (lo < hi) {
637
+ const mid = Math.ceil((lo + hi) / 2)
638
+ if (height(cut.cell, list[mid]) <= cut.limit)
639
+ lo = mid
640
+ else
641
+ hi = mid - 1
600
642
  }
601
- current = current.parentNode
602
- }
603
- /* the gain is measured on the applied fold, as only that
604
- one tells the real one, and a fold gaining less than the
605
- minimum share is taken back again, which the chevron
606
- alone has to leave behind, as the hidden remainder stays
607
- inert without the class -- which a kept fold sheds
608
- again, too, as every cell starts out unfolded */
609
- cut.cell.classList.add("folded")
610
- const gained = height(cut.cell) <= cut.full * (1 - cellGain / 100)
611
- cut.cell.classList.remove("folded")
612
- if (!gained) {
613
- chevron.remove()
614
- continue
643
+ const tail = list[lo].node.splitText(list[lo].offset)
644
+ const chevron = mark("fold-more", "fold/unfold the remaining text")
645
+ tail.parentNode.insertBefore(chevron, tail)
646
+ let current = chevron
647
+ while (current !== cut.cell) {
648
+ let next = current.nextSibling
649
+ while (next !== null) {
650
+ const following = next.nextSibling
651
+ rest(next)
652
+ next = following
653
+ }
654
+ current = current.parentNode
655
+ }
656
+
657
+ /* the gain is measured on the applied fold, as only that
658
+ one tells the real one, and a fold gaining less than the
659
+ minimum share is taken back again, while a kept fold
660
+ sheds the class again, as every cell starts out unfolded */
661
+ cut.cell.classList.add("folded")
662
+ const gained = height(cut.cell) <= cut.full * (1 - cellGain / 100)
663
+ if (!gained) {
664
+ uncut(cut.cell)
665
+ continue
666
+ }
667
+ cut.cell.classList.toggle("folded", state.get(cut.cell) ?? all)
668
+ folds.text.push(cut.cell)
669
+ chevron.addEventListener("click", (event) => {
670
+ /* the cut can land inside a hyperlink, whose navigation
671
+ the chevron has to suppress for its own click alone,
672
+ so the remaining link text still follows the link */
673
+ event.preventDefault()
674
+ cut.cell.classList.toggle("folded")
675
+ sync()
676
+ })
615
677
  }
616
- folds.text.push(cut.cell)
617
- chevron.addEventListener("click", (event) => {
618
- /* the cut can land inside a hyperlink, whose navigation
619
- the chevron has to suppress for its own click alone,
620
- so the remaining link text still follows the link */
621
- event.preventDefault()
622
- cut.cell.classList.toggle("folded")
623
- sync()
624
- })
625
678
  }
679
+ layout()
626
680
 
627
- /* mark the control of a kind while anything of that kind is
628
- folded, and the tab itself while any kind at all is */
681
+ /* mark the text control while any cell text is folded, a diagram
682
+ control while it is active itself (as the sets of the diagram
683
+ controls overlap, the fold state of their diagrams tells
684
+ nothing), and the tab itself while anything at all is folded */
685
+ const active = new Set()
629
686
  const sync = () => {
630
- let any = false
631
- for (const kind of Object.keys(folds)) {
632
- const folded = folds[kind].some((fold) => fold.classList.contains("folded"))
633
- controls[kind].classList.toggle("folded", folded)
634
- any ||= folded
635
- }
636
- tab.classList.toggle("folded", any)
687
+ for (const kind of Object.keys(folds))
688
+ controls[kind].classList.toggle("folded", kind === "text" ?
689
+ folds.text.some((fold) => fold.classList.contains("folded")) : active.has(kind))
690
+ tab.classList.toggle("folded", Object.keys(folds).some((kind) =>
691
+ folds[kind].some((fold) => fold.classList.contains("folded"))))
637
692
  }
638
- for (const kind of Object.keys(folds)) {
639
- /* without a stored state the rendered one stands, which is
640
- everything unfolded */
641
- let stored = null
642
- try { stored = localStorage.getItem("specbook-fold-" + kind) }
693
+
694
+ /* fold exactly the diagrams at least one active control covers */
695
+ const apply = () => {
696
+ folds.level1.forEach((fold) => {
697
+ fold.classList.toggle("folded", Array.from(active).some((kind) => folds[kind].includes(fold)))
698
+ })
699
+ }
700
+
701
+ /* without a stored state the rendered one stands, which is
702
+ everything unfolded: the cell texts store their state as a
703
+ whole, the diagrams as the list of their active controls */
704
+ const stored = { text: null, diagrams: null }
705
+ for (const name of Object.keys(stored)) {
706
+ try { stored[name] = localStorage.getItem("specbook-fold-" + name) }
643
707
  catch { /* an inaccessible storage just means no stored state */ }
644
- if (stored !== null)
645
- folds[kind].forEach((fold) => { fold.classList.toggle("folded", stored === "folded") })
708
+ }
709
+ if (stored.text !== null)
710
+ folds.text.forEach((fold) => { fold.classList.toggle("folded", stored.text === "folded") })
711
+ if (stored.diagrams !== null) {
712
+ stored.diagrams.split(",").filter((kind) => kind !== "text" && folds[kind] !== undefined)
713
+ .forEach((kind) => { active.add(kind) })
714
+ apply()
715
+ }
716
+ for (const kind of Object.keys(folds)) {
646
717
  controls[kind].addEventListener("click", () => {
647
- const all = !folds[kind].every((fold) => fold.classList.contains("folded"))
648
- folds[kind].forEach((fold) => { fold.classList.toggle("folded", all) })
649
- try { localStorage.setItem("specbook-fold-" + kind, all ? "folded" : "unfolded") }
718
+ try {
719
+ if (kind === "text") {
720
+ const all = !folds.text.every((fold) => fold.classList.contains("folded"))
721
+ folds.text.forEach((fold) => { fold.classList.toggle("folded", all) })
722
+ localStorage.setItem("specbook-fold-text", all ? "folded" : "unfolded")
723
+ }
724
+ else {
725
+ if (!active.delete(kind))
726
+ active.add(kind)
727
+ apply()
728
+ localStorage.setItem("specbook-fold-diagrams", Array.from(active).join(","))
729
+ }
730
+ }
650
731
  catch { /* an inaccessible storage just loses the state */ }
651
732
  sync()
652
733
  })
653
734
  }
654
735
  sync()
655
736
 
737
+ /* redo the text folds once the line breaks change, which a new
738
+ viewport width and the arrival of the embedded fonts (still
739
+ loading while this script runs) cause. A running search
740
+ (which shows the remainders) defers it, and a live preview
741
+ body swap, which left this script behind, ends it */
742
+ let width = document.documentElement.clientWidth
743
+ let timer = 0
744
+ const relayout = () => {
745
+ if (!tab.isConnected)
746
+ return
747
+ if (document.body.classList.contains("searching")) {
748
+ schedule()
749
+ return
750
+ }
751
+ layout()
752
+ sync()
753
+ }
754
+ const schedule = () => {
755
+ clearTimeout(timer)
756
+ timer = setTimeout(relayout, 250)
757
+ }
758
+ const resized = () => {
759
+ if (!tab.isConnected)
760
+ window.removeEventListener("resize", resized)
761
+ else if (width !== document.documentElement.clientWidth) {
762
+ width = document.documentElement.clientWidth
763
+ schedule()
764
+ }
765
+ }
766
+ window.addEventListener("resize", resized)
767
+ document.fonts.ready.then(() => {
768
+ if (tab.isConnected)
769
+ schedule()
770
+ })
656
771
  })()
657
772
  `;
658
773
  /* the client-side script of the diagram maximization: it attaches two
@@ -714,14 +829,14 @@ const maximizeScript = textframe `
714
829
  overlay.classList.add("open")
715
830
  document.documentElement.classList.add("maximized")
716
831
  if (fullscreen)
717
- overlay.requestFullscreen().catch(() => {})
832
+ overlay.requestFullscreen().catch(() => { /* a refused request leaves the viewport overlay */ })
718
833
  }
719
834
  const shut = () => {
720
835
  overlay.classList.remove("open")
721
836
  document.documentElement.classList.remove("maximized")
722
837
  content.replaceChildren()
723
838
  if (document.fullscreenElement === overlay)
724
- document.exitFullscreen().catch(() => {})
839
+ document.exitFullscreen().catch(() => { /* a fullscreen left already needs no exit */ })
725
840
  }
726
841
  close.addEventListener("click", shut)
727
842
  overlay.addEventListener("click", (event) => {
@@ -865,10 +980,16 @@ const tocPanelScript = textframe `
865
980
  schema description of its object kind (or property), while one
866
981
  carrying a "data-info-spec" key pops up the corpus description of
867
982
  the object instance (a diagram node box resolves through its
868
- hyperlinked object anchor), titled with the "data-info-path" object
869
- path (an instance popup trailing the "data-info-id" anchor id of
870
- the object) and fed from the injected INFO/SPEC maps of pre-rendered
871
- description HTML; the popup is capped at 40% viewport width and
983
+ hyperlinked object anchor), titled with the object path (an instance
984
+ popup trailing the anchor id of the object) and fed from the
985
+ injected INFO/SPEC tables: as thousands of elements carry those
986
+ keys, they are just the table indices, and a title path is composed
987
+ from the SPEC table (the anchor id, parent index, kind, plain name,
988
+ and pre-rendered description HTML per object) instead of being
989
+ repeated on every element -- the path of an instance popup is the
990
+ one of its own object, while "data-info-path" names the object of a
991
+ schema popup, a trailing "^" keeping its last segment kind-only;
992
+ the popup is capped at 40% viewport width and
872
993
  attached above or below, whichever side offers more space; the
873
994
  script runs at the end of the body, so a live preview body swap
874
995
  replaces the popup and the body-bound listeners along with it */
@@ -919,6 +1040,14 @@ const infoPopupScript = textframe `
919
1040
  return
920
1041
  }
921
1042
  current = el
1043
+
1044
+ /* compose the title path segments of an object */
1045
+ const pathOf = (n, named) => {
1046
+ const [ , up, kind, title ] = SPEC[n]
1047
+ const name = named ? title : ""
1048
+ return [ ...(up >= 0 ? pathOf(up, true) : []),
1049
+ name !== "" && kind !== "" ? kind + ": " + name : (name !== "" ? name : kind) ]
1050
+ }
922
1051
  const key = source.getAttribute("data-info")
923
1052
  const spec = source.getAttribute("data-info-spec")
924
1053
  const prop = source.getAttribute("data-info-prop")
@@ -933,7 +1062,9 @@ const infoPopupScript = textframe `
933
1062
  span.textContent = "▷"
934
1063
  return span
935
1064
  }
936
- const segments = (source.getAttribute("data-info-path") ?? key ?? spec).split(" . ")
1065
+ const ref = spec ?? source.getAttribute("data-info-path") ?? ""
1066
+ const index = parseInt(ref, 10)
1067
+ const segments = SPEC[index] !== undefined ? pathOf(index, !ref.endsWith("^")) : []
937
1068
  for (const [ i, segment ] of segments.entries()) {
938
1069
  if (i > 0)
939
1070
  title.appendChild(pointer())
@@ -967,10 +1098,9 @@ const infoPopupScript = textframe `
967
1098
  }
968
1099
 
969
1100
  /* the anchor id of an object instance (explicit or derived
970
- from its name, carried by the "data-info-id" key) trails
971
- the last segment */
972
- const anchor = source.getAttribute("data-info-id")
973
- if (spec !== null && anchor !== null) {
1101
+ from its name) trails the last segment */
1102
+ const anchor = spec !== null ? SPEC[spec]?.[0] : undefined
1103
+ if (anchor !== undefined) {
974
1104
  const id = document.createElement("span")
975
1105
  id.className = "info-id"
976
1106
  const name = document.createElement("span")
@@ -989,8 +1119,8 @@ const infoPopupScript = textframe `
989
1119
  popup.replaceChildren(title)
990
1120
  const entry = key !== null ? INFO[key] : undefined
991
1121
  const desc = prop !== null ? entry?.p?.[prop] :
992
- (spec !== null ? SPEC[spec] : entry?.d)
993
- if (desc !== undefined) {
1122
+ (spec !== null ? SPEC[spec]?.[4] : entry?.d)
1123
+ if (desc !== undefined && desc !== "") {
994
1124
  const text = document.createElement("div")
995
1125
  text.className = "info-desc"
996
1126
  text.innerHTML = desc
@@ -1114,17 +1244,20 @@ const render = (name, context) => {
1114
1244
  };
1115
1245
  /* the active per-document reference expander, fully-qualified
1116
1246
  anchor paths, member-carrying property value constraints, object
1117
- schema nodes, pre-rendered diagram SVGs, reference coverages,
1118
- description popup keys of the schema nodes, and object parents of
1119
- the description popup title paths (all set during HTML rendering) */
1247
+ schema nodes, pre-rendered diagram blocks, optimized embedded images,
1248
+ reference coverages, description popup keys of the schema nodes,
1249
+ description popup keys of the objects, and omitted aspects (all set
1250
+ during HTML rendering) */
1120
1251
  let linker = null;
1121
1252
  let anchors = null;
1122
1253
  let members = null;
1123
1254
  let schemas = null;
1124
1255
  let diagrams = null;
1256
+ let images = null;
1125
1257
  let coverages = null;
1126
1258
  let infoKeys = null;
1127
- let infoParents = null;
1259
+ let infoObjects = null;
1260
+ let omits = null;
1128
1261
  /* the object whose texts are currently rendered, scoping the
1129
1262
  resolution of the references inside them (nearest object wins),
1130
1263
  established around every object rendering and restored afterwards */
@@ -1141,49 +1274,43 @@ const scoped = (object, body) => {
1141
1274
  };
1142
1275
  /* determine the fully-qualified anchor path of an object */
1143
1276
  const anchorOf = (object) => anchors?.get(object) ?? object.id;
1144
- /* determine the description popup key of an object (the qualified
1145
- title path of its schema node) */
1277
+ /* determine the schema description popup key of an object (the
1278
+ INFO table index of its schema node) */
1146
1279
  const infoKeyOf = (object) => {
1147
1280
  const schema = schemas?.get(object);
1148
1281
  return schema !== undefined ? infoKeys?.get(schema) : undefined;
1149
1282
  };
1150
- /* determine the description popup title path of an object: the chain
1151
- of "KIND: Name" segments from the artifact down to the object
1152
- (arbitrarily deep), where "named = false" keeps the last segment
1153
- kind-only, as the kind popups (and the group headers of the compact
1154
- tables) describe the object class instead of the single instance */
1155
- const infoPathOf = (object, named = true) => {
1156
- const parent = infoParents?.get(object);
1157
- const prefix = parent !== undefined ? `${infoPathOf(parent)} . ` : "";
1158
- const name = named ? plainText(object.name).trim() : "";
1159
- const segment = name !== "" && object.kind !== "" ? `${object.kind}: ${name}` :
1160
- (name !== "" ? name : object.kind);
1161
- return prefix + segment;
1283
+ /* determine the corpus description popup key of an object (its SPEC
1284
+ table index), which a schema popup references for its title path,
1285
+ too: "named = false" keeps the last path segment kind-only there,
1286
+ as the kind popups (and the group headers of the compact tables)
1287
+ describe the object class instead of the single instance */
1288
+ const infoRefOf = (object, named = true) => {
1289
+ const key = infoObjects?.get(object);
1290
+ return key !== undefined ? key + (named ? "" : "^") : undefined;
1162
1291
  };
1163
1292
  /* render the schema description popup key and kind-ending title path
1164
- of an object as "data-info" attributes for the manually assembled
1165
- hyperlink markup (the kind popup of the full hyperlinks) */
1293
+ reference of an object as "data-info" attributes for the manually
1294
+ assembled hyperlink markup (the kind popup of the full hyperlinks) */
1166
1295
  const infoAttr = (object) => {
1167
1296
  const key = infoKeyOf(object);
1168
- return key !== undefined ?
1169
- ` data-info="${escapeHtml(key)}" data-info-path="${escapeHtml(infoPathOf(object, false))}"` : "";
1297
+ const ref = infoRefOf(object, false);
1298
+ return key !== undefined && ref !== undefined ?
1299
+ ` data-info="${key}" data-info-path="${ref}"` : "";
1300
+ };
1301
+ /* render the corpus description popup key of an object as the
1302
+ "data-info-spec" attribute for the manually assembled hyperlink
1303
+ markup (the instance popup of the compact and full hyperlinks) */
1304
+ const specAttr = (object) => {
1305
+ const ref = infoRefOf(object);
1306
+ return ref !== undefined ? ` data-info-spec="${ref}"` : "";
1170
1307
  };
1171
- /* render the corpus description popup key (the fully-qualified anchor
1172
- path), anchor id, and name-ending title path of an object as
1173
- "data-info-spec" attributes for the manually assembled hyperlink markup (the
1174
- instance popup of the compact and full hyperlinks) */
1175
- const specAttr = (object) => infoParents !== null ?
1176
- ` data-info-spec="${escapeHtml(anchorOf(object))}" data-info-id="${escapeHtml(object.id)}"` +
1177
- ` data-info-path="${escapeHtml(infoPathOf(object))}"` : "";
1178
- /* collect the description popup map and the qualified title paths of
1179
- the schema nodes (the artifact kind leading its name, the nested
1180
- kinds appended), with the description Markdown of the objects and
1181
- properties pre-rendered to HTML */
1182
- const collectInfo = (nodes, prefix, keys, info) => {
1308
+ /* collect the description popup table of the schema nodes and their
1309
+ keys (the table indices), with the description Markdown of the
1310
+ objects and properties pre-rendered to HTML */
1311
+ const collectInfo = (nodes, keys, info) => {
1183
1312
  for (const schema of nodes) {
1184
- const path = prefix !== "" ? `${prefix} . ${schema.kind}` :
1185
- (schema.name !== undefined ? `${schema.kind}: ${schema.name}` : schema.kind);
1186
- keys.set(schema, path);
1313
+ keys.set(schema, String(info.length));
1187
1314
  const entry = {};
1188
1315
  if (schema.desc !== undefined)
1189
1316
  entry.d = marked.parse(schema.desc, { async: false }).trim();
@@ -1193,17 +1320,17 @@ const collectInfo = (nodes, prefix, keys, info) => {
1193
1320
  entry.p ??= {};
1194
1321
  entry.p[property.name] = marked.parse(property.desc, { async: false }).trim();
1195
1322
  }
1196
- if (entry.d !== undefined || entry.p !== undefined)
1197
- info[path] = entry;
1198
- collectInfo(schema.children ?? [], path, keys, info);
1323
+ info.push(entry);
1324
+ collectInfo(schema.children ?? [], keys, info);
1199
1325
  }
1200
1326
  };
1201
- /* inject the description popup maps into their client-side script ("<"
1327
+ /* inject the description popup tables into their client-side script ("<"
1202
1328
  escaped, so no embedded HTML can close the surrounding <script>
1203
- element) */
1329
+ element, and the trailing table first, so a placeholder text
1330
+ inside an injected table is never taken for the placeholder) */
1204
1331
  const infoScript = (info, spec) => infoPopupScript
1205
- .replace("@INFO@", () => JSON.stringify(info).replace(/</g, "\\u003c"))
1206
- .replace("@SPEC@", () => JSON.stringify(spec).replace(/</g, "\\u003c"));
1332
+ .replace("@SPEC@", () => JSON.stringify(spec).replace(/</g, "\\u003c"))
1333
+ .replace("@INFO@", () => JSON.stringify(info).replace(/</g, "\\u003c"));
1207
1334
  /* expand the inline Markdown of a text (code spans, emphasis, etc.),
1208
1335
  with Wiki-style references expanded upfront (in their compact form
1209
1336
  for prose, i.e. descriptions, in their full form otherwise) */
@@ -1217,11 +1344,12 @@ const isBlock = (text) => {
1217
1344
  const tokens = marked.lexer(text).filter((token) => token.type !== "space");
1218
1345
  return tokens.length > 0 && !(tokens.length === 1 && tokens[0].type === "paragraph");
1219
1346
  };
1220
- /* render a single embedded image file onto an <img> tag with a
1221
- self-contained data: URL (converting the SVG text into one, as an
1222
- SVG inlined as-is would leak its document-global <style> rules
1223
- into all other inlined SVGs sharing the same class names) */
1224
- const renderImage = (content, alt) => {
1347
+ /* render a single embedded image file (in its optimized form) onto an
1348
+ <img> tag with a self-contained data: URL (converting the SVG text
1349
+ into one, as an SVG inlined as-is would leak its document-global
1350
+ <style> rules into all other inlined SVGs sharing the same class names) */
1351
+ const renderImage = (original, alt) => {
1352
+ const content = images?.get(original) ?? original;
1225
1353
  const url = content.startsWith("data:") ? content :
1226
1354
  `data:image/svg+xml;base64,${Buffer.from(content, "utf8").toString("base64")}`;
1227
1355
  return `<img src="${url}" alt="${escapeHtml(alt)}"/>`;
@@ -1229,7 +1357,7 @@ const renderImage = (content, alt) => {
1229
1357
  /* wrap the theme variants of an image into their layout-neutral theme
1230
1358
  containers, of which the stylesheet shows just the one matching the
1231
1359
  color theme currently active in the document */
1232
- const renderThemed = (images) => images.map((image, i) => `<span class="theme-${embeddingThemes[i]}-only">${image}</span>`).join("");
1360
+ const renderThemed = (variants) => variants.map((variant, i) => `<span class="theme-${embeddingThemes[i]}-only">${variant}</span>`).join("");
1233
1361
  /* render the embedded image files of a text into HTML, taking the image
1234
1362
  alternate texts from the corresponding "![alt](file)" markups and
1235
1363
  pairing up the consecutive theme variants of a "{theme}" markup
@@ -1238,18 +1366,17 @@ const renderEmbeddings = (text, embedding) => {
1238
1366
  const result = new Array();
1239
1367
  let i = 0;
1240
1368
  for (const m of text.matchAll(embeddingRegex)) {
1241
- const reference = m[2].trim();
1242
- if (embeddingMimeType(reference) === undefined)
1369
+ const count = embeddingCount(m[2]);
1370
+ if (count === 0)
1243
1371
  continue;
1244
- const variants = embeddingVariants(reference);
1245
- const contents = embedding.slice(i, i + variants.length);
1246
- const images = contents.filter((content) => content !== "")
1372
+ const contents = embedding.slice(i, i + count);
1373
+ const variants = contents.filter((content) => content !== "")
1247
1374
  .map((content) => renderImage(content, m[1].trim()));
1248
- i += variants.length;
1249
- if (variants.length > 1 && images.length === variants.length)
1250
- result.push(renderThemed(images));
1375
+ i += count;
1376
+ if (count > 1 && variants.length === count)
1377
+ result.push(renderThemed(variants));
1251
1378
  else
1252
- result.push(...images);
1379
+ result.push(...variants);
1253
1380
  }
1254
1381
  return result;
1255
1382
  };
@@ -1260,7 +1387,7 @@ const embeddingMarkup = new RegExp(`[ \\t]*${embeddingRegex.source}`, "g");
1260
1387
  moving the file embeddings to the end of the description */
1261
1388
  const renderDescription = (description) => {
1262
1389
  const text = description.description
1263
- .replace(embeddingMarkup, (markup, _alt, reference) => embeddingMimeType(reference.trim()) !== undefined ? "" : markup)
1390
+ .replace(embeddingMarkup, (markup, _alt, reference) => embeddingCount(reference) > 0 ? "" : markup)
1264
1391
  .trim();
1265
1392
  const embeddings = renderEmbeddings(description.description, description.embedding ?? []).map((content) => safe(content));
1266
1393
  const blocked = isBlock(text);
@@ -1272,17 +1399,18 @@ const renderDescription = (description) => {
1272
1399
  embeddings
1273
1400
  } });
1274
1401
  };
1275
- /* collect the description popup map of the object instances: the
1276
- corpus description Markdown of every object, pre-rendered to HTML
1277
- and keyed by the fully-qualified anchor path (the embedded images
1278
- and the rationale are left out, as the popup shows the prose alone) */
1402
+ /* collect the description popups of the object instances: the corpus
1403
+ description Markdown of every object, pre-rendered to HTML into
1404
+ its SPEC table entry (the embedded images and the rationale are
1405
+ left out, as the popup shows the prose alone) */
1279
1406
  const collectSpec = (objects, spec) => {
1280
1407
  for (const object of objects) {
1281
1408
  const text = (object.description?.description ?? "")
1282
- .replace(embeddingMarkup, (markup, _alt, reference) => embeddingMimeType(reference.trim()) !== undefined ? "" : markup)
1409
+ .replace(embeddingMarkup, (markup, _alt, reference) => embeddingCount(reference) > 0 ? "" : markup)
1283
1410
  .trim();
1284
- if (text !== "")
1285
- spec[anchorOf(object)] = scoped(object, () => block(text, true).toString().trim());
1411
+ const key = infoObjects?.get(object);
1412
+ if (key !== undefined && text !== "")
1413
+ spec[Number(key)][4] = scoped(object, () => block(text, true).toString().trim());
1286
1414
  collectSpec(object.children, spec);
1287
1415
  }
1288
1416
  };
@@ -1325,7 +1453,7 @@ const inlineValue = (kind, property) => {
1325
1453
  const { key, value, embedding } = property;
1326
1454
  const expr = members?.get(`${kind} ${key}`);
1327
1455
  const text = value
1328
- .replace(embeddingMarkup, (markup, _alt, reference) => embeddingMimeType(reference.trim()) !== undefined ? "" : markup)
1456
+ .replace(embeddingMarkup, (markup, _alt, reference) => embeddingCount(reference) > 0 ? "" : markup)
1329
1457
  .trim();
1330
1458
  const embeddings = renderEmbeddings(value, embedding ?? [])
1331
1459
  .map((content) => `<div class="embedding">${content}</div>`).join("");
@@ -1344,7 +1472,7 @@ const inlineValue = (kind, property) => {
1344
1472
  entries injected for unused properties (foreign to the object)
1345
1473
  rendered as absent */
1346
1474
  const inlineProperties = (object, properties) => properties.map((property) => ({ key: property.key,
1347
- info: infoKeyOf(object), infopath: infoPathOf(object),
1475
+ info: infoKeyOf(object), infopath: infoRefOf(object),
1348
1476
  value: inlineValue(object.kind, object.properties.includes(property) ? property : undefined) }));
1349
1477
  /* resolve the format configuration of the kind of an object */
1350
1478
  const formatOf = (object) => schemas?.get(object)?.format;
@@ -1427,7 +1555,7 @@ const renderCell = (child) => {
1427
1555
  (empty for an object without a configured or renderable diagram) */
1428
1556
  const diagramOf = (object) => {
1429
1557
  const diagram = diagrams?.get(object);
1430
- return diagram !== undefined ? safe(`<div class="diagram">${diagram}</div>`) : "";
1558
+ return diagram !== undefined ? safe(diagram) : "";
1431
1559
  };
1432
1560
  /* the reference coverage of an object as a table: one row per
1433
1561
  configured pattern, labeled by the kinds of the matching objects,
@@ -1453,6 +1581,59 @@ const coverageOf = (object) => {
1453
1581
  even: i % 2 === 1
1454
1582
  })) }));
1455
1583
  };
1584
+ /* the estimated number of characters a text line spanning the
1585
+ entire content width holds (60rem at the document font) */
1586
+ const lineChars = 140;
1587
+ /* omit the long texts of the comparable cells of a table row, the
1588
+ server-side heuristic counterpart of the client-side cell text
1589
+ folding, judging a cell by its estimated number of text lines (its
1590
+ plain text per block, wrapped at the characters its column width
1591
+ share holds per line) instead of its rendered height: a cell
1592
+ exceeding every other one by more than the "maxCellHeight"
1593
+ percentage is cut back onto the whole lines of that limit (less the
1594
+ room of the mark) at a word boundary, its open elements closed
1595
+ again, and ends in a grey "[...]" mark,
1596
+ unless that hides less than 25% of it, where the empty cells and the
1597
+ ones carrying further cells or a diagram take no part at all */
1598
+ const omitLong = (cells, shares, percent = 0) => {
1599
+ if (omits?.has("text:long") !== true)
1600
+ return cells;
1601
+ const htmls = cells.map(String);
1602
+ const plain = (html) => html.replace(/<[^>]*>/g, "").trim().length;
1603
+ const lengths = htmls.map((html) => (/<td[\s>]|<div class="diagram"/).test(html) ? 0 : plain(html));
1604
+ if (lengths.filter((length) => length > 0).length < 2)
1605
+ return cells;
1606
+ const chars = shares.map((share) => Math.max(1, Math.floor(lineChars * share)));
1607
+ const lines = htmls.map((html, i) => lengths[i] === 0 ? 0 :
1608
+ html.split(/<\/(?:p|li|div|pre)>|<br\s*\/?>/)
1609
+ .reduce((sum, block) => sum + Math.ceil(plain(block) / chars[i]), 0));
1610
+ return htmls.map((html, i) => {
1611
+ const limit = Math.floor(Math.max(...lines.filter((_, j) => j !== i)) *
1612
+ (1 + (percent > 0 ? percent : 40) / 100)) * chars[i] - " [...]".length;
1613
+ if (lines[i] === 0 || limit > lengths[i] * 0.75)
1614
+ return cells[i];
1615
+ const open = new Array();
1616
+ let out = "";
1617
+ let count = 0;
1618
+ for (const [token] of html.matchAll(/<[^>]*>|[^<]+/g)) {
1619
+ if (token.startsWith("<")) {
1620
+ const tag = (/^<(\/?)([a-zA-Z][^\s/>]*)[^>]*?(\/?)>$/).exec(token);
1621
+ if (tag?.[1] === "/")
1622
+ open.pop();
1623
+ else if (tag !== null && tag[3] !== "/" && !(/^(?:br|hr|img|wbr)$/).test(tag[2]))
1624
+ open.push(tag[2]);
1625
+ }
1626
+ else if (count + token.length > limit)
1627
+ return safe(out + token.slice(0, limit - count).replace(/\S*$/, "") +
1628
+ "<span class=\"omit\">[...]</span>" +
1629
+ open.reverse().map((name) => `</${name}>`).join(""));
1630
+ else
1631
+ count += token.length;
1632
+ out += token;
1633
+ }
1634
+ return cells[i];
1635
+ });
1636
+ };
1456
1637
  /* render a single-kind group of children into one compact table:
1457
1638
  the name first, then the property columns, then the description;
1458
1639
  a group wider than maxColumns, or carrying diagrams, instead chunks
@@ -1462,31 +1643,33 @@ const coverageOf = (object) => {
1462
1643
  headers of a plain table */
1463
1644
  const renderTable = (children, maxColumns) => {
1464
1645
  const { keys, desc } = tableShape(children);
1465
- const diagrammed = children.some((child) => diagramOf(child) !== "");
1646
+ const diagrammed = children.some((child) => diagrams?.has(child) === true);
1466
1647
  if (!diagrammed && 1 + keys.length + (desc ? 1 : 0) <= maxColumns)
1467
1648
  return render("Table", { Table: {
1468
1649
  head: children[0].kind !== "" ? children[0].kind : "Name",
1469
1650
  info: infoKeyOf(children[0]),
1470
- infopath: infoPathOf(children[0], false),
1651
+ infopath: infoRefOf(children[0], false),
1471
1652
  keys,
1472
1653
  desc,
1473
1654
  fold: formatOf(children[0])?.maxCellHeight,
1474
1655
  /* under the fixed table layout the description column claims
1475
1656
  twice the share of a regular column, compressing the others */
1476
1657
  width: Math.round(200 / (keys.length + 3)),
1477
- rows: children.map((child, i) => scoped(child, () => ({
1478
- id: anchorOf(child),
1479
- anchor: child.anchor,
1480
- paren: child.paren,
1481
- primary: child.primary,
1482
- spec: infoParents !== null ? anchorOf(child) : undefined,
1483
- specid: child.id,
1484
- specpath: infoPathOf(child),
1485
- name: inline(child.name),
1486
- even: i % 2 === 1,
1487
- values: keys.map((key) => inlineValue(child.kind, child.properties.find((property) => property.key === key))),
1488
- description: safe(renderCell(child))
1489
- })))
1658
+ rows: children.map((child, i) => scoped(child, () => {
1659
+ const cells = omitLong([...keys.map((key) => inlineValue(child.kind, child.properties.find((property) => property.key === key))),
1660
+ safe(renderCell(child))].slice(0, keys.length + (desc ? 1 : 0)), [...keys.map(() => 1), 2].map((share) => share / (keys.length + (desc ? 3 : 1))), formatOf(children[0])?.maxCellHeight);
1661
+ return {
1662
+ id: anchorOf(child),
1663
+ anchor: child.anchor,
1664
+ paren: child.paren,
1665
+ primary: child.primary,
1666
+ spec: infoRefOf(child),
1667
+ name: inline(child.name),
1668
+ even: i % 2 === 1,
1669
+ values: cells.slice(0, keys.length),
1670
+ description: cells[keys.length]
1671
+ };
1672
+ }))
1490
1673
  } });
1491
1674
  /* the embedded rows hold at most maxColumns - 1 cells (of the
1492
1675
  group-wide union of property keys, plus the trailing description),
@@ -1495,7 +1678,8 @@ const renderTable = (children, maxColumns) => {
1495
1678
  return render("TableChunked", { Table: {
1496
1679
  head: children[0].kind !== "" ? children[0].kind : "Name",
1497
1680
  info: infoKeyOf(children[0]),
1498
- infopath: infoPathOf(children[0], false),
1681
+ infopath: infoRefOf(children[0], false),
1682
+ desc,
1499
1683
  fold: formatOf(children[0])?.maxCellHeight,
1500
1684
  width: Math.round(100 / maxColumns),
1501
1685
  rows: children.map((child, i) => scoped(child, () => {
@@ -1511,6 +1695,9 @@ const renderTable = (children, maxColumns) => {
1511
1695
  const last = chunks[chunks.length - 1];
1512
1696
  last[last.length - 1].span = size - last.length + 1;
1513
1697
  }
1698
+ for (const chunk of chunks)
1699
+ omitLong(chunk.map((cell) => cell.value), chunk.map((cell) => cell.span / maxColumns), formatOf(children[0])?.maxCellHeight)
1700
+ .forEach((value, k) => { chunk[k].value = value; });
1514
1701
  /* the diagram leads the chunks as a full-width chunk of its
1515
1702
  own, headed like the description */
1516
1703
  const diagram = diagramOf(child);
@@ -1521,9 +1708,7 @@ const renderTable = (children, maxColumns) => {
1521
1708
  anchor: child.anchor,
1522
1709
  paren: child.paren,
1523
1710
  primary: child.primary,
1524
- spec: infoParents !== null ? anchorOf(child) : undefined,
1525
- specid: child.id,
1526
- specpath: infoPathOf(child),
1711
+ spec: infoRefOf(child),
1527
1712
  name: inline(child.name),
1528
1713
  even: i % 2 === 1,
1529
1714
  chunks
@@ -1538,10 +1723,8 @@ const renderObject = (object, level, concise) => {
1538
1723
  level: Math.min(level, 6),
1539
1724
  kind: object.kind,
1540
1725
  info: infoKeyOf(object),
1541
- infopath: infoPathOf(object, false),
1542
- spec: infoParents !== null ? anchorOf(object) : undefined,
1543
- specid: object.id,
1544
- specpath: infoPathOf(object),
1726
+ infopath: infoRefOf(object, false),
1727
+ spec: infoRefOf(object),
1545
1728
  id: anchorOf(object),
1546
1729
  anchor: object.anchor,
1547
1730
  paren: object.paren,
@@ -1611,8 +1794,7 @@ const tocEntries = (objects, pages) => {
1611
1794
  for (const object of objects) {
1612
1795
  const id = anchorOf(object);
1613
1796
  entries.push({ id, kind: object.kind,
1614
- info: infoKeyOf(object), infopath: infoPathOf(object, false),
1615
- spec: infoParents !== null ? id : undefined, specid: object.id, specpath: infoPathOf(object),
1797
+ info: infoKeyOf(object), infopath: infoRefOf(object, false), spec: infoRefOf(object),
1616
1798
  name: inline(object.name), level: Math.min(level, 6), page: pages?.get(id) });
1617
1799
  collect(headingChildren(object), level + 1);
1618
1800
  }
@@ -1630,10 +1812,8 @@ const renderTocPanel = (objects, title, doc) => {
1630
1812
  id: anchorOf(object),
1631
1813
  kind: object.kind,
1632
1814
  info: infoKeyOf(object),
1633
- infopath: infoPathOf(object, false),
1634
- spec: infoParents !== null ? anchorOf(object) : undefined,
1635
- specid: object.id,
1636
- specpath: infoPathOf(object),
1815
+ infopath: infoRefOf(object, false),
1816
+ spec: infoRefOf(object),
1637
1817
  name: inline(object.name),
1638
1818
  children: safe(entries(headingChildren(object)))
1639
1819
  })) });
@@ -1668,24 +1848,43 @@ export const titlePageObject = (specification) => {
1668
1848
  /* pre-render the configured diagrams as embeddable SVGs, displayed
1669
1849
  at a reduced coordinate scale, as the Gradia geometry (node
1670
1850
  boxes, font sizes) is dimensioned for a stand-alone canvas and
1671
- would dwarf the document text at 1:1 */
1672
- const scaledDiagrams = async (specification, config, verbose) => {
1673
- const scale = 0.75;
1851
+ would dwarf the document text at 1:1: the scale renders the node
1852
+ names (30 units) at 15px, about the size of the document text, so a
1853
+ diagram narrower than its container is no longer blown up beyond
1854
+ the zoom level of the diagrams the container width caps anyway. The
1855
+ <style> elements are stripped off the diagrams and the union of
1856
+ their CSS rules is returned for the document stylesheet instead, as
1857
+ Gradia names a class after its declarations and hence hundreds of
1858
+ diagrams repeat the very same few rules (which are XML-escaped
1859
+ inside an SVG, but plain text inside the HTML <style> element,
1860
+ which no unescaped "</" of a configured value may close) */
1861
+ const scaledDiagrams = async (specification, config, verbose, omit) => {
1862
+ const scale = 0.5;
1674
1863
  const rendered = new Map();
1675
- for (const [object, result] of await renderDiagrams(specification, config, verbose)) {
1864
+ const rules = new Set();
1865
+ const entities = { amp: "&", lt: "<", gt: ">", quot: "\"" };
1866
+ for (const [object, result] of await renderDiagrams(specification, config, verbose, omit)) {
1867
+ const svg = result.svg.replace(/<style>([\s\S]*?)<\/style>\s*/g, (_, css) => {
1868
+ for (const rule of css.split("\n"))
1869
+ if (rule.trim() !== "")
1870
+ rules.add(rule.trim()
1871
+ .replace(/&(amp|lt|gt|quot);/g, (_, name) => entities[name])
1872
+ .replace(/<\//g, "<\\/"));
1873
+ return "";
1874
+ });
1676
1875
  /* a "hub" diagram is capped to the width share it would
1677
1876
  occupy on its full three-column canvas (padded by the
1678
1877
  minimum widths of the absent columns and their channels),
1679
1878
  so all hub diagrams share the zoom level of the
1680
- three-column ones and are centered in the leftover space */
1879
+ three-column ones, the leftover space splitting evenly, as
1880
+ the stylesheet centers every diagram */
1681
1881
  const absent = result.columns !== undefined ? 3 - result.columns : 0;
1682
1882
  const pad = absent * ((result.config?.["size-node-width-min"] ?? Gradia.config["size-node-width-min"]) +
1683
1883
  (result.config?.["hub-channel-width-min"] ?? Gradia.config["hub-channel-width-min"]));
1684
- rendered.set(object, result.svg.replace(/(<svg[^>]*) width="([0-9.]+)" height="([0-9.]+)"/, (_, head, w, h) => `${head} width="${Number(w) * scale}" height="${Number(h) * scale}"` +
1685
- (result.columns !== undefined ? " class=\"hub\"" : "") +
1884
+ rendered.set(object, svg.replace(/(<svg[^>]*) width="([0-9.]+)" height="([0-9.]+)"/, (_, head, w, h) => `${head} width="${Number(w) * scale}" height="${Number(h) * scale}"` +
1686
1885
  (absent > 0 ? ` style="max-width: ${(Number(w) / (Number(w) + pad) * 100).toFixed(2)}%"` : "")));
1687
1886
  }
1688
- return rendered;
1887
+ return { svgs: rendered, css: Array.from(rules).join("\n") };
1689
1888
  };
1690
1889
  /* create the reference expander of a document: "[[xxx]]" references
1691
1890
  expand into hyperlinks (an unresolvable or ambiguous reference stays
@@ -1724,12 +1923,38 @@ export const renderPlaceholder = (message) => render("Placeholder", { Placeholde
1724
1923
  /* render the entire specification into a self-contained HTML document,
1725
1924
  with the build-time pre-assembled stylesheet embedded inline, the
1726
1925
  artifact timestamps aggregated into min(Created)/max(Modified),
1727
- optional per-anchor page numbers attached to the ToC entries, and
1728
- optionally the client-side script of the live preview injected */
1729
- export const renderHtml = async (specification, config, tocPages, css, realtime = false, verbose) => {
1730
- /* pre-render the configured diagrams as scaled embeddable SVGs */
1926
+ optional per-anchor page numbers attached to the ToC entries,
1927
+ optionally the client-side script of the live preview injected and
1928
+ aspects omitted (see "ExportOptions"), and the embedded images
1929
+ optimized for the screen or for print (the PDF) */
1930
+ export const renderHtml = async (specification, config, tocPages, css, options = {}, verbose, print = false) => {
1931
+ /* pre-render the configured diagrams as scaled embeddable SVGs
1932
+ (except for the omitted ones, which hence leave no trace at all) */
1933
+ const omit = options.omit ?? new Set();
1731
1934
  const rendered = config !== undefined ?
1732
- await scaledDiagrams(specification, config, verbose) : null;
1935
+ await scaledDiagrams(specification, config, verbose, omit) : null;
1936
+ /* the fold controls of the omitted aspects leave along with the
1937
+ implied ones (no type control without diagrams of that type, no
1938
+ level control without diagrams from that level on, no fold tab
1939
+ without any control, which lets the tabs below it move up), and
1940
+ the omitted long texts bring the style of their "[...]" mark */
1941
+ const none = omit.has("diagram:1")
1942
+ || (omit.has("diagram:graph") && omit.has("diagram:hub") && omit.has("diagram:grid"));
1943
+ const omitted = {
1944
+ graph: none || omit.has("diagram:graph"),
1945
+ hub: none || omit.has("diagram:hub"),
1946
+ grid: none || omit.has("diagram:grid"),
1947
+ level1: none,
1948
+ level2: none || omit.has("diagram:2"),
1949
+ level3: none || omit.has("diagram:2") || omit.has("diagram:3"),
1950
+ text: omit.has("text:long"),
1951
+ all: none && omit.has("text:long")
1952
+ };
1953
+ const omitCss = (omitted.text ? "\nspan.omit { color: var(--theme-color-specbook-muted) }" : "") +
1954
+ (omitted.all ? "\nnav.toc-panel div.toc-tab { top: 10.66rem }" +
1955
+ "\ndiv.realtime-status { top: 13.89rem }" : "");
1956
+ /* pre-optimize the embedded images (downscaled and re-encoded) */
1957
+ const optimized = await optimizeImages(specification, print, verbose);
1733
1958
  /* the document language selects the smart typography quote style */
1734
1959
  const lang = documentLang(specification);
1735
1960
  quotes = quoteStyles[lang?.toLowerCase().split(/[-_]/)[0] ?? "en"] ?? quoteStyles.en;
@@ -1740,31 +1965,45 @@ export const renderHtml = async (specification, config, tocPages, css, realtime
1740
1965
  anchors = anchorPaths(index);
1741
1966
  members = config !== undefined ? collectMembers(config, new Map()) : null;
1742
1967
  schemas = config !== undefined ? collectSchemas(specification, config) : null;
1743
- diagrams = rendered;
1968
+ images = optimized;
1744
1969
  coverages = schemas !== null ? specCoverage(index, schemas) : null;
1970
+ omits = omit;
1971
+ /* wrap the diagrams into their blocks, carrying the diagram type
1972
+ and the object tree nesting level the folding distinguishes */
1973
+ diagrams = rendered !== null ? new Map() : null;
1974
+ for (const node of index) {
1975
+ const svg = rendered?.svgs.get(node.object);
1976
+ if (svg === undefined)
1977
+ continue;
1978
+ let level = 1;
1979
+ for (let parent = node.parent; parent !== undefined; parent = parent.parent)
1980
+ level++;
1981
+ const type = schemas?.get(node.object)?.diagram?.type ?? "graph";
1982
+ diagrams?.set(node.object, `<div class="diagram" data-type="${type}" data-level="${level}">${svg}</div>`);
1983
+ }
1745
1984
  /* collect the schema descriptions for the description popups,
1746
- plus the object parents composing their title paths */
1985
+ plus the object table composing their title paths */
1747
1986
  let info = null;
1987
+ const spec = [];
1748
1988
  if (config !== undefined) {
1749
1989
  infoKeys = new Map();
1750
- info = {};
1751
- collectInfo(config, "", infoKeys, info);
1752
- const parents = new Map();
1990
+ info = [];
1991
+ collectInfo(config, infoKeys, info);
1992
+ const objects = new Map();
1753
1993
  for (const node of index)
1754
- if (node.parent !== undefined)
1755
- parents.set(node.object, node.parent.object);
1756
- infoParents = parents;
1994
+ objects.set(node.object, String(objects.size));
1995
+ for (const node of index)
1996
+ spec.push([node.object.id, node.parent !== undefined ? Number(objects.get(node.parent.object)) : -1,
1997
+ node.object.kind, plainText(node.object.name).trim(), ""]);
1998
+ infoObjects = objects;
1757
1999
  }
1758
2000
  linker = makeLinker(index);
1759
2001
  /* collect the corpus descriptions of the object instances for the
1760
2002
  description popups (after the linker is in place, as the
1761
2003
  pre-rendered descriptions expand their references, too) */
1762
- let spec = null;
1763
- if (info !== null) {
1764
- spec = {};
2004
+ if (info !== null)
1765
2005
  for (const artifact of specification.artifacts)
1766
2006
  collectSpec(artifact.objects, spec);
1767
- }
1768
2007
  /* the artifact timestamps aggregate into the earliest creation
1769
2008
  and the latest modification timestamp of the document */
1770
2009
  const created = new Date(Math.min(...specification.artifacts.map((artifact) => artifact.created.getTime())));
@@ -1775,23 +2014,24 @@ export const renderHtml = async (specification, config, tocPages, css, realtime
1775
2014
  const artifacts = specification.artifacts
1776
2015
  .filter((artifact) => !artifact.objects.some(isTitleObject));
1777
2016
  const meta = titleObject(specification);
1778
- const doc = meta !== undefined ? rendered?.get(meta) : undefined;
2017
+ const doc = meta !== undefined ? diagrams?.get(meta) : undefined;
1779
2018
  const objects = artifacts.flatMap((artifact) => artifact.objects);
1780
2019
  const entries = tocEntries(objects, tocPages);
1781
2020
  return render("Document", { Document: {
1782
2021
  title: documentTitle(specification).title,
1783
2022
  lang,
1784
2023
  theme: documentThemeStyle(specification)?.toLowerCase(),
1785
- css: safe(css ?? stylesheet()),
2024
+ css: safe((css ?? stylesheet()) + (rendered !== null ? `\n${rendered.css}` : "") + omitCss),
1786
2025
  themescript: safe(themeScript),
1787
2026
  titlepage: title !== undefined ?
1788
2027
  safe(renderTitlePage(title, formatDate(created), formatDate(modified))) : "",
1789
2028
  search: safe(searchScript()),
1790
2029
  progress: safe(scrollProgressScript),
1791
- fold: safe(foldScript),
2030
+ omitted,
2031
+ fold: safe(foldScript(omit.size > 0, omitted.text)),
1792
2032
  maximize: safe(maximizeScript),
1793
- info: info !== null ? safe(infoScript(info, spec ?? {})) : "",
1794
- realtime: realtime ? safe(realtimeScript) : "",
2033
+ info: info !== null ? safe(infoScript(info, spec)) : "",
2034
+ realtime: options.realtime === true ? safe(realtimeScript) : "",
1795
2035
  toc: entries.length > 0 ? safe(render("Toc", { Toc: { entries } })) : "",
1796
2036
  tocpanel: entries.length > 0 ?
1797
2037
  safe(renderTocPanel(objects, title !== undefined, doc !== undefined)) : "",
@@ -1809,8 +2049,10 @@ export const renderHtml = async (specification, config, tocPages, css, realtime
1809
2049
  members = null;
1810
2050
  schemas = null;
1811
2051
  diagrams = null;
2052
+ images = null;
1812
2053
  coverages = null;
1813
2054
  infoKeys = null;
1814
- infoParents = null;
2055
+ infoObjects = null;
2056
+ omits = null;
1815
2057
  }
1816
2058
  };