@rse/specbook 1.2.16 → 1.3.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.
@@ -4,6 +4,7 @@
4
4
  ** Licensed under Apache 2.0 <https://spdx.org/licenses/Apache-2.0>
5
5
  */
6
6
  import fs from "node:fs";
7
+ import { embeddingThemes } from "./specbook-parse-common.js";
7
8
  import { plainText } from "./specbook-link.js";
8
9
  /* escape a text for embedding into template HTML (text and attributes) */
9
10
  export const escapeHtml = (text) => text.replace(/&/g, "&amp;").replace(/</g, "&lt;").replace(/>/g, "&gt;")
@@ -26,9 +27,9 @@ export const omitAspects = ["diagram:graph", "diagram:hub", "diagram:grid",
26
27
  plain "diagram" is the alias of "diagram:1", hence of all diagrams */
27
28
  export const parseOmit = (omit = []) => {
28
29
  const aspects = new Set();
29
- for (const item of omit.flatMap((list) => list.split(",")).map((item) => item.trim())) {
30
- const aspect = item === "diagram" ? "diagram:1" : item;
31
- if (!omitAspects.includes(aspect))
30
+ for (const item of omit.flatMap((list) => list.split(",")).map((entry) => entry.trim())) {
31
+ const aspect = omitAspects.find((known) => known === (item === "diagram" ? "diagram:1" : item));
32
+ if (aspect === undefined)
32
33
  throw new Error(`unknown omit aspect "${item}" ` +
33
34
  `(expected "diagram" or one of "${omitAspects.join("\", \"")}")`);
34
35
  aspects.add(aspect);
@@ -52,22 +53,33 @@ export const titleObject = (specification) => {
52
53
  };
53
54
  return search(specification.artifacts.flatMap((artifact) => artifact.objects));
54
55
  };
55
- /* determine a property value of the title object (a present but empty
56
- value counting as an absent property, so every consumer falls back
57
- onto its own default) */
56
+ /* determine the (trimmed) value of a property of the title object (a
57
+ present but empty value counting as an absent property, so every
58
+ consumer falls back onto its own default) */
58
59
  const titleProperty = (specification, name) => {
59
60
  const value = titleObject(specification)
60
- ?.properties.find((property) => property.key === name)?.value;
61
- return value !== undefined && value.trim() !== "" ? value : undefined;
61
+ ?.properties.find((property) => property.key === name)?.value.trim();
62
+ return value !== undefined && value !== "" ? value : undefined;
62
63
  };
63
64
  /* determine the document language (LANG) from the title object */
64
- export const documentLang = (specification) => titleProperty(specification, "LANG")?.trim();
65
+ export const documentLang = (specification) => titleProperty(specification, "LANG");
65
66
  /* determine the document character set (CHARSET) from the title object */
66
- export const documentCharset = (specification) => titleProperty(specification, "CHARSET")?.trim();
67
- /* determine the document theme style (THEME-STYLE) from the title object */
68
- export const documentThemeStyle = (specification) => titleProperty(specification, "THEME-STYLE")?.trim();
67
+ export const documentCharset = (specification) => titleProperty(specification, "CHARSET");
68
+ /* determine the document theme style (THEME-STYLE) from the title
69
+ object, lower-cased and rejecting an unknown style (the styles being
70
+ the very themes the "{theme}" image embeddings know) */
71
+ export const documentThemeStyle = (specification) => {
72
+ const value = titleProperty(specification, "THEME-STYLE");
73
+ if (value === undefined)
74
+ return undefined;
75
+ const style = embeddingThemes.find((name) => name === value.toLowerCase());
76
+ if (style === undefined)
77
+ throw new Error(`unknown theme style "${value}" ` +
78
+ `(expected ${embeddingThemes.join(", ")})`);
79
+ return style;
80
+ };
69
81
  /* determine the document theme color tone (THEME-TONE) from the title object */
70
- export const documentThemeTone = (specification) => titleProperty(specification, "THEME-TONE")?.trim();
82
+ export const documentThemeTone = (specification) => titleProperty(specification, "THEME-TONE");
71
83
  /* the supported paper sizes for print: ISO A4 in millimeters and the
72
84
  two US sizes closest to it in inches, each with the default margins
73
85
  of 1in (25mm) at the top/bottom and 0.8in (20mm) at the left/right */
@@ -86,7 +98,7 @@ export const paperLength = (setup, value) => `${value}${setup.unit}`;
86
98
  matched case-insensitively, falling back onto the default if unset
87
99
  and rejecting an unknown size */
88
100
  export const documentPaperSize = (specification) => {
89
- const value = titleProperty(specification, "PAPER-SIZE")?.trim();
101
+ const value = titleProperty(specification, "PAPER-SIZE");
90
102
  if (value === undefined)
91
103
  return paperSizeDefault;
92
104
  const paper = paperSizes.find((name) => name.toLowerCase() === value.toLowerCase());
@@ -204,11 +216,10 @@ export const subsetStylesheet = async (charset) => {
204
216
  the inline code markup stripped, as the plain-text targets (the HTML
205
217
  <title>, the PDF metadata and page header) render no Markdown */
206
218
  export const documentTitle = (specification) => {
207
- const title = titleProperty(specification, "TITLE")?.trim();
208
- const subtitle = titleProperty(specification, "SUBTITLE")?.trim();
219
+ const title = titleProperty(specification, "TITLE");
220
+ const subtitle = titleProperty(specification, "SUBTITLE");
209
221
  return {
210
- title: plainText(title !== undefined && title !== "" ? title :
211
- titleObject(specification)?.name ?? "Specification"),
222
+ title: plainText(title ?? "Specification"),
212
223
  subtitle: subtitle !== undefined ? plainText(subtitle) : undefined
213
224
  };
214
225
  };
@@ -96,7 +96,7 @@
96
96
  event.preventDefault()
97
97
  reset()
98
98
  slide(false)
99
- link.dispatchEvent(new MouseEvent("click", { bubbles: true, cancelable: true }))
99
+ link.click()
100
100
  })
101
101
  })
102
102
  }
@@ -273,8 +273,11 @@
273
273
  to two passes, literal substrings first and, only if those match
274
274
  nothing at all, the fuzzy variants, as the edit-distance
275
275
  neighborhood of a word is wide enough to dilute a correctly
276
- spelled query and has to stay a fallback for misspelled ones */
276
+ spelled query and has to stay a fallback for misspelled ones;
277
+ a debounced run a live preview body swap left behind is dropped */
277
278
  const search = () => {
279
+ if (!tab.isConnected)
280
+ return
278
281
  unmark()
279
282
  const parts = input.value.toLowerCase().split(",")
280
283
  .map((part) => part.split(/\s+/).filter((word) => word !== ""))
@@ -351,14 +354,17 @@
351
354
  panel entries) along: an entry stays visible only while its
352
355
  target still is, which covers the front matter entries of
353
356
  the panel, too, as their targets are suppressed by the
354
- search mode anyway */
357
+ search mode anyway, where the entries are hidden only after
358
+ all targets got measured, as each hiding would force a layout again */
359
+ const hidden = []
355
360
  document.querySelectorAll("nav.toc table tr, nav.toc-panel div.toc-list li").forEach((entry) => {
356
361
  const link = entry.querySelector(":scope > a, :scope > td > a")
357
362
  const target = link !== null ?
358
363
  document.getElementById(decodeURIComponent(link.hash.slice(1))) : null
359
364
  if (target === null || target.getClientRects().length === 0)
360
- entry.classList.add("search-hide")
365
+ hidden.push(entry)
361
366
  })
367
+ hidden.forEach((entry) => { entry.classList.add("search-hide") })
362
368
  }
363
369
 
364
370
  /* run the search debounced on every keystroke (750ms after the
@@ -202,6 +202,10 @@ body::before {
202
202
  padding: 0;
203
203
  }
204
204
  }
205
+ /* a tab riding right of the brand bar (fixed independent of the
206
+ content scrolling, at its vertical offset and inset by a pixel or
207
+ two from the bar), which follows the bar when the table of contents
208
+ panel shifts it aside */
205
209
  /* the theme-switching tab (screen-only): the topmost tab riding right
206
210
  of the brand bar above the info tab (sized like the table of
207
211
  contents tab, fixed independent of the content scrolling), showing
@@ -213,12 +217,12 @@ div.theme-switch {
213
217
  top: 0.97rem;
214
218
  left: calc(0.6rem - 1px);
215
219
  z-index: 30;
216
- padding: 0.9rem 0.35rem;
217
- line-height: 0;
218
220
  border-radius: 0 0.3rem 0.3rem 0;
219
221
  color: var(--theme-color-specbook-background);
220
222
  background-color: var(--theme-color-specbook-accent);
221
223
  transition: left 0.3s ease;
224
+ padding: 0.9rem 0.35rem;
225
+ line-height: 0;
222
226
  cursor: pointer;
223
227
  -webkit-user-select: none;
224
228
  user-select: none;
@@ -253,12 +257,12 @@ div.info-switch {
253
257
  top: 4.2rem;
254
258
  left: calc(0.6rem - 1px);
255
259
  z-index: 30;
256
- padding: 0.65rem 0.1rem;
257
- line-height: 0;
258
260
  border-radius: 0 0.3rem 0.3rem 0;
259
261
  color: var(--theme-color-specbook-background);
260
262
  background-color: var(--theme-color-specbook-accent);
261
263
  transition: left 0.3s ease;
264
+ padding: 0.65rem 0.1rem;
265
+ line-height: 0;
262
266
  cursor: pointer;
263
267
  -webkit-user-select: none;
264
268
  user-select: none;
@@ -286,6 +290,8 @@ body.info-on div.info-switch svg.info-icon {
286
290
  display: none;
287
291
  }
288
292
  }
293
+ /* the circle icon leading an object kind (filled) or a property
294
+ name (not filled), sized and aligned onto the text */
289
295
  /* the description popup (screen-only): a single shared, script-
290
296
  positioned element showing the schema description of the hovered
291
297
  object kind or property name, or the corpus description of the
@@ -440,12 +446,12 @@ div.fold-switch {
440
446
  top: 10.66rem;
441
447
  left: calc(0.6rem - 1px);
442
448
  z-index: 30;
443
- display: flex;
444
- align-items: center;
445
449
  border-radius: 0 0.3rem 0.3rem 0;
446
450
  color: var(--theme-color-specbook-background);
447
451
  background-color: var(--theme-color-specbook-accent);
448
452
  transition: left 0.3s ease;
453
+ display: flex;
454
+ align-items: center;
449
455
  }
450
456
  body:has(nav.toc-panel.open) div.fold-switch {
451
457
  left: calc(31rem - 1px);
@@ -694,12 +700,12 @@ div.realtime-status {
694
700
  top: 17.12rem;
695
701
  left: calc(0.6rem - 2px);
696
702
  z-index: 30;
697
- padding: 0.65rem 0.1rem;
698
- line-height: 0;
699
703
  border-radius: 0 0.3rem 0.3rem 0;
700
704
  color: var(--theme-color-specbook-background);
701
705
  background-color: var(--theme-color-specbook-accent);
702
706
  transition: left 0.3s ease;
707
+ padding: 0.65rem 0.1rem;
708
+ line-height: 0;
703
709
  -webkit-user-select: none;
704
710
  user-select: none;
705
711
  }
@@ -954,7 +960,6 @@ h4 {
954
960
  h4,
955
961
  h5,
956
962
  h6 {
957
- page-break-after: avoid;
958
963
  break-after: avoid;
959
964
  }
960
965
  }
@@ -1001,21 +1006,21 @@ a {
1001
1006
  }
1002
1007
  .object-kind::before {
1003
1008
  content: "\25CF";
1004
- color: var(--theme-color-specbook-accent);
1005
1009
  font-weight: normal;
1006
1010
  font-size: 120%;
1007
1011
  margin-right: 0.25rem;
1008
1012
  vertical-align: -0.075em;
1009
1013
  line-height: 0;
1014
+ color: var(--theme-color-specbook-accent);
1010
1015
  }
1011
1016
  .property-name::before {
1012
1017
  content: "\25CB";
1013
- color: var(--theme-color-specbook-accent);
1014
1018
  font-weight: normal;
1015
1019
  font-size: 120%;
1016
1020
  margin-right: 0.25rem;
1017
1021
  vertical-align: -0.075em;
1018
1022
  line-height: 0;
1023
+ color: var(--theme-color-specbook-accent);
1019
1024
  }
1020
1025
  span.link-broken {
1021
1026
  color: var(--theme-color-specbook-error);
@@ -1048,12 +1053,12 @@ a.link-compact {
1048
1053
  }
1049
1054
  a.link-compact::before {
1050
1055
  content: "\25CF";
1051
- color: var(--theme-color-specbook-accent);
1052
1056
  font-weight: normal;
1053
1057
  font-size: 120%;
1054
1058
  margin-right: 0.25rem;
1055
1059
  vertical-align: -0.075em;
1056
1060
  line-height: 0;
1061
+ color: var(--theme-color-specbook-accent);
1057
1062
  }
1058
1063
  span.anchor-paren {
1059
1064
  color: var(--theme-color-specbook-muted);
@@ -1172,14 +1177,12 @@ table.objects table.chunks th:not(.property-name) {
1172
1177
  /* the diagram leading the chunks of an object, kept in one piece */
1173
1178
  table.objects table.chunks div.diagram {
1174
1179
  margin: 0.15rem 0;
1175
- page-break-inside: avoid;
1176
1180
  break-inside: avoid;
1177
1181
  }
1178
1182
  /* bundle a chunk header row with its value row, so neither the
1179
1183
  "Diagram" header nor the property names are orphaned at a page end */
1180
1184
  @media print {
1181
1185
  table.objects table.chunks tr:has(> th) {
1182
- page-break-after: avoid;
1183
1186
  break-after: avoid;
1184
1187
  }
1185
1188
  }
@@ -1359,7 +1362,6 @@ code {
1359
1362
  div.titlepage {
1360
1363
  text-align: center;
1361
1364
  margin: 6rem 0 4rem 0;
1362
- page-break-after: always;
1363
1365
  break-after: page;
1364
1366
  }
1365
1367
  div.titlepage .logo {
@@ -1424,12 +1426,12 @@ div.search {
1424
1426
  top: 7.43rem;
1425
1427
  left: calc(0.6rem - 1px);
1426
1428
  z-index: 30;
1427
- display: flex;
1428
- align-items: center;
1429
1429
  border-radius: 0 0.3rem 0.3rem 0;
1430
1430
  color: var(--theme-color-specbook-background);
1431
1431
  background-color: var(--theme-color-specbook-accent);
1432
1432
  transition: left 0.3s ease;
1433
+ display: flex;
1434
+ align-items: center;
1433
1435
  }
1434
1436
  body:has(nav.toc-panel.open) div.search {
1435
1437
  left: calc(31rem - 1px);
@@ -1506,7 +1508,8 @@ div.search span.search-clear:hover {
1506
1508
  /* the search result filtering and matched word highlighting, where
1507
1509
  the SVG text labels of the diagrams (having no background) mimic
1508
1510
  the <mark> look with a rounded <rect> placed below the glyphs */
1509
- .search-hide {
1511
+ .search-hide,
1512
+ div.fold:has(> div.diagram.search-hide) {
1510
1513
  display: none;
1511
1514
  }
1512
1515
  body.searching nav.doc,
@@ -1531,7 +1534,6 @@ rect.search-hit {
1531
1534
  the deeper ones unnumbered) and indented per nesting level, where
1532
1535
  the (potentially multi-page) table paginates between its rows only */
1533
1536
  nav.toc {
1534
- page-break-after: always;
1535
1537
  break-after: page;
1536
1538
  counter-reset: toc1;
1537
1539
  }
@@ -1585,7 +1587,6 @@ nav.toc table {
1585
1587
  width: 100%;
1586
1588
  }
1587
1589
  nav.toc tr {
1588
- page-break-inside: avoid;
1589
1590
  break-inside: avoid;
1590
1591
  }
1591
1592
  nav.toc td {
@@ -1607,7 +1608,6 @@ nav.toc a {
1607
1608
  }
1608
1609
  /* the diagram of contents (the diagram of the title object) */
1609
1610
  nav.doc {
1610
- page-break-after: always;
1611
1611
  break-after: page;
1612
1612
  }
1613
1613
  nav.doc h1 {
@@ -258,17 +258,19 @@ const themeScript = textframe `
258
258
  let style = null
259
259
  try { style = localStorage.getItem("specbook-theme") }
260
260
  catch { /* an inaccessible storage just means no stored choice */ }
261
- if (style === null) {
262
- const m = document.documentElement.className.match(/theme-(light|dark)/)
263
- style = m !== null ? m[1] : null
264
- }
261
+ const html = document.documentElement.classList
262
+ if (style === null)
263
+ style = html.contains("theme-dark") ? "dark" : (html.contains("theme-light") ? "light" : null)
265
264
  if (style === null)
266
265
  style = window.matchMedia("(prefers-color-scheme: dark)").matches ? "dark" : "light"
267
- document.documentElement.className = "theme-" + style
266
+ html.remove("theme-light", "theme-dark")
267
+ html.add("theme-" + style)
268
268
  })()
269
269
  function themeSwitch () {
270
- const style = document.documentElement.className === "theme-dark" ? "light" : "dark"
271
- document.documentElement.className = "theme-" + style
270
+ const html = document.documentElement.classList
271
+ const style = html.contains("theme-dark") ? "light" : "dark"
272
+ html.remove("theme-light", "theme-dark")
273
+ html.add("theme-" + style)
272
274
  try { localStorage.setItem("specbook-theme", style) }
273
275
  catch { /* an inaccessible storage just loses the choice */ }
274
276
  }
@@ -415,6 +417,13 @@ const scrollProgressScript = textframe `
415
417
  update()
416
418
  })()
417
419
  `;
420
+ /* the default percentage by which the text of a table cell may exceed
421
+ the height of every other cell of its row before it folds (or is
422
+ omitted), overridden per object kind by "maxCellHeight", and the
423
+ minimum percentage of its own height a cell has to gain by that,
424
+ shared by the client-side folding and the server-side omitting */
425
+ const cellHeightDefault = 40;
426
+ const cellGainMin = 25;
418
427
  /* the client-side script of the folding: it wraps every diagram of the
419
428
  content into a fold container carrying a chevron mark at its top
420
429
  left corner, folds the text of every table cell towering over the
@@ -461,7 +470,7 @@ const foldScript = (standins, textless) => textframe `
461
470
  exceed the height of every other cell of its row before it
462
471
  folds, overridden per object kind by the "data-fold-height"
463
472
  attribute the schema configuration puts onto its table */
464
- const cellHeight = 40
473
+ const cellHeight = ${cellHeightDefault}
465
474
 
466
475
  /* the chevron mark of a foldable element, rotated by the
467
476
  stylesheet while the element is folded */
@@ -584,7 +593,7 @@ const foldScript = (standins, textless) => textframe `
584
593
  by folding, as hiding less than that is no visible relief
585
594
  and only costs the reader a chevron to click (measured after
586
595
  the cut, as the chevron itself can claim a line of its own) */
587
- const cellGain = 25
596
+ const cellGain = ${cellGainMin}
588
597
 
589
598
  /* lay out the text folds, which holds for the current line
590
599
  breaks only and hence is redone on every change of them: the
@@ -709,7 +718,7 @@ const foldScript = (standins, textless) => textframe `
709
718
  if (stored.text !== null)
710
719
  folds.text.forEach((fold) => { fold.classList.toggle("folded", stored.text === "folded") })
711
720
  if (stored.diagrams !== null) {
712
- stored.diagrams.split(",").filter((kind) => kind !== "text" && folds[kind] !== undefined)
721
+ stored.diagrams.split(",").filter((kind) => kind !== "text" && Object.hasOwn(folds, kind))
713
722
  .forEach((kind) => { active.add(kind) })
714
723
  apply()
715
724
  }
@@ -1026,21 +1035,12 @@ const infoPopupScript = textframe `
1026
1035
  catch { /* an inaccessible storage just loses the choice */ }
1027
1036
  apply()
1028
1037
  })
1029
- const show = (el) => {
1030
- /* a diagram node box carries no popup attributes itself, but
1031
- hyperlinks its object, whose heading name or table row name
1032
- carries the instance popup attributes */
1033
- let source = el
1034
- if (!el.hasAttribute("data-info") && !el.hasAttribute("data-info-spec")) {
1035
- const target = document.getElementById(
1036
- decodeURIComponent((el.getAttribute("href") ?? "").replace(/^#/, "")))
1037
- source = target === null ? null :
1038
- (target.hasAttribute("data-info-spec") ? target : target.querySelector("[data-info-spec]"))
1039
- if (source === null)
1040
- return
1041
- }
1042
- current = el
1043
1038
 
1039
+ /* compose the title of a popup: the title path segments of the
1040
+ object (the last one kind-only for a kind popup), the anchor
1041
+ id trailing an instance popup, and the property name trailing
1042
+ a property popup */
1043
+ const titleOf = (source, spec, prop) => {
1044
1044
  /* compose the title path segments of an object */
1045
1045
  const pathOf = (n, named) => {
1046
1046
  const [ , up, kind, title ] = SPEC[n]
@@ -1048,10 +1048,6 @@ const infoPopupScript = textframe `
1048
1048
  return [ ...(up >= 0 ? pathOf(up, true) : []),
1049
1049
  name !== "" && kind !== "" ? kind + ": " + name : (name !== "" ? name : kind) ]
1050
1050
  }
1051
- const key = source.getAttribute("data-info")
1052
- const spec = source.getAttribute("data-info-spec")
1053
- const prop = source.getAttribute("data-info-prop")
1054
- popup.classList.toggle("spec", spec !== null)
1055
1051
  const title = document.createElement("div")
1056
1052
  title.className = "info-title"
1057
1053
 
@@ -1076,7 +1072,7 @@ const infoPopupScript = textframe `
1076
1072
  description color), where a segment without a kind
1077
1073
  prefix is the kind-only ending of a kind popup or the
1078
1074
  name of a kind-less object */
1079
- const m = /^([^\s:]+): (.*)$/.exec(segment)
1075
+ const m = /^([^\\s:]+): (.*)$/.exec(segment)
1080
1076
  const bare = i === segments.length - 1 && spec === null && prop === null
1081
1077
  const name = document.createElement("span")
1082
1078
  name.className = "info-name"
@@ -1116,7 +1112,27 @@ const infoPopupScript = textframe `
1116
1112
  name.textContent = prop
1117
1113
  title.appendChild(name)
1118
1114
  }
1119
- popup.replaceChildren(title)
1115
+ return title
1116
+ }
1117
+ const show = (el) => {
1118
+ /* a diagram node box carries no popup attributes itself, but
1119
+ hyperlinks its object, whose heading name or table row name
1120
+ carries the instance popup attributes */
1121
+ let source = el
1122
+ if (!el.hasAttribute("data-info") && !el.hasAttribute("data-info-spec")) {
1123
+ const target = document.getElementById(
1124
+ decodeURIComponent((el.getAttribute("href") ?? "").replace(/^#/, "")))
1125
+ source = target === null ? null :
1126
+ (target.hasAttribute("data-info-spec") ? target : target.querySelector("[data-info-spec]"))
1127
+ if (source === null)
1128
+ return
1129
+ }
1130
+ current = el
1131
+ const key = source.getAttribute("data-info")
1132
+ const spec = source.getAttribute("data-info-spec")
1133
+ const prop = source.getAttribute("data-info-prop")
1134
+ popup.classList.toggle("spec", spec !== null)
1135
+ popup.replaceChildren(titleOf(source, spec, prop))
1120
1136
  const entry = key !== null ? INFO[key] : undefined
1121
1137
  const desc = prop !== null ? entry?.p?.[prop] :
1122
1138
  (spec !== null ? SPEC[spec]?.[4] : entry?.d)
@@ -1608,9 +1624,10 @@ const omitLong = (cells, shares, percent = 0) => {
1608
1624
  html.split(/<\/(?:p|li|div|pre)>|<br\s*\/?>/)
1609
1625
  .reduce((sum, block) => sum + Math.ceil(plain(block) / chars[i]), 0));
1610
1626
  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)
1627
+ const other = Math.max(...lines.filter((_, j) => j !== i)) *
1628
+ (1 + (percent > 0 ? percent : cellHeightDefault) / 100);
1629
+ const limit = Math.floor(other) * chars[i] - " [...]".length;
1630
+ if (lines[i] === 0 || lines[i] <= other || limit > lengths[i] * (1 - cellGainMin / 100))
1614
1631
  return cells[i];
1615
1632
  const open = new Array();
1616
1633
  let out = "";
@@ -1620,7 +1637,7 @@ const omitLong = (cells, shares, percent = 0) => {
1620
1637
  const tag = (/^<(\/?)([a-zA-Z][^\s/>]*)[^>]*?(\/?)>$/).exec(token);
1621
1638
  if (tag?.[1] === "/")
1622
1639
  open.pop();
1623
- else if (tag !== null && tag[3] !== "/" && !(/^(?:br|hr|img|wbr)$/).test(tag[2]))
1640
+ else if (tag !== null && tag[3] !== "/" && !(/^(?:br|hr|img|input|wbr)$/).test(tag[2]))
1624
1641
  open.push(tag[2]);
1625
1642
  }
1626
1643
  else if (count + token.length > limit)
@@ -1656,8 +1673,9 @@ const renderTable = (children, maxColumns) => {
1656
1673
  twice the share of a regular column, compressing the others */
1657
1674
  width: Math.round(200 / (keys.length + 3)),
1658
1675
  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);
1676
+ const values = keys.map((key) => inlineValue(child.kind, child.properties.find((property) => property.key === key)));
1677
+ const shares = desc ? [...keys.map(() => 1), 2] : keys.map(() => 1);
1678
+ const cells = omitLong(desc ? [...values, safe(renderCell(child))] : values, shares.map((share) => share / (keys.length + (desc ? 3 : 1))), formatOf(children[0])?.maxCellHeight);
1661
1679
  return {
1662
1680
  id: anchorOf(child),
1663
1681
  anchor: child.anchor,
@@ -1750,15 +1768,15 @@ const renderTitlePage = (object, created, modified) => {
1750
1768
  const prop = (name) => object.properties.find((property) => property.key === name)?.value;
1751
1769
  const inlineProp = (name) => {
1752
1770
  const value = prop(name);
1753
- return value !== undefined ? inline(value) : "";
1771
+ return value !== undefined && value.trim() !== "" ? inline(value) : "";
1754
1772
  };
1755
1773
  const rest = object.properties.filter((property) => !["LOGO", "TITLE", "SUBTITLE", "AUTHOR", "VERSION",
1756
1774
  "LANG", "CHARSET", "PAPER-SIZE", "THEME-STYLE", "THEME-TONE"].includes(property.key));
1757
1775
  /* the logo is rendered above the title, from the embedded image content
1758
1776
  of the LOGO property or, for a non-embeddable reference, as its inline
1759
- Markdown; without a LOGO property the built-in SpecBook logo is used,
1760
- in both its theme variants */
1761
- const logo = object.properties.find((property) => property.key === "LOGO");
1777
+ Markdown; without a (non-empty) LOGO property the built-in SpecBook
1778
+ logo is used, in both its theme variants */
1779
+ const logo = object.properties.find((property) => property.key === "LOGO" && property.value.trim() !== "");
1762
1780
  const image = logo !== undefined ? renderEmbeddings(logo.value, logo.embedding ?? []) : [];
1763
1781
  return scoped(object, () => render("TitlePage", { TitlePage: {
1764
1782
  logo: logo === undefined ?
@@ -1920,24 +1938,13 @@ export const renderPlaceholder = (message) => render("Placeholder", { Placeholde
1920
1938
  realtime: safe(realtimeScript),
1921
1939
  message
1922
1940
  } });
1923
- /* render the entire specification into a self-contained HTML document,
1924
- with the build-time pre-assembled stylesheet embedded inline, the
1925
- artifact timestamps aggregated into min(Created)/max(Modified),
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();
1934
- const rendered = config !== undefined ?
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
+ /* the fold controls the omitted aspects drop, along with the implied
1942
+ ones (no type control without diagrams of that type, no level
1943
+ control without diagrams from that level on, no fold tab without
1944
+ any control, which lets the tabs below it move up), plus the extra
1945
+ style rules the omission brings (the "[...]" mark of the omitted
1946
+ long texts and the moved-up tabs of a dropped fold tab) */
1947
+ const omittedControls = (omit) => {
1941
1948
  const none = omit.has("diagram:1")
1942
1949
  || (omit.has("diagram:graph") && omit.has("diagram:hub") && omit.has("diagram:grid"));
1943
1950
  const omitted = {
@@ -1950,9 +1957,43 @@ export const renderHtml = async (specification, config, tocPages, css, options =
1950
1957
  text: omit.has("text:long"),
1951
1958
  all: none && omit.has("text:long")
1952
1959
  };
1953
- const omitCss = (omitted.text ? "\nspan.omit { color: var(--theme-color-specbook-muted) }" : "") +
1960
+ const css = (omitted.text ? "\nspan.omit { color: var(--theme-color-specbook-muted) }" : "") +
1954
1961
  (omitted.all ? "\nnav.toc-panel div.toc-tab { top: 10.66rem }" +
1955
1962
  "\ndiv.realtime-status { top: 13.89rem }" : "");
1963
+ return { omitted, css };
1964
+ };
1965
+ /* wrap the pre-rendered diagrams into their blocks, carrying the
1966
+ diagram type and the object tree nesting level the folding
1967
+ distinguishes (the top-level objects of an artifact being level 1) */
1968
+ const wrapDiagrams = (index, svgs) => {
1969
+ const blocks = new Map();
1970
+ for (const node of index) {
1971
+ const svg = svgs.get(node.object);
1972
+ if (svg === undefined)
1973
+ continue;
1974
+ let level = 1;
1975
+ for (let parent = node.parent; parent !== undefined; parent = parent.parent)
1976
+ level++;
1977
+ const type = schemas?.get(node.object)?.diagram?.type ?? "graph";
1978
+ blocks.set(node.object, `<div class="diagram" data-type="${type}" data-level="${level}">${svg}</div>`);
1979
+ }
1980
+ return blocks;
1981
+ };
1982
+ /* render the entire specification into a self-contained HTML document,
1983
+ with the build-time pre-assembled stylesheet embedded inline, the
1984
+ artifact timestamps aggregated into min(Created)/max(Modified),
1985
+ optional per-anchor page numbers attached to the ToC entries,
1986
+ optionally the client-side script of the live preview injected and
1987
+ aspects omitted (see "ExportOptions"), and the embedded images
1988
+ optimized for the screen or for print (the PDF) */
1989
+ export const renderHtml = async (specification, config, tocPages, css, options = {}, verbose, print = false) => {
1990
+ /* pre-render the configured diagrams as scaled embeddable SVGs
1991
+ (except for the omitted ones, which hence leave no trace at all) */
1992
+ const omit = options.omit ?? new Set();
1993
+ const rendered = config !== undefined ?
1994
+ await scaledDiagrams(specification, config, verbose, omit) : null;
1995
+ /* the fold controls and the extra style rules of the omitted aspects */
1996
+ const { omitted, css: omitCss } = omittedControls(omit);
1956
1997
  /* pre-optimize the embedded images (downscaled and re-encoded) */
1957
1998
  const optimized = await optimizeImages(specification, print, verbose);
1958
1999
  /* the document language selects the smart typography quote style */
@@ -1970,17 +2011,7 @@ export const renderHtml = async (specification, config, tocPages, css, options =
1970
2011
  omits = omit;
1971
2012
  /* wrap the diagrams into their blocks, carrying the diagram type
1972
2013
  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
- }
2014
+ diagrams = rendered !== null ? wrapDiagrams(index, rendered.svgs) : null;
1984
2015
  /* collect the schema descriptions for the description popups,
1985
2016
  plus the object table composing their title paths */
1986
2017
  let info = null;
@@ -2020,7 +2051,7 @@ export const renderHtml = async (specification, config, tocPages, css, options =
2020
2051
  return render("Document", { Document: {
2021
2052
  title: documentTitle(specification).title,
2022
2053
  lang,
2023
- theme: documentThemeStyle(specification)?.toLowerCase(),
2054
+ theme: documentThemeStyle(specification),
2024
2055
  css: safe((css ?? stylesheet()) + (rendered !== null ? `\n${rendered.css}` : "") + omitCss),
2025
2056
  themescript: safe(themeScript),
2026
2057
  titlepage: title !== undefined ?
@@ -83,11 +83,15 @@ const collect = (object, contents) => {
83
83
  for) or which does not shrink the image keeps the original, so the
84
84
  export never depends on it */
85
85
  export const optimizeImages = async (specification, print, verbose) => {
86
+ /* collect the distinct embedded image contents of the specification */
86
87
  const medium = print ? "print" : "screen";
87
88
  const contents = new Set();
88
89
  for (const artifact of specification.artifacts)
89
90
  for (const object of artifact.objects)
90
91
  collect(object, contents);
92
+ /* optimize every image the cache of the medium cannot serve (an
93
+ optimization which fails or does not shrink keeps the original),
94
+ accounting the sizes for the report below */
91
95
  const cache = new Map();
92
96
  let cached = 0;
93
97
  let before = 0;
@@ -101,7 +105,9 @@ export const optimizeImages = async (specification, print, verbose) => {
101
105
  optimized = content.startsWith("data:") ?
102
106
  await optimizeRaster(content, print) : await optimizeSvg(content);
103
107
  }
104
- catch {
108
+ catch (err) {
109
+ verbose?.("optimizing image failed (keeping the original): " +
110
+ (err instanceof Error ? err.message : String(err)));
105
111
  optimized = content;
106
112
  }
107
113
  if (optimized.length >= content.length)
@@ -111,6 +117,8 @@ export const optimizeImages = async (specification, print, verbose) => {
111
117
  before += content.length;
112
118
  after += optimized.length;
113
119
  }
120
+ /* sweep the cache of the medium to the images of this
121
+ optimization and report its figures */
114
122
  imageCaches[medium] = cache;
115
123
  if (contents.size > 0)
116
124
  verbose?.((cached === contents.size ?