@rse/specbook 1.2.4 → 1.2.5
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 +12 -0
- package/dst/specbook-diagram.js +151 -19
- package/dst/specbook-export-html.css +178 -5
- package/dst/specbook-export-html.js +266 -4
- package/dst/specbook-export-pdf.js +30 -6
- package/dst/specbook-format-schema.d.ts +10 -0
- package/dst/specbook-format-schema.js +10 -0
- package/dst/specbook-format.md +76 -5
- package/dst/specbook-format.yaml +255 -158
- package/package.json +5 -5
|
@@ -43,6 +43,13 @@ const templates = {
|
|
|
43
43
|
<span class="search-clear" id="search-clear" title="clear search">×</span>
|
|
44
44
|
</div>
|
|
45
45
|
</div>
|
|
46
|
+
<div class="fold-switch">
|
|
47
|
+
<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>
|
|
48
|
+
<div class="fold-controls">
|
|
49
|
+
<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>
|
|
50
|
+
<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>
|
|
51
|
+
</div>
|
|
52
|
+
</div>
|
|
46
53
|
<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
54
|
{{ Document.tocpanel }}
|
|
48
55
|
{{ Document.titlepage }}
|
|
@@ -51,6 +58,7 @@ const templates = {
|
|
|
51
58
|
{{ Document.artifacts }}
|
|
52
59
|
{% if Document.search %}<script>{{ Document.search }}</script>{% endif %}
|
|
53
60
|
<script>{{ Document.progress }}</script>
|
|
61
|
+
<script>{{ Document.fold }}</script>
|
|
54
62
|
{% if Document.tocpanel %}<script>{{ Document.tocscript }}</script>{% endif %}
|
|
55
63
|
{% if Document.info %}<script>{{ Document.info }}</script>{% endif %}
|
|
56
64
|
{% if Document.realtime %}<script class="realtime">{{ Document.realtime }}</script>{% endif %}
|
|
@@ -151,7 +159,7 @@ const templates = {
|
|
|
151
159
|
`,
|
|
152
160
|
/* <Properties/> */
|
|
153
161
|
"Properties": textframe `
|
|
154
|
-
<table class="props">
|
|
162
|
+
<table class="props"{% if Fold %} data-fold-height="{{ Fold }}"{% endif %}>
|
|
155
163
|
{% for property in Properties %}
|
|
156
164
|
<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
165
|
{% endfor %}
|
|
@@ -172,7 +180,7 @@ const templates = {
|
|
|
172
180
|
`,
|
|
173
181
|
/* <Table/> */
|
|
174
182
|
"Table": textframe `
|
|
175
|
-
<table class="objects">
|
|
183
|
+
<table class="objects"{% if Table.fold %} data-fold-height="{{ Table.fold }}"{% endif %}>
|
|
176
184
|
<thead>
|
|
177
185
|
<tr>
|
|
178
186
|
<th class="object-kind"><span{% if Table.info %} data-info="{{ Table.info }}" data-info-path="{{ Table.infopath }}"{% endif %}>{{ Table.head }}</span></th>
|
|
@@ -193,7 +201,7 @@ const templates = {
|
|
|
193
201
|
`,
|
|
194
202
|
/* <TableChunked/> */
|
|
195
203
|
"TableChunked": textframe `
|
|
196
|
-
<table class="objects">
|
|
204
|
+
<table class="objects"{% if Table.fold %} data-fold-height="{{ Table.fold }}"{% endif %}>
|
|
197
205
|
<thead>
|
|
198
206
|
<tr>
|
|
199
207
|
<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 +390,255 @@ const scrollProgressScript = textframe `
|
|
|
382
390
|
update()
|
|
383
391
|
})()
|
|
384
392
|
`;
|
|
393
|
+
/* the client-side script of the folding: it wraps every diagram of the
|
|
394
|
+
content into a fold container carrying a chevron mark at its top
|
|
395
|
+
left corner, folds the
|
|
396
|
+
text of every table cell towering over the other cells of its row
|
|
397
|
+
behind a chevron mark of its own, and lets the two
|
|
398
|
+
controls of the fold tab fold and unfold all diagrams and all cell
|
|
399
|
+
texts at once, with their state persisted across page loads and
|
|
400
|
+
their icons (plus the tab icon) marked while anything of their kind
|
|
401
|
+
is folded. The script
|
|
402
|
+
runs at the end of the body, as the content it wraps has to exist
|
|
403
|
+
already, and a live preview body swap replaces the containers and
|
|
404
|
+
their listeners along with the body */
|
|
405
|
+
const foldScript = textframe `
|
|
406
|
+
(function () {
|
|
407
|
+
const tab = document.querySelector("div.fold-switch")
|
|
408
|
+
if (tab === null)
|
|
409
|
+
return
|
|
410
|
+
const controls = { diagram: tab.querySelector("div.fold-diagrams"),
|
|
411
|
+
text: tab.querySelector("div.fold-texts") }
|
|
412
|
+
|
|
413
|
+
/* let the fold icon slide the two controls out of the tab (and
|
|
414
|
+
back in again), remembering the choice across page loads */
|
|
415
|
+
try { if (localStorage.getItem("specbook-fold") === "open") tab.classList.add("open") }
|
|
416
|
+
catch { /* an inaccessible storage just means no stored state */ }
|
|
417
|
+
tab.querySelector("div.fold-toggle").addEventListener("click", () => {
|
|
418
|
+
tab.classList.toggle("open")
|
|
419
|
+
try { localStorage.setItem("specbook-fold", tab.classList.contains("open") ? "open" : "closed") }
|
|
420
|
+
catch { /* an inaccessible storage just loses the state */ }
|
|
421
|
+
})
|
|
422
|
+
|
|
423
|
+
/* the default percentage by which the text of a table cell may
|
|
424
|
+
exceed the height of every other cell of its row before it
|
|
425
|
+
folds, overridden per object kind by the "data-fold-height"
|
|
426
|
+
attribute the schema configuration puts onto its table */
|
|
427
|
+
const cellHeight = 40
|
|
428
|
+
|
|
429
|
+
/* the chevron mark of a foldable element, rotated by the
|
|
430
|
+
stylesheet while the element is folded */
|
|
431
|
+
const svgNS = "http://www.w3.org/2000/svg"
|
|
432
|
+
const mark = (name, title) => {
|
|
433
|
+
const span = document.createElement("span")
|
|
434
|
+
span.className = name
|
|
435
|
+
span.title = title
|
|
436
|
+
const svg = document.createElementNS(svgNS, "svg")
|
|
437
|
+
svg.setAttribute("viewBox", "0 0 24 24")
|
|
438
|
+
svg.setAttribute("fill", "none")
|
|
439
|
+
svg.setAttribute("stroke", "currentColor")
|
|
440
|
+
svg.setAttribute("stroke-width", "2.5")
|
|
441
|
+
svg.setAttribute("stroke-linecap", "round")
|
|
442
|
+
svg.setAttribute("stroke-linejoin", "round")
|
|
443
|
+
const path = document.createElementNS(svgNS, "path")
|
|
444
|
+
path.setAttribute("d", "M5 9l7 7 7-7")
|
|
445
|
+
svg.appendChild(path)
|
|
446
|
+
span.appendChild(svg)
|
|
447
|
+
return span
|
|
448
|
+
}
|
|
449
|
+
|
|
450
|
+
/* wrap every diagram into its fold container, carrying the
|
|
451
|
+
chevron mark plus the muted placeholder the diagram leaves
|
|
452
|
+
behind while folded, which is the very icon of its tab
|
|
453
|
+
control */
|
|
454
|
+
const folds = { diagram: [], text: [] }
|
|
455
|
+
document.querySelectorAll("article div.diagram, nav.doc div.diagram").forEach((el) => {
|
|
456
|
+
const fold = document.createElement("div")
|
|
457
|
+
fold.className = "fold"
|
|
458
|
+
el.parentNode.insertBefore(fold, el)
|
|
459
|
+
const chevron = mark("fold-mark", "fold/unfold this diagram")
|
|
460
|
+
const label = document.createElement("span")
|
|
461
|
+
label.className = "fold-label"
|
|
462
|
+
label.appendChild(controls.diagram.querySelector("svg").cloneNode(true))
|
|
463
|
+
fold.append(chevron, label, el)
|
|
464
|
+
chevron.addEventListener("click", () => {
|
|
465
|
+
fold.classList.toggle("folded")
|
|
466
|
+
sync()
|
|
467
|
+
})
|
|
468
|
+
folds.diagram.push(fold)
|
|
469
|
+
})
|
|
470
|
+
|
|
471
|
+
/* the rendered height of the content of a table cell, or of its
|
|
472
|
+
leading part up to a word position: a cell box itself always
|
|
473
|
+
spans the whole row, so only a range over the content
|
|
474
|
+
measures the text */
|
|
475
|
+
const range = document.createRange()
|
|
476
|
+
const height = (cell, upto) => {
|
|
477
|
+
if (upto === undefined)
|
|
478
|
+
range.selectNodeContents(cell)
|
|
479
|
+
else {
|
|
480
|
+
range.setStart(cell, 0)
|
|
481
|
+
range.setEnd(upto.node, upto.offset)
|
|
482
|
+
}
|
|
483
|
+
return range.getBoundingClientRect().height
|
|
484
|
+
}
|
|
485
|
+
|
|
486
|
+
/* the word positions of a table cell, each right behind one word */
|
|
487
|
+
const positions = (cell) => {
|
|
488
|
+
const walker = document.createTreeWalker(cell, NodeFilter.SHOW_TEXT)
|
|
489
|
+
const list = []
|
|
490
|
+
let node
|
|
491
|
+
while ((node = walker.nextNode()) !== null) {
|
|
492
|
+
const regex = /\\S+/g
|
|
493
|
+
let m
|
|
494
|
+
while ((m = regex.exec(node.nodeValue ?? "")) !== null)
|
|
495
|
+
list.push({ node, offset: m.index + m[0].length })
|
|
496
|
+
}
|
|
497
|
+
return list
|
|
498
|
+
}
|
|
499
|
+
|
|
500
|
+
/* a cell taking part in the comparison of its row: one with
|
|
501
|
+
content of its own, i.e. neither empty (a not given property
|
|
502
|
+
draws its marker through the stylesheet alone) nor holding
|
|
503
|
+
further cells (a chunked or nested table, whose own cells are
|
|
504
|
+
compared instead) nor a diagram (which folds on its own and
|
|
505
|
+
whose SVG text cannot carry the wrapper of a hidden
|
|
506
|
+
remainder) */
|
|
507
|
+
const candidate = (cell) =>
|
|
508
|
+
cell.querySelector("td") === null
|
|
509
|
+
&& cell.querySelector("div.diagram") === null
|
|
510
|
+
&& (cell.textContent.trim() !== "" || cell.querySelector("img") !== null)
|
|
511
|
+
|
|
512
|
+
/* decide which table cells fold: one which is taller than the
|
|
513
|
+
configured percentage above every other cell of its row (the
|
|
514
|
+
leading name column of a compact table excluded, as it is no
|
|
515
|
+
text of its own). All rows are measured before the first cut,
|
|
516
|
+
as every cut shifts the layout of the ones still to come */
|
|
517
|
+
const cuts = []
|
|
518
|
+
document.querySelectorAll("article table tr").forEach((row) => {
|
|
519
|
+
const table = row.closest("table")
|
|
520
|
+
let cells = Array.from(row.children).filter((el) => el.tagName === "TD")
|
|
521
|
+
if (!table.classList.contains("chunks"))
|
|
522
|
+
cells = cells.slice(1)
|
|
523
|
+
cells = cells.filter(candidate)
|
|
524
|
+
if (cells.length < 2)
|
|
525
|
+
return
|
|
526
|
+
const configured = row.closest("table[data-fold-height]")
|
|
527
|
+
const percent = Number(configured?.getAttribute("data-fold-height")) || cellHeight
|
|
528
|
+
const heights = cells.map((cell) => height(cell))
|
|
529
|
+
cells.forEach((cell, i) => {
|
|
530
|
+
const other = Math.max(...heights.filter((_, j) => j !== i))
|
|
531
|
+
const limit = other * (1 + percent / 100)
|
|
532
|
+
if (heights[i] > limit)
|
|
533
|
+
cuts.push({ cell, limit, full: heights[i] })
|
|
534
|
+
})
|
|
535
|
+
})
|
|
536
|
+
|
|
537
|
+
/* hide a node of the folded remainder of a table cell: an
|
|
538
|
+
element carries the class itself, a text node gets wrapped */
|
|
539
|
+
const rest = (node) => {
|
|
540
|
+
if (node.nodeType === Node.ELEMENT_NODE)
|
|
541
|
+
node.classList.add("fold-rest")
|
|
542
|
+
else if (node.nodeType === Node.TEXT_NODE) {
|
|
543
|
+
const span = document.createElement("span")
|
|
544
|
+
span.className = "fold-rest"
|
|
545
|
+
node.parentNode.insertBefore(span, node)
|
|
546
|
+
span.appendChild(node)
|
|
547
|
+
}
|
|
548
|
+
}
|
|
549
|
+
|
|
550
|
+
/* the minimum percentage of its own height a cell has to gain
|
|
551
|
+
by folding, as hiding less than that is no visible relief
|
|
552
|
+
and only costs the reader a chevron to click (measured after
|
|
553
|
+
the cut, as the chevron itself can claim a line of its own) */
|
|
554
|
+
const cellGain = 25
|
|
555
|
+
|
|
556
|
+
/* fold the decided cells back onto their height limit: the last
|
|
557
|
+
word position still fitting is found by a binary search over
|
|
558
|
+
the measured word positions, the text node is split there and
|
|
559
|
+
the chevron mark takes its place, followed by the remainder
|
|
560
|
+
hidden up the ancestor chain of the cell */
|
|
561
|
+
for (const cut of cuts) {
|
|
562
|
+
const list = positions(cut.cell)
|
|
563
|
+
if (list.length === 0)
|
|
564
|
+
continue
|
|
565
|
+
let lo = 0
|
|
566
|
+
let hi = list.length - 1
|
|
567
|
+
while (lo < hi) {
|
|
568
|
+
const mid = Math.ceil((lo + hi) / 2)
|
|
569
|
+
if (height(cut.cell, list[mid]) <= cut.limit)
|
|
570
|
+
lo = mid
|
|
571
|
+
else
|
|
572
|
+
hi = mid - 1
|
|
573
|
+
}
|
|
574
|
+
const tail = list[lo].node.splitText(list[lo].offset)
|
|
575
|
+
const chevron = mark("fold-more", "fold/unfold the remaining text")
|
|
576
|
+
tail.parentNode.insertBefore(chevron, tail)
|
|
577
|
+
let current = chevron
|
|
578
|
+
while (current !== cut.cell) {
|
|
579
|
+
let next = current.nextSibling
|
|
580
|
+
while (next !== null) {
|
|
581
|
+
const following = next.nextSibling
|
|
582
|
+
rest(next)
|
|
583
|
+
next = following
|
|
584
|
+
}
|
|
585
|
+
current = current.parentNode
|
|
586
|
+
}
|
|
587
|
+
/* the gain is measured on the applied fold, as only that
|
|
588
|
+
one tells the real one, and a fold gaining less than the
|
|
589
|
+
minimum share is taken back again, which the chevron
|
|
590
|
+
alone has to leave behind, as the hidden remainder stays
|
|
591
|
+
inert without the class -- which a kept fold sheds
|
|
592
|
+
again, too, as every cell starts out unfolded */
|
|
593
|
+
cut.cell.classList.add("folded")
|
|
594
|
+
const gained = height(cut.cell) <= cut.full * (1 - cellGain / 100)
|
|
595
|
+
cut.cell.classList.remove("folded")
|
|
596
|
+
if (!gained) {
|
|
597
|
+
chevron.remove()
|
|
598
|
+
continue
|
|
599
|
+
}
|
|
600
|
+
folds.text.push(cut.cell)
|
|
601
|
+
chevron.addEventListener("click", (event) => {
|
|
602
|
+
/* the cut can land inside a hyperlink, whose navigation
|
|
603
|
+
the chevron has to suppress for its own click alone,
|
|
604
|
+
so the remaining link text still follows the link */
|
|
605
|
+
event.preventDefault()
|
|
606
|
+
cut.cell.classList.toggle("folded")
|
|
607
|
+
sync()
|
|
608
|
+
})
|
|
609
|
+
}
|
|
610
|
+
|
|
611
|
+
/* mark the control of a kind while anything of that kind is
|
|
612
|
+
folded, and the tab itself while any kind at all is */
|
|
613
|
+
const sync = () => {
|
|
614
|
+
let any = false
|
|
615
|
+
for (const kind of Object.keys(folds)) {
|
|
616
|
+
const folded = folds[kind].some((fold) => fold.classList.contains("folded"))
|
|
617
|
+
controls[kind].classList.toggle("folded", folded)
|
|
618
|
+
any ||= folded
|
|
619
|
+
}
|
|
620
|
+
tab.classList.toggle("folded", any)
|
|
621
|
+
}
|
|
622
|
+
for (const kind of Object.keys(folds)) {
|
|
623
|
+
/* without a stored state the rendered one stands, which is
|
|
624
|
+
everything unfolded */
|
|
625
|
+
let stored = null
|
|
626
|
+
try { stored = localStorage.getItem("specbook-fold-" + kind) }
|
|
627
|
+
catch { /* an inaccessible storage just means no stored state */ }
|
|
628
|
+
if (stored !== null)
|
|
629
|
+
folds[kind].forEach((fold) => { fold.classList.toggle("folded", stored === "folded") })
|
|
630
|
+
controls[kind].addEventListener("click", () => {
|
|
631
|
+
const all = !folds[kind].every((fold) => fold.classList.contains("folded"))
|
|
632
|
+
folds[kind].forEach((fold) => { fold.classList.toggle("folded", all) })
|
|
633
|
+
try { localStorage.setItem("specbook-fold-" + kind, all ? "folded" : "unfolded") }
|
|
634
|
+
catch { /* an inaccessible storage just loses the state */ }
|
|
635
|
+
sync()
|
|
636
|
+
})
|
|
637
|
+
}
|
|
638
|
+
sync()
|
|
639
|
+
|
|
640
|
+
})()
|
|
641
|
+
`;
|
|
385
642
|
/* the client-side script of the table of contents side panel: it
|
|
386
643
|
slides the panel out of and back into the viewport edge (closing it
|
|
387
644
|
also on a jump, on "Escape", and on a click outside), remembers the
|
|
@@ -470,6 +727,7 @@ const tocPanelScript = textframe `
|
|
|
470
727
|
&& event.target.closest("div.search") === null
|
|
471
728
|
&& event.target.closest("div.theme-switch") === null
|
|
472
729
|
&& event.target.closest("div.info-switch") === null
|
|
730
|
+
&& event.target.closest("div.fold-switch") === null
|
|
473
731
|
&& event.target.closest("div.realtime-status") === null)
|
|
474
732
|
toggle(false)
|
|
475
733
|
}
|
|
@@ -1051,6 +1309,7 @@ const renderTable = (children, maxColumns) => {
|
|
|
1051
1309
|
infopath: infoPathOf(children[0], false),
|
|
1052
1310
|
keys,
|
|
1053
1311
|
desc,
|
|
1312
|
+
fold: formatOf(children[0])?.maxCellHeight,
|
|
1054
1313
|
/* under the fixed table layout the description column claims
|
|
1055
1314
|
twice the share of a regular column, compressing the others */
|
|
1056
1315
|
width: Math.round(200 / (keys.length + 3)),
|
|
@@ -1074,6 +1333,7 @@ const renderTable = (children, maxColumns) => {
|
|
|
1074
1333
|
head: children[0].kind !== "" ? children[0].kind : "Name",
|
|
1075
1334
|
info: infoKeyOf(children[0]),
|
|
1076
1335
|
infopath: infoPathOf(children[0], false),
|
|
1336
|
+
fold: formatOf(children[0])?.maxCellHeight,
|
|
1077
1337
|
width: Math.round(100 / maxColumns),
|
|
1078
1338
|
rows: children.map((child, i) => scoped(child, () => {
|
|
1079
1339
|
const cells = keys.map((key) => ({ key, desc: false, span: 1,
|
|
@@ -1122,7 +1382,8 @@ const renderObject = (object, level, concise) => {
|
|
|
1122
1382
|
name: inline(object.name),
|
|
1123
1383
|
diagram: diagramOf(object),
|
|
1124
1384
|
properties: properties.length > 0 ?
|
|
1125
|
-
safe(render("Properties", { Properties: inlineProperties(object, properties)
|
|
1385
|
+
safe(render("Properties", { Properties: inlineProperties(object, properties),
|
|
1386
|
+
Fold: formatOf(object)?.maxCellHeight })) : "",
|
|
1126
1387
|
description: object.description !== undefined ?
|
|
1127
1388
|
safe(renderDescription(object.description)) : "",
|
|
1128
1389
|
children: safe(renderChildren(object, level + 1, concise))
|
|
@@ -1357,6 +1618,7 @@ export const renderHtml = async (specification, config, tocPages, css, realtime
|
|
|
1357
1618
|
safe(renderTitlePage(title, formatDate(created), formatDate(modified))) : "",
|
|
1358
1619
|
search: safe(searchScript()),
|
|
1359
1620
|
progress: safe(scrollProgressScript),
|
|
1621
|
+
fold: safe(foldScript),
|
|
1360
1622
|
info: info !== null ? safe(infoScript(info, spec ?? {})) : "",
|
|
1361
1623
|
realtime: realtime ? safe(realtimeScript) : "",
|
|
1362
1624
|
toc: entries.length > 0 ? safe(render("Toc", { Toc: { entries } })) : "",
|
|
@@ -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
|
|
@@ -37,6 +37,13 @@ export type SchemaDiagramCenterEdges = {
|
|
|
37
37
|
both?: string;
|
|
38
38
|
labeled?: string;
|
|
39
39
|
};
|
|
40
|
+
export type SchemaDiagramNest = {
|
|
41
|
+
properties?: string[];
|
|
42
|
+
parent?: boolean;
|
|
43
|
+
kind?: boolean;
|
|
44
|
+
layout?: "graph" | "hub" | "grid";
|
|
45
|
+
crossing?: "nodes" | "target" | "both";
|
|
46
|
+
};
|
|
40
47
|
export type SchemaDiagram = {
|
|
41
48
|
type?: "graph" | "hub" | "grid";
|
|
42
49
|
nodes?: string;
|
|
@@ -53,12 +60,15 @@ export type SchemaDiagram = {
|
|
|
53
60
|
onlyConnected?: boolean;
|
|
54
61
|
collapse?: boolean;
|
|
55
62
|
qualified?: boolean;
|
|
63
|
+
ordered?: boolean;
|
|
64
|
+
nest?: SchemaDiagramNest;
|
|
56
65
|
properties?: string[];
|
|
57
66
|
config?: Partial<GradiaConfig>;
|
|
58
67
|
};
|
|
59
68
|
export type SchemaFormat = {
|
|
60
69
|
type?: "auto" | "complex" | "concise";
|
|
61
70
|
maxTableColumns?: number;
|
|
71
|
+
maxCellHeight?: number;
|
|
62
72
|
withUnusedProps?: boolean;
|
|
63
73
|
};
|
|
64
74
|
export type SchemaProperty = {
|
|
@@ -50,6 +50,13 @@ const SchemaDiagramCenterEdges = v.strictObject({
|
|
|
50
50
|
both: v.optional(v.string()),
|
|
51
51
|
labeled: v.optional(v.string())
|
|
52
52
|
});
|
|
53
|
+
const SchemaDiagramNest = v.strictObject({
|
|
54
|
+
properties: v.optional(v.array(v.string())),
|
|
55
|
+
parent: v.optional(v.boolean()),
|
|
56
|
+
kind: v.optional(v.boolean()),
|
|
57
|
+
layout: v.optional(v.picklist(["graph", "hub", "grid"])),
|
|
58
|
+
crossing: v.optional(v.picklist(["nodes", "target", "both"]))
|
|
59
|
+
});
|
|
53
60
|
const SchemaDiagram = v.strictObject({
|
|
54
61
|
type: v.optional(v.picklist(["graph", "hub", "grid"])),
|
|
55
62
|
nodes: v.optional(v.string()),
|
|
@@ -66,12 +73,15 @@ const SchemaDiagram = v.strictObject({
|
|
|
66
73
|
onlyConnected: v.optional(v.boolean()),
|
|
67
74
|
collapse: v.optional(v.boolean()),
|
|
68
75
|
qualified: v.optional(v.boolean()),
|
|
76
|
+
ordered: v.optional(v.boolean()),
|
|
77
|
+
nest: v.optional(SchemaDiagramNest),
|
|
69
78
|
properties: v.optional(v.array(v.string())),
|
|
70
79
|
config: v.optional(SchemaDiagramConfig)
|
|
71
80
|
});
|
|
72
81
|
const SchemaFormat = v.strictObject({
|
|
73
82
|
type: v.optional(v.picklist(["auto", "complex", "concise"])),
|
|
74
83
|
maxTableColumns: v.optional(v.pipe(v.number(), v.integer(), v.minValue(2))),
|
|
84
|
+
maxCellHeight: v.optional(v.pipe(v.number(), v.integer(), v.minValue(0))),
|
|
75
85
|
withUnusedProps: v.optional(v.boolean())
|
|
76
86
|
});
|
|
77
87
|
const SchemaObject = v.strictObject({
|
package/dst/specbook-format.md
CHANGED
|
@@ -123,6 +123,7 @@ type SchemaDiagram = {
|
|
|
123
123
|
type SchemaFormat = {
|
|
124
124
|
type?: "auto" | "complex" | "concise"
|
|
125
125
|
maxTableColumns?: number
|
|
126
|
+
maxCellHeight?: number
|
|
126
127
|
withUnusedProps?: boolean
|
|
127
128
|
}
|
|
128
129
|
type SchemaProperty = {
|
|
@@ -350,7 +351,7 @@ type SchemaGradiaConfig = Partial<{
|
|
|
350
351
|
|
|
351
352
|
- `SchemaDiagram.deep?: boolean`:
|
|
352
353
|
whether the references of the descendants of a node count as its own,
|
|
353
|
-
|
|
354
|
+
counted as the arity (default: `false`),
|
|
354
355
|
BECAUSE the relations between coarse objects live in their fine parts
|
|
355
356
|
|
|
356
357
|
- `SchemaDiagram.onlyConnected?: boolean`:
|
|
@@ -365,6 +366,40 @@ type SchemaGradiaConfig = Partial<{
|
|
|
365
366
|
whether every node is labeled with its kind (default: `false`),
|
|
366
367
|
BECAUSE names alone are ambiguous across multiple object kinds
|
|
367
368
|
|
|
369
|
+
- `SchemaDiagram.ordered?: boolean`:
|
|
370
|
+
whether the nodes are placed in document order instead of by the
|
|
371
|
+
layered layout, the top-level ones stacked top-down and the members
|
|
372
|
+
of a container lined up left-to-right (default: `false`),
|
|
373
|
+
BECAUSE in a coarse-to-fine specification the references then all
|
|
374
|
+
point backward, i.e. upward
|
|
375
|
+
|
|
376
|
+
- `SchemaDiagram.nest?: SchemaDiagramNest`:
|
|
377
|
+
nesting of the nodes into container nodes,
|
|
378
|
+
BECAUSE a containment drawn as a box beats one drawn as an edge
|
|
379
|
+
|
|
380
|
+
- `SchemaDiagramNest.properties?: string[]`:
|
|
381
|
+
properties whose single referenced object is the container of a node,
|
|
382
|
+
BECAUSE a `PART-OF` or `TIER` reference is a containment, not a relation
|
|
383
|
+
|
|
384
|
+
- `SchemaDiagramNest.parent?: boolean`:
|
|
385
|
+
whether the parent object is the container of a node (default: `false`),
|
|
386
|
+
BECAUSE the nesting of the objects is a containment, too
|
|
387
|
+
|
|
388
|
+
- `SchemaDiagramNest.kind?: boolean`:
|
|
389
|
+
whether a synthetic container per object kind holds the nodes
|
|
390
|
+
(default: `false`),
|
|
391
|
+
BECAUSE a kind is a grouping no object models
|
|
392
|
+
|
|
393
|
+
- `SchemaDiagramNest.layout?: "graph" | "hub" | "grid"`:
|
|
394
|
+
shape the members of every container are laid out with
|
|
395
|
+
(default: the diagram type),
|
|
396
|
+
BECAUSE the inside of a container may tell a different story than the whole
|
|
397
|
+
|
|
398
|
+
- `SchemaDiagramNest.crossing?: "nodes" | "target" | "both"`:
|
|
399
|
+
ends of a boundary-crossing edge lifted onto the containers
|
|
400
|
+
(default: `nodes`, i.e. none),
|
|
401
|
+
BECAUSE a bundle of references into a container reads as one edge onto it
|
|
402
|
+
|
|
368
403
|
- `SchemaDiagram.properties?: string[]`:
|
|
369
404
|
property names attached to the nodes as key/value annotations,
|
|
370
405
|
BECAUSE the node name alone rarely makes the nodes comparable
|
|
@@ -385,6 +420,10 @@ type SchemaGradiaConfig = Partial<{
|
|
|
385
420
|
maximum column count of the table of the kind (default: `4`),
|
|
386
421
|
BECAUSE the fixed print/PDF page width bounds the table width
|
|
387
422
|
|
|
423
|
+
- `SchemaFormat.maxCellHeight?: number`:
|
|
424
|
+
percent a table cell may tower over its row before it folds (default: `40`),
|
|
425
|
+
BECAUSE a cell of rich prose otherwise dwarfs the whole table
|
|
426
|
+
|
|
388
427
|
- `SchemaFormat.withUnusedProps?: boolean`:
|
|
389
428
|
whether unused properties still render (default: `false`),
|
|
390
429
|
BECAUSE empty entries are noise, unless the gap is the message
|
|
@@ -474,9 +513,10 @@ type SchemaGradiaConfig = Partial<{
|
|
|
474
513
|
|
|
475
514
|
The edges are derived from the `[[xxx]]` references of the node objects
|
|
476
515
|
-- from their property values only, or, with `links: all`, from their
|
|
477
|
-
descriptions as well.
|
|
478
|
-
|
|
479
|
-
|
|
516
|
+
descriptions as well. Every referenced object is lifted to its nearest
|
|
517
|
+
ancestor-or-self within the node set, so a reference onto the scenario
|
|
518
|
+
of a use case yields an edge onto the use case. With `deep: true` the
|
|
519
|
+
references of all descendants of a node object count as its own and the
|
|
480
520
|
number of references behind an edge becomes its arity, so a diagram of
|
|
481
521
|
coarse objects (like the artifacts of a whole specification) shows the
|
|
482
522
|
relations living in their fine-grained parts. With `labeled: true` an
|
|
@@ -527,6 +567,34 @@ nodes as key/value annotations (with every `[[xxx]]` reference stripped
|
|
|
527
567
|
to its target object name), and `config` passes arbitrary Gradia
|
|
528
568
|
rendering options (e.g. `grid-columns-max: 5`) through to the diagram.
|
|
529
569
|
|
|
570
|
+
`ordered` replaces the layered layout of a `graph` by the document
|
|
571
|
+
order of the objects: the top-level nodes stack top-down in that order
|
|
572
|
+
(a synthetic container at the position of its first member), while the
|
|
573
|
+
members of a container line up left-to-right in it, wrapped by the
|
|
574
|
+
Gradia option `graph-columns-max`, so the references of a coarse-to-fine
|
|
575
|
+
specification all point backward, i.e. upward or leftward.
|
|
576
|
+
|
|
577
|
+
`nest` nests the nodes into *container* nodes, drawn as boxes around
|
|
578
|
+
their members with the edges crossing the box borders: the first of its
|
|
579
|
+
`properties` carrying exactly one resolvable reference names the
|
|
580
|
+
container of a node (a container outside the node set is added to it,
|
|
581
|
+
labeled and linked like a node, and nested on its own in turn, so a
|
|
582
|
+
`NODE` sits in its `TIER` and the tier in the tier it is `PART-OF`),
|
|
583
|
+
else with `parent: true` the parent object is the container when part
|
|
584
|
+
of the node set, else with `kind: true` a synthetic container per object
|
|
585
|
+
kind is. An edge between a container and one of its own members is
|
|
586
|
+
dropped, as the nesting already shows it, `layout` selects the shape
|
|
587
|
+
the members of every container are laid out with (default: the diagram
|
|
588
|
+
type), a `hub` carries no containers, as every container would need a
|
|
589
|
+
primary node of its own, and a node nested into itself or into a cycle
|
|
590
|
+
is reported. With `crossing: target` an edge crossing a container
|
|
591
|
+
boundary ends at the outermost container of its target not enclosing
|
|
592
|
+
its source, too, and with `crossing: both` it also starts at the
|
|
593
|
+
corresponding container of its source, where the edges coinciding
|
|
594
|
+
afterwards merge into one with their arities summed, so the edges
|
|
595
|
+
inside a container stay node-to-node while the bundles of references
|
|
596
|
+
into a container become single edges onto it.
|
|
597
|
+
|
|
530
598
|
### Format
|
|
531
599
|
|
|
532
600
|
An object kind can furthermore carry a `format` field, which controls
|
|
@@ -542,7 +610,10 @@ Inside a `concise` table, unconfigured child kinds implicitly stay
|
|
|
542
610
|
an explicitly configured `type` is always honored, even a `complex`
|
|
543
611
|
rendering pressed into a cell. `maxTableColumns` (default: `4`) bounds
|
|
544
612
|
the columns of the compact table of the kind (a wider group chunks
|
|
545
|
-
its properties into embedded per-object tables),
|
|
613
|
+
its properties into embedded per-object tables), `maxCellHeight`
|
|
614
|
+
(default: `40`) is the percentage a table cell of the kind may exceed
|
|
615
|
+
the height of every other non-empty cell of its row before the HTML
|
|
616
|
+
export folds its remaining text away, and `withUnusedProps`
|
|
546
617
|
(default: `false`) unconditionally renders the defined but unused
|
|
547
618
|
properties of that kind, as property table lines or table columns. In
|
|
548
619
|
the HTML/PDF export, a property absent from an object shows an "empty
|