@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.
- package/README.md +21 -3
- package/dst/specbook-cmd-lint.js +25 -1
- package/dst/specbook-config.js +18 -17
- package/dst/specbook-coverage.d.ts +17 -0
- package/dst/specbook-coverage.js +106 -0
- package/dst/specbook-diagram.js +163 -21
- package/dst/specbook-export-ast.js +11 -1
- package/dst/specbook-export-html.css +236 -5
- package/dst/specbook-export-html.js +312 -7
- package/dst/specbook-export-pdf.js +30 -6
- package/dst/specbook-format-schema.d.ts +12 -0
- package/dst/specbook-format-schema.js +13 -1
- package/dst/specbook-format.md +110 -10
- package/dst/specbook-format.yaml +354 -173
- package/dst/specbook-parse-semantic.js +6 -42
- package/dst/specbook-parse.d.ts +1 -1
- package/dst/specbook-parse.js +1 -1
- package/package.json +5 -5
|
@@ -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">×</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,
|
|
725
|
-
of the schema nodes, and object parents of
|
|
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
|
-
/*
|
|
137
|
-
|
|
138
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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),
|