@rse/specbook 1.2.4 → 1.2.6

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.
@@ -15,6 +15,7 @@ import { embeddingRegex, embeddingMimeType, embeddingThemes, embeddingVariants }
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 { specCoverage, coverageRatio } from "./specbook-coverage.js";
18
19
  /* ==== Templates ==== */
19
20
  /* the built-in Nunjucks templates for the HTML export */
20
21
  const templates = {
@@ -43,6 +44,13 @@ const templates = {
43
44
  <span class="search-clear" id="search-clear" title="clear search">&#x00D7;</span>
44
45
  </div>
45
46
  </div>
47
+ <div class="fold-switch">
48
+ <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
+ <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
+ </div>
53
+ </div>
46
54
  <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>
47
55
  {{ Document.tocpanel }}
48
56
  {{ Document.titlepage }}
@@ -51,6 +59,7 @@ const templates = {
51
59
  {{ Document.artifacts }}
52
60
  {% if Document.search %}<script>{{ Document.search }}</script>{% endif %}
53
61
  <script>{{ Document.progress }}</script>
62
+ <script>{{ Document.fold }}</script>
54
63
  {% if Document.tocpanel %}<script>{{ Document.tocscript }}</script>{% endif %}
55
64
  {% if Document.info %}<script>{{ Document.info }}</script>{% endif %}
56
65
  {% if Document.realtime %}<script class="realtime">{{ Document.realtime }}</script>{% endif %}
@@ -146,12 +155,26 @@ const templates = {
146
155
  {{ Object.diagram }}
147
156
  {{ Object.properties }}
148
157
  {{ Object.description }}
158
+ {{ Object.coverage }}
149
159
  {{ Object.children }}
150
160
  </section>
151
161
  `,
162
+ /* <Coverage/> */
163
+ "Coverage": textframe `
164
+ <table class="coverage">
165
+ <thead>
166
+ <tr><th>Coverage</th><th>Covered</th><th>Total</th><th class="ratio">Ratio</th></tr>
167
+ </thead>
168
+ <tbody>
169
+ {% for entry in Coverage %}
170
+ <tr{% if entry.even %} class="even"{% endif %}><td>{{ entry.label }}</td><td>{{ entry.covered }}</td><td>{{ entry.total }}</td><td class="ratio"><span class="bar"><span style="width: {{ entry.ratio }}%"></span></span> {{ entry.ratio }}%</td></tr>
171
+ {% endfor %}
172
+ </tbody>
173
+ </table>
174
+ `,
152
175
  /* <Properties/> */
153
176
  "Properties": textframe `
154
- <table class="props">
177
+ <table class="props"{% if Fold %} data-fold-height="{{ Fold }}"{% endif %}>
155
178
  {% for property in Properties %}
156
179
  <tr><td class="key property-name"><span{% if property.info %} data-info="{{ property.info }}" data-info-path="{{ property.infopath }}" data-info-prop="{{ property.key }}"{% endif %}>{{ property.key }}</span></td><td>{{ property.value }}</td></tr>
157
180
  {% endfor %}
@@ -172,7 +195,7 @@ const templates = {
172
195
  `,
173
196
  /* <Table/> */
174
197
  "Table": textframe `
175
- <table class="objects">
198
+ <table class="objects"{% if Table.fold %} data-fold-height="{{ Table.fold }}"{% endif %}>
176
199
  <thead>
177
200
  <tr>
178
201
  <th class="object-kind"><span{% if Table.info %} data-info="{{ Table.info }}" data-info-path="{{ Table.infopath }}"{% endif %}>{{ Table.head }}</span></th>
@@ -193,7 +216,7 @@ const templates = {
193
216
  `,
194
217
  /* <TableChunked/> */
195
218
  "TableChunked": textframe `
196
- <table class="objects">
219
+ <table class="objects"{% if Table.fold %} data-fold-height="{{ Table.fold }}"{% endif %}>
197
220
  <thead>
198
221
  <tr>
199
222
  <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>
@@ -382,6 +405,255 @@ const scrollProgressScript = textframe `
382
405
  update()
383
406
  })()
384
407
  `;
408
+ /* the client-side script of the folding: it wraps every diagram of the
409
+ content into a fold container carrying a chevron mark at its top
410
+ left corner, folds the
411
+ text of every table cell towering over the other cells of its row
412
+ behind a chevron mark of its own, and lets the two
413
+ controls of the fold tab fold and unfold all diagrams and all cell
414
+ texts at once, with their state persisted across page loads and
415
+ their icons (plus the tab icon) marked while anything of their kind
416
+ is folded. The script
417
+ runs at the end of the body, as the content it wraps has to exist
418
+ already, and a live preview body swap replaces the containers and
419
+ their listeners along with the body */
420
+ const foldScript = textframe `
421
+ (function () {
422
+ const tab = document.querySelector("div.fold-switch")
423
+ if (tab === null)
424
+ return
425
+ const controls = { diagram: tab.querySelector("div.fold-diagrams"),
426
+ text: tab.querySelector("div.fold-texts") }
427
+
428
+ /* let the fold icon slide the two controls out of the tab (and
429
+ back in again), remembering the choice across page loads */
430
+ try { if (localStorage.getItem("specbook-fold") === "open") tab.classList.add("open") }
431
+ catch { /* an inaccessible storage just means no stored state */ }
432
+ tab.querySelector("div.fold-toggle").addEventListener("click", () => {
433
+ tab.classList.toggle("open")
434
+ try { localStorage.setItem("specbook-fold", tab.classList.contains("open") ? "open" : "closed") }
435
+ catch { /* an inaccessible storage just loses the state */ }
436
+ })
437
+
438
+ /* the default percentage by which the text of a table cell may
439
+ exceed the height of every other cell of its row before it
440
+ folds, overridden per object kind by the "data-fold-height"
441
+ attribute the schema configuration puts onto its table */
442
+ const cellHeight = 40
443
+
444
+ /* the chevron mark of a foldable element, rotated by the
445
+ stylesheet while the element is folded */
446
+ const svgNS = "http://www.w3.org/2000/svg"
447
+ const mark = (name, title) => {
448
+ const span = document.createElement("span")
449
+ span.className = name
450
+ span.title = title
451
+ const svg = document.createElementNS(svgNS, "svg")
452
+ svg.setAttribute("viewBox", "0 0 24 24")
453
+ svg.setAttribute("fill", "none")
454
+ svg.setAttribute("stroke", "currentColor")
455
+ svg.setAttribute("stroke-width", "2.5")
456
+ svg.setAttribute("stroke-linecap", "round")
457
+ svg.setAttribute("stroke-linejoin", "round")
458
+ const path = document.createElementNS(svgNS, "path")
459
+ path.setAttribute("d", "M5 9l7 7 7-7")
460
+ svg.appendChild(path)
461
+ span.appendChild(svg)
462
+ return span
463
+ }
464
+
465
+ /* wrap every diagram into its fold container, carrying the
466
+ chevron mark plus the muted placeholder the diagram leaves
467
+ behind while folded, which is the very icon of its tab
468
+ control */
469
+ const folds = { diagram: [], text: [] }
470
+ document.querySelectorAll("article div.diagram, nav.doc div.diagram").forEach((el) => {
471
+ const fold = document.createElement("div")
472
+ fold.className = "fold"
473
+ el.parentNode.insertBefore(fold, el)
474
+ const chevron = mark("fold-mark", "fold/unfold this diagram")
475
+ const label = document.createElement("span")
476
+ label.className = "fold-label"
477
+ label.appendChild(controls.diagram.querySelector("svg").cloneNode(true))
478
+ fold.append(chevron, label, el)
479
+ chevron.addEventListener("click", () => {
480
+ fold.classList.toggle("folded")
481
+ sync()
482
+ })
483
+ folds.diagram.push(fold)
484
+ })
485
+
486
+ /* the rendered height of the content of a table cell, or of its
487
+ leading part up to a word position: a cell box itself always
488
+ spans the whole row, so only a range over the content
489
+ measures the text */
490
+ const range = document.createRange()
491
+ const height = (cell, upto) => {
492
+ if (upto === undefined)
493
+ range.selectNodeContents(cell)
494
+ else {
495
+ range.setStart(cell, 0)
496
+ range.setEnd(upto.node, upto.offset)
497
+ }
498
+ return range.getBoundingClientRect().height
499
+ }
500
+
501
+ /* the word positions of a table cell, each right behind one word */
502
+ const positions = (cell) => {
503
+ const walker = document.createTreeWalker(cell, NodeFilter.SHOW_TEXT)
504
+ const list = []
505
+ let node
506
+ while ((node = walker.nextNode()) !== null) {
507
+ const regex = /\\S+/g
508
+ let m
509
+ while ((m = regex.exec(node.nodeValue ?? "")) !== null)
510
+ list.push({ node, offset: m.index + m[0].length })
511
+ }
512
+ return list
513
+ }
514
+
515
+ /* a cell taking part in the comparison of its row: one with
516
+ content of its own, i.e. neither empty (a not given property
517
+ draws its marker through the stylesheet alone) nor holding
518
+ further cells (a chunked or nested table, whose own cells are
519
+ compared instead) nor a diagram (which folds on its own and
520
+ whose SVG text cannot carry the wrapper of a hidden
521
+ remainder) */
522
+ const candidate = (cell) =>
523
+ cell.querySelector("td") === null
524
+ && cell.querySelector("div.diagram") === null
525
+ && (cell.textContent.trim() !== "" || cell.querySelector("img") !== null)
526
+
527
+ /* decide which table cells fold: one which is taller than the
528
+ configured percentage above every other cell of its row (the
529
+ leading name column of a compact table excluded, as it is no
530
+ text of its own). All rows are measured before the first cut,
531
+ as every cut shifts the layout of the ones still to come */
532
+ const cuts = []
533
+ document.querySelectorAll("article table tr").forEach((row) => {
534
+ const table = row.closest("table")
535
+ let cells = Array.from(row.children).filter((el) => el.tagName === "TD")
536
+ if (!table.classList.contains("chunks"))
537
+ cells = cells.slice(1)
538
+ cells = cells.filter(candidate)
539
+ if (cells.length < 2)
540
+ return
541
+ const configured = row.closest("table[data-fold-height]")
542
+ const percent = Number(configured?.getAttribute("data-fold-height")) || cellHeight
543
+ const heights = cells.map((cell) => height(cell))
544
+ cells.forEach((cell, i) => {
545
+ const other = Math.max(...heights.filter((_, j) => j !== i))
546
+ const limit = other * (1 + percent / 100)
547
+ if (heights[i] > limit)
548
+ cuts.push({ cell, limit, full: heights[i] })
549
+ })
550
+ })
551
+
552
+ /* hide a node of the folded remainder of a table cell: an
553
+ element carries the class itself, a text node gets wrapped */
554
+ const rest = (node) => {
555
+ if (node.nodeType === Node.ELEMENT_NODE)
556
+ node.classList.add("fold-rest")
557
+ else if (node.nodeType === Node.TEXT_NODE) {
558
+ const span = document.createElement("span")
559
+ span.className = "fold-rest"
560
+ node.parentNode.insertBefore(span, node)
561
+ span.appendChild(node)
562
+ }
563
+ }
564
+
565
+ /* the minimum percentage of its own height a cell has to gain
566
+ by folding, as hiding less than that is no visible relief
567
+ and only costs the reader a chevron to click (measured after
568
+ the cut, as the chevron itself can claim a line of its own) */
569
+ const cellGain = 25
570
+
571
+ /* fold the decided cells back onto their height limit: the last
572
+ word position still fitting is found by a binary search over
573
+ the measured word positions, the text node is split there and
574
+ the chevron mark takes its place, followed by the remainder
575
+ hidden up the ancestor chain of the cell */
576
+ for (const cut of cuts) {
577
+ const list = positions(cut.cell)
578
+ if (list.length === 0)
579
+ continue
580
+ let lo = 0
581
+ let hi = list.length - 1
582
+ while (lo < hi) {
583
+ const mid = Math.ceil((lo + hi) / 2)
584
+ if (height(cut.cell, list[mid]) <= cut.limit)
585
+ lo = mid
586
+ else
587
+ hi = mid - 1
588
+ }
589
+ const tail = list[lo].node.splitText(list[lo].offset)
590
+ const chevron = mark("fold-more", "fold/unfold the remaining text")
591
+ tail.parentNode.insertBefore(chevron, tail)
592
+ let current = chevron
593
+ while (current !== cut.cell) {
594
+ let next = current.nextSibling
595
+ while (next !== null) {
596
+ const following = next.nextSibling
597
+ rest(next)
598
+ next = following
599
+ }
600
+ current = current.parentNode
601
+ }
602
+ /* the gain is measured on the applied fold, as only that
603
+ one tells the real one, and a fold gaining less than the
604
+ minimum share is taken back again, which the chevron
605
+ alone has to leave behind, as the hidden remainder stays
606
+ inert without the class -- which a kept fold sheds
607
+ again, too, as every cell starts out unfolded */
608
+ cut.cell.classList.add("folded")
609
+ const gained = height(cut.cell) <= cut.full * (1 - cellGain / 100)
610
+ cut.cell.classList.remove("folded")
611
+ if (!gained) {
612
+ chevron.remove()
613
+ continue
614
+ }
615
+ folds.text.push(cut.cell)
616
+ chevron.addEventListener("click", (event) => {
617
+ /* the cut can land inside a hyperlink, whose navigation
618
+ the chevron has to suppress for its own click alone,
619
+ so the remaining link text still follows the link */
620
+ event.preventDefault()
621
+ cut.cell.classList.toggle("folded")
622
+ sync()
623
+ })
624
+ }
625
+
626
+ /* mark the control of a kind while anything of that kind is
627
+ folded, and the tab itself while any kind at all is */
628
+ const sync = () => {
629
+ let any = false
630
+ for (const kind of Object.keys(folds)) {
631
+ const folded = folds[kind].some((fold) => fold.classList.contains("folded"))
632
+ controls[kind].classList.toggle("folded", folded)
633
+ any ||= folded
634
+ }
635
+ tab.classList.toggle("folded", any)
636
+ }
637
+ for (const kind of Object.keys(folds)) {
638
+ /* without a stored state the rendered one stands, which is
639
+ everything unfolded */
640
+ let stored = null
641
+ try { stored = localStorage.getItem("specbook-fold-" + kind) }
642
+ catch { /* an inaccessible storage just means no stored state */ }
643
+ if (stored !== null)
644
+ folds[kind].forEach((fold) => { fold.classList.toggle("folded", stored === "folded") })
645
+ controls[kind].addEventListener("click", () => {
646
+ const all = !folds[kind].every((fold) => fold.classList.contains("folded"))
647
+ folds[kind].forEach((fold) => { fold.classList.toggle("folded", all) })
648
+ try { localStorage.setItem("specbook-fold-" + kind, all ? "folded" : "unfolded") }
649
+ catch { /* an inaccessible storage just loses the state */ }
650
+ sync()
651
+ })
652
+ }
653
+ sync()
654
+
655
+ })()
656
+ `;
385
657
  /* the client-side script of the table of contents side panel: it
386
658
  slides the panel out of and back into the viewport edge (closing it
387
659
  also on a jump, on "Escape", and on a click outside), remembers the
@@ -470,6 +742,7 @@ const tocPanelScript = textframe `
470
742
  && event.target.closest("div.search") === null
471
743
  && event.target.closest("div.theme-switch") === null
472
744
  && event.target.closest("div.info-switch") === null
745
+ && event.target.closest("div.fold-switch") === null
473
746
  && event.target.closest("div.realtime-status") === null)
474
747
  toggle(false)
475
748
  }
@@ -721,14 +994,15 @@ const render = (name, context) => {
721
994
  };
722
995
  /* the active per-document reference expander, fully-qualified
723
996
  anchor paths, member-carrying property value constraints, object
724
- schema nodes, pre-rendered diagram SVGs, description popup keys
725
- of the schema nodes, and object parents of the description popup
726
- title paths (all set during HTML rendering) */
997
+ schema nodes, pre-rendered diagram SVGs, reference coverages,
998
+ description popup keys of the schema nodes, and object parents of
999
+ the description popup title paths (all set during HTML rendering) */
727
1000
  let linker = null;
728
1001
  let anchors = null;
729
1002
  let members = null;
730
1003
  let schemas = null;
731
1004
  let diagrams = null;
1005
+ let coverages = null;
732
1006
  let infoKeys = null;
733
1007
  let infoParents = null;
734
1008
  /* the object whose texts are currently rendered, scoping the
@@ -1034,6 +1308,30 @@ const diagramOf = (object) => {
1034
1308
  const diagram = diagrams?.get(object);
1035
1309
  return diagram !== undefined ? safe(`<div class="diagram">${diagram}</div>`) : "";
1036
1310
  };
1311
+ /* the reference coverage of an object as a table: one row per
1312
+ configured pattern, labeled by the kinds of the matching objects,
1313
+ marked as object kinds and carrying the description popup of the
1314
+ first matching object of each kind (by the bare pattern, if none
1315
+ matches), with the counts and the ratio as a bar (empty for an
1316
+ object without a configured coverage) */
1317
+ const coverageOf = (object) => {
1318
+ const coverage = coverages?.get(object);
1319
+ if (coverage === undefined)
1320
+ return "";
1321
+ const kindLabel = (entry, kind) => {
1322
+ const first = [...entry.covered, ...entry.uncovered].find((target) => target.kind === kind);
1323
+ return `<span class="object-kind"${first !== undefined ? infoAttr(first) : ""}>${escapeHtml(kind)}</span>`;
1324
+ };
1325
+ return safe(render("Coverage", { Coverage: coverage.map((entry, i) => ({
1326
+ label: entry.kinds.length > 0 ?
1327
+ safe(entry.kinds.map((kind) => kindLabel(entry, kind)).join(", ")) :
1328
+ entry.pattern.replace(/^\[\[(.*)\]\]$/s, "$1"),
1329
+ covered: entry.covered.length,
1330
+ total: entry.covered.length + entry.uncovered.length,
1331
+ ratio: coverageRatio(entry.covered.length, entry.covered.length + entry.uncovered.length),
1332
+ even: i % 2 === 1
1333
+ })) }));
1334
+ };
1037
1335
  /* render a single-kind group of children into one compact table:
1038
1336
  the name first, then the property columns, then the description;
1039
1337
  a group wider than maxColumns, or carrying diagrams, instead chunks
@@ -1051,6 +1349,7 @@ const renderTable = (children, maxColumns) => {
1051
1349
  infopath: infoPathOf(children[0], false),
1052
1350
  keys,
1053
1351
  desc,
1352
+ fold: formatOf(children[0])?.maxCellHeight,
1054
1353
  /* under the fixed table layout the description column claims
1055
1354
  twice the share of a regular column, compressing the others */
1056
1355
  width: Math.round(200 / (keys.length + 3)),
@@ -1074,6 +1373,7 @@ const renderTable = (children, maxColumns) => {
1074
1373
  head: children[0].kind !== "" ? children[0].kind : "Name",
1075
1374
  info: infoKeyOf(children[0]),
1076
1375
  infopath: infoPathOf(children[0], false),
1376
+ fold: formatOf(children[0])?.maxCellHeight,
1077
1377
  width: Math.round(100 / maxColumns),
1078
1378
  rows: children.map((child, i) => scoped(child, () => {
1079
1379
  const cells = keys.map((key) => ({ key, desc: false, span: 1,
@@ -1122,9 +1422,11 @@ const renderObject = (object, level, concise) => {
1122
1422
  name: inline(object.name),
1123
1423
  diagram: diagramOf(object),
1124
1424
  properties: properties.length > 0 ?
1125
- safe(render("Properties", { Properties: inlineProperties(object, properties) })) : "",
1425
+ safe(render("Properties", { Properties: inlineProperties(object, properties),
1426
+ Fold: formatOf(object)?.maxCellHeight })) : "",
1126
1427
  description: object.description !== undefined ?
1127
1428
  safe(renderDescription(object.description)) : "",
1429
+ coverage: coverageOf(object),
1128
1430
  children: safe(renderChildren(object, level + 1, concise))
1129
1431
  } }));
1130
1432
  };
@@ -1311,6 +1613,7 @@ export const renderHtml = async (specification, config, tocPages, css, realtime
1311
1613
  members = config !== undefined ? collectMembers(config, new Map()) : null;
1312
1614
  schemas = config !== undefined ? collectSchemas(specification, config) : null;
1313
1615
  diagrams = rendered;
1616
+ coverages = schemas !== null ? specCoverage(index, schemas) : null;
1314
1617
  /* collect the schema descriptions for the description popups,
1315
1618
  plus the object parents composing their title paths */
1316
1619
  let info = null;
@@ -1357,6 +1660,7 @@ export const renderHtml = async (specification, config, tocPages, css, realtime
1357
1660
  safe(renderTitlePage(title, formatDate(created), formatDate(modified))) : "",
1358
1661
  search: safe(searchScript()),
1359
1662
  progress: safe(scrollProgressScript),
1663
+ fold: safe(foldScript),
1360
1664
  info: info !== null ? safe(infoScript(info, spec ?? {})) : "",
1361
1665
  realtime: realtime ? safe(realtimeScript) : "",
1362
1666
  toc: entries.length > 0 ? safe(render("Toc", { Toc: { entries } })) : "",
@@ -1376,6 +1680,7 @@ export const renderHtml = async (specification, config, tocPages, css, realtime
1376
1680
  members = null;
1377
1681
  schemas = null;
1378
1682
  diagrams = null;
1683
+ coverages = null;
1379
1684
  infoKeys = null;
1380
1685
  infoParents = null;
1381
1686
  }
@@ -133,11 +133,36 @@ const drawBrandBar = async (doc, accent) => {
133
133
  Chrome has to actually launch it and as the export preflight and the
134
134
  renderer both ask for the very same browser */
135
135
  let browserOptions;
136
- /* resolve the browser printing the PDF: the downloaded Playwright
137
- Chromium is a plain file check, while a system-installed Google
138
- Chrome is only detectable by launching it */
136
+ /* the remedy of an unavailable Playwright Chromium, shared by the
137
+ fallback notice and the missing-browser error, as both have to point
138
+ at the very same one-time command */
139
+ const chromiumRemedy = `run "${literal("npx playwright install chromium")}" once to download the Playwright Chromium`;
140
+ /* resolve the browser printing the PDF: an explicitly configured one
141
+ wins, then the downloaded Playwright Chromium as a plain file check,
142
+ while a system-installed Google Chrome is only detectable by
143
+ launching it */
139
144
  const resolveBrowser = async (verbose) => {
140
145
  const { chromium } = await import("playwright");
146
+ /* an explicitly configured browser is taken as an executable path
147
+ once it carries a path separator and as a Playwright channel
148
+ name otherwise, and it never falls back onto another browser, as
149
+ an explicit choice has to be either honored or reported */
150
+ const configured = process.env.SPECBOOK_BROWSER ?? "";
151
+ if (configured !== "") {
152
+ const options = /[\\/]/.test(configured) ?
153
+ { executablePath: configured } : { channel: configured };
154
+ try {
155
+ const browser = await chromium.launch(options);
156
+ await browser.close();
157
+ }
158
+ catch (err) {
159
+ throw new Error(`the browser "${literal(configured)}" configured by ` +
160
+ `"${literal("SPECBOOK_BROWSER")}" failed to launch`, { cause: err });
161
+ }
162
+ verbose(`using the browser "${literal(configured)}" configured by ` +
163
+ `"${literal("SPECBOOK_BROWSER")}"`);
164
+ return options;
165
+ }
141
166
  let executable = "";
142
167
  try {
143
168
  executable = chromium.executablePath();
@@ -151,7 +176,7 @@ const resolveBrowser = async (verbose) => {
151
176
  const chrome = await chromium.launch({ channel: "chrome" });
152
177
  await chrome.close();
153
178
  verbose("Playwright Chromium unavailable -- falling back to the " +
154
- "system-installed Google Chrome", "notice");
179
+ `system-installed Google Chrome (${chromiumRemedy})`, "notice");
155
180
  return { channel: "chrome" };
156
181
  }
157
182
  catch {
@@ -166,8 +191,7 @@ export const requireBrowser = async (verbose) => {
166
191
  const options = await browserOptions;
167
192
  if (options === undefined)
168
193
  throw new Error("the PDF export requires a Chromium-class browser, but neither the " +
169
- "Playwright Chromium nor a system-installed Google Chrome was found -- run " +
170
- `"${literal("npx playwright install chromium")}" once to download the Playwright Chromium`);
194
+ `Playwright Chromium nor a system-installed Google Chrome was found -- ${chromiumRemedy}`);
171
195
  return options;
172
196
  };
173
197
  /* the bound of the ToC page number fixpoint iteration, as the page
@@ -10,6 +10,7 @@ export type SchemaObject = {
10
10
  refs?: string;
11
11
  optional?: boolean;
12
12
  referenced?: string[];
13
+ coverage?: string[];
13
14
  automaton?: SchemaAutomaton;
14
15
  diagram?: SchemaDiagram;
15
16
  format?: SchemaFormat;
@@ -36,6 +37,14 @@ export type SchemaDiagramCenterEdges = {
36
37
  outbound?: string;
37
38
  both?: string;
38
39
  labeled?: string;
40
+ via?: string;
41
+ };
42
+ export type SchemaDiagramNest = {
43
+ properties?: string[];
44
+ parent?: boolean;
45
+ kind?: boolean;
46
+ layout?: "graph" | "hub" | "grid";
47
+ crossing?: "nodes" | "target" | "both";
39
48
  };
40
49
  export type SchemaDiagram = {
41
50
  type?: "graph" | "hub" | "grid";
@@ -53,12 +62,15 @@ export type SchemaDiagram = {
53
62
  onlyConnected?: boolean;
54
63
  collapse?: boolean;
55
64
  qualified?: boolean;
65
+ ordered?: boolean;
66
+ nest?: SchemaDiagramNest;
56
67
  properties?: string[];
57
68
  config?: Partial<GradiaConfig>;
58
69
  };
59
70
  export type SchemaFormat = {
60
71
  type?: "auto" | "complex" | "concise";
61
72
  maxTableColumns?: number;
73
+ maxCellHeight?: number;
62
74
  withUnusedProps?: boolean;
63
75
  };
64
76
  export type SchemaProperty = {
@@ -48,7 +48,15 @@ const SchemaDiagramCenterEdges = v.strictObject({
48
48
  inbound: v.optional(v.string()),
49
49
  outbound: v.optional(v.string()),
50
50
  both: v.optional(v.string()),
51
- labeled: v.optional(v.string())
51
+ labeled: v.optional(v.string()),
52
+ via: v.optional(v.string())
53
+ });
54
+ const SchemaDiagramNest = v.strictObject({
55
+ properties: v.optional(v.array(v.string())),
56
+ parent: v.optional(v.boolean()),
57
+ kind: v.optional(v.boolean()),
58
+ layout: v.optional(v.picklist(["graph", "hub", "grid"])),
59
+ crossing: v.optional(v.picklist(["nodes", "target", "both"]))
52
60
  });
53
61
  const SchemaDiagram = v.strictObject({
54
62
  type: v.optional(v.picklist(["graph", "hub", "grid"])),
@@ -66,12 +74,15 @@ const SchemaDiagram = v.strictObject({
66
74
  onlyConnected: v.optional(v.boolean()),
67
75
  collapse: v.optional(v.boolean()),
68
76
  qualified: v.optional(v.boolean()),
77
+ ordered: v.optional(v.boolean()),
78
+ nest: v.optional(SchemaDiagramNest),
69
79
  properties: v.optional(v.array(v.string())),
70
80
  config: v.optional(SchemaDiagramConfig)
71
81
  });
72
82
  const SchemaFormat = v.strictObject({
73
83
  type: v.optional(v.picklist(["auto", "complex", "concise"])),
74
84
  maxTableColumns: v.optional(v.pipe(v.number(), v.integer(), v.minValue(2))),
85
+ maxCellHeight: v.optional(v.pipe(v.number(), v.integer(), v.minValue(0))),
75
86
  withUnusedProps: v.optional(v.boolean())
76
87
  });
77
88
  const SchemaObject = v.strictObject({
@@ -83,6 +94,7 @@ const SchemaObject = v.strictObject({
83
94
  refs: v.optional(v.string()),
84
95
  optional: v.optional(v.boolean()),
85
96
  referenced: v.optional(v.pipe(v.array(v.string()), v.minLength(1))),
97
+ coverage: v.optional(v.pipe(v.array(v.string()), v.minLength(1))),
86
98
  automaton: v.optional(SchemaAutomaton),
87
99
  diagram: v.optional(SchemaDiagram),
88
100
  format: v.optional(SchemaFormat),