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