@rse/specbook 1.3.2 → 1.4.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (50) hide show
  1. package/README.md +9 -2
  2. package/dst/specbook-api.d.ts +5 -1
  3. package/dst/specbook-api.js +23 -9
  4. package/dst/specbook-cli.js +8 -5
  5. package/dst/specbook-cmd-export.js +2 -2
  6. package/dst/specbook-cmd-init.js +16 -2
  7. package/dst/specbook-coverage.js +1 -1
  8. package/dst/specbook-diagram.d.ts +1 -1
  9. package/dst/specbook-diagram.js +12 -10
  10. package/dst/specbook-export-ast.d.ts +1 -1
  11. package/dst/specbook-export-ast.js +21 -4
  12. package/dst/specbook-export-code.d.ts +14 -0
  13. package/dst/specbook-export-code.js +155 -0
  14. package/dst/specbook-export-common.d.ts +8 -2
  15. package/dst/specbook-export-common.js +126 -6
  16. package/dst/specbook-export-diagram.d.ts +15 -0
  17. package/dst/specbook-export-diagram.js +211 -0
  18. package/dst/specbook-export-html-fold.d.ts +20 -0
  19. package/dst/specbook-export-html-fold.js +458 -0
  20. package/dst/specbook-export-html-render.d.ts +51 -0
  21. package/dst/specbook-export-html-render.js +748 -0
  22. package/dst/specbook-export-html-search.js +6 -5
  23. package/dst/specbook-export-html-template.d.ts +21 -0
  24. package/dst/specbook-export-html-template.js +282 -0
  25. package/dst/specbook-export-html-widget.d.ts +12 -0
  26. package/dst/specbook-export-html-widget.js +605 -0
  27. package/dst/specbook-export-html.css +325 -50
  28. package/dst/specbook-export-html.js +62 -1931
  29. package/dst/specbook-export-image.d.ts +2 -0
  30. package/dst/specbook-export-image.js +161 -9
  31. package/dst/specbook-export-md.js +60 -12
  32. package/dst/specbook-format-schema.d.ts +1 -1
  33. package/dst/specbook-format-schema.js +1 -1
  34. package/dst/specbook-format-spec.d.ts +1 -0
  35. package/dst/specbook-format-spec.js +1 -0
  36. package/dst/specbook-format.md +173 -19
  37. package/dst/specbook-format.yaml +265 -24
  38. package/dst/specbook-ignore.js +2 -0
  39. package/dst/specbook-link.js +21 -5
  40. package/dst/specbook-mcp.js +9 -5
  41. package/dst/specbook-parse-common.d.ts +24 -0
  42. package/dst/specbook-parse-common.js +137 -10
  43. package/dst/specbook-parse-semantic.js +2 -0
  44. package/dst/specbook-parse-syntax.js +81 -20
  45. package/dst/specbook-parse.js +1 -1
  46. package/dst/specbook-project.d.ts +1 -0
  47. package/dst/specbook-project.js +10 -1
  48. package/dst/specbook-theme.d.ts +1 -0
  49. package/dst/specbook-theme.js +2 -0
  50. package/package.json +18 -12
@@ -3,1840 +3,19 @@
3
3
  ** Copyright (c) 2026 Dr. Ralf S. Engelschall <rse@engelschall.com>
4
4
  ** Licensed under Apache 2.0 <https://spdx.org/licenses/Apache-2.0>
5
5
  */
6
- import { Marked } from "marked";
7
- import { markedSmartypants } from "marked-smartypants";
8
- import nunjucks from "nunjucks";
9
- import nunjucksAddons from "@rse/nunjucks-addons";
10
- import textframe from "textframe";
11
6
  import { Gradia } from "@rse/gradia";
12
- import { buildLinkIndex, resolveUnique, expandReferences, anchorPaths, plainText } from "./specbook-link.js";
13
- import { compileValueExpr, splitItems } from "./specbook-parse-value.js";
14
- import { embeddingRegex, embeddingCount, embeddingThemes } from "./specbook-parse-common.js";
15
- import { escapeHtml, stylesheet, searchScript, fallbackLogo, isTitleObject, titleObject, documentTitle, documentLang, documentThemeStyle } from "./specbook-export-common.js";
7
+ import { buildLinkIndex, anchorPaths, plainText } from "./specbook-link.js";
8
+ import { stylesheet, searchScript, isTitleObject, titleObject, documentTitle, documentLang, documentThemeStyle } from "./specbook-export-common.js";
16
9
  import { collectSchemas } from "./specbook-parse-semantic.js";
17
10
  import { renderDiagrams } from "./specbook-diagram.js";
18
- import { optimizeImages } from "./specbook-export-image.js";
19
- import { specCoverage, coverageRatio } from "./specbook-coverage.js";
20
- /* ==== Templates ==== */
21
- /* the built-in Nunjucks templates for the HTML export */
22
- const templates = {
23
- /* <Document/> */
24
- "Document": textframe `
25
- <!DOCTYPE html>
26
- <html{% if Document.lang %} lang="{{ Document.lang }}"{% endif %}{% if Document.theme %} class="theme-{{ Document.theme }}"{% endif %}>
27
- <head>
28
- <meta charset="utf-8"/>
29
- <title>{{ Document.title }}</title>
30
- <style>
31
- {{ Document.css }}
32
- </style>
33
- <script>
34
- {{ Document.themescript }}
35
- </script>
36
- </head>
37
- <body>
38
- {% if Document.realtime %}<div class="realtime-status disconnected" title="live preview connection"><svg class="realtime-icon" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5" stroke-linecap="round" stroke-linejoin="round"><path d="M9 8V2"/><path d="M15 8V2"/><path d="M18 8v5a4 4 0 0 1-4 4h-4a4 4 0 0 1-4-4V8Z"/><path d="M12 22v-5"/></svg></div>{% endif %}
39
- <div class="theme-switch" onclick="themeSwitch()" title="switch color theme"><svg class="theme-icon" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5" stroke-linecap="round"><circle cx="12" cy="12" r="8.5"/><path d="M12 3.5 A 8.5 8.5 0 0 0 12 20.5 Z" fill="currentColor" stroke="none"/></svg></div>
40
- {% if Document.info %}<div class="info-switch" title="toggle description popups"><svg class="info-icon" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5" stroke-linecap="round"><circle cx="12" cy="12" r="8.5"/><line x1="12" y1="11" x2="12" y2="16"/><line x1="12" y1="7.75" x2="12.01" y2="7.75"/></svg></div>{% endif %}
41
- <div class="search" id="search">
42
- <div class="search-toggle" id="search-toggle" title="toggle search field"><svg class="search-icon" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5" stroke-linecap="round" stroke-linejoin="round"><polygon points="3.5,4.5 20.5,4.5 14,12.5 14,20 10,17.5 10,12.5"/></svg></div>
43
- <div class="search-field">
44
- <input type="text" id="search-input" placeholder="Filter&hellip; (fuzzy matched keywords)" autocomplete="off" spellcheck="false"/>
45
- <span class="search-clear" id="search-clear" title="clear search">&#x00D7;</span>
46
- </div>
47
- </div>
48
- {%- if not Document.omitted.all %}
49
- <div class="fold-switch">
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>
51
- <div class="fold-controls">
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 %}
57
- </div>
58
- </div>
59
- {%- endif %}
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>
61
- {{ Document.tocpanel }}
62
- {{ Document.titlepage }}
63
- {{ Document.toc }}
64
- {{ Document.doc }}
65
- {{ Document.artifacts }}
66
- {% if Document.search %}<script>{{ Document.search }}</script>{% endif %}
67
- <script>{{ Document.progress }}</script>
68
- <script>{{ Document.fold }}</script>
69
- <script>{{ Document.maximize }}</script>
70
- {% if Document.tocpanel %}<script>{{ Document.tocscript }}</script>{% endif %}
71
- {% if Document.info %}<script>{{ Document.info }}</script>{% endif %}
72
- {% if Document.realtime %}<script class="realtime">{{ Document.realtime }}</script>{% endif %}
73
- </body>
74
- </html>
75
- `,
76
- /* <Placeholder/> */
77
- "Placeholder": textframe `
78
- <!DOCTYPE html>
79
- <html>
80
- <head>
81
- <meta charset="utf-8"/>
82
- <title>{{ Placeholder.title }}</title>
83
- <style>
84
- {{ Placeholder.css }}
85
- </style>
86
- <script>
87
- {{ Placeholder.themescript }}
88
- </script>
89
- </head>
90
- <body>
91
- <div class="placeholder">{{ Placeholder.message }}</div>
92
- <script class="realtime">{{ Placeholder.realtime }}</script>
93
- </body>
94
- </html>
95
- `,
96
- /* <TitlePage/> */
97
- "TitlePage": textframe `
98
- <div class="titlepage" id="titlepage">
99
- {% if TitlePage.logo %}<div class="logo">{{ TitlePage.logo }}</div>{% endif %}
100
- <div class="title">{{ TitlePage.title }}</div>
101
- {% if TitlePage.subtitle %}<div class="subtitle">{{ TitlePage.subtitle }}</div>{% endif %}
102
- {% if TitlePage.author %}<div class="author">{{ TitlePage.author }}</div>{% endif %}
103
- <table class="meta">
104
- {% if TitlePage.version %}<tr><td class="label">Version:</td><td>{{ TitlePage.version }}</td></tr>{% endif %}
105
- <tr><td class="label">Created:</td><td>{{ TitlePage.created }}</td></tr>
106
- <tr><td class="label">Modified:</td><td>{{ TitlePage.modified }}</td></tr>
107
- </table>
108
- {{ TitlePage.description }}
109
- {{ TitlePage.properties }}
110
- </div>
111
- `,
112
- /* <Toc/> */
113
- "Toc": textframe `
114
- <nav class="toc" id="toc">
115
- <h1>Table of Contents</h1>
116
- <table>
117
- {% for entry in Toc.entries %}
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">&#x26AD;</span></a></td>{% if entry.page %}<td class="page"><a href="#{{ entry.id }}">{{ entry.page }}</a></td>{% endif %}</tr>
119
- {% endfor %}
120
- </table>
121
- </nav>
122
- `,
123
- /* <TocPanel/> */
124
- "TocPanel": textframe `
125
- <nav class="toc-panel">
126
- <div class="toc-tab" title="toggle table of contents"><svg class="toc-icon" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5" stroke-linecap="round"><line x1="3" y1="6" x2="21" y2="6"/><line x1="3" y1="12" x2="21" y2="12"/><line x1="3" y1="18" x2="21" y2="18"/></svg></div>
127
- <div class="toc-list">
128
- <ul class="extra">
129
- {% if TocPanel.title %}<li><a href="#titlepage"><span class="entry">Title</span></a></li>{% endif %}
130
- <li><a href="#toc"><span class="entry">Table of Contents</span></a></li>
131
- {% if TocPanel.doc %}<li><a href="#doc"><span class="entry">Diagram of Contents</span></a></li>{% endif %}
132
- </ul>
133
- {{ TocPanel.entries }}
134
- </div>
135
- </nav>
136
- `,
137
- /* <TocPanelEntries/> */
138
- "TocPanelEntries": textframe `
139
- <ul>
140
- {% for entry in Entries %}
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>
142
- {% endfor %}
143
- </ul>
144
- `,
145
- /* <Doc/> */
146
- "Doc": textframe `
147
- <nav class="doc" id="doc">
148
- <h1>Diagram of Contents</h1>
149
- {{ Doc.diagram }}
150
- </nav>
151
- `,
152
- /* <Artifact/> */
153
- "Artifact": textframe `
154
- <article>
155
- {{ Artifact.objects }}
156
- </article>
157
- `,
158
- /* <Object/> */
159
- "Object": textframe `
160
- <section>
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">&#x2318;</span>{% endif %}{% if Object.paren %} <span class="anchor-paren">({{ Object.paren }})</span>{% endif %} <a href="#{{ Object.id }}"><span class="anchor-symbol">&#x2693;&#xFE0E;</span></a></h{{ Object.level }}>
162
- {{ Object.diagram }}
163
- {{ Object.properties }}
164
- {{ Object.description }}
165
- {{ Object.coverage }}
166
- {{ Object.children }}
167
- </section>
168
- `,
169
- /* <Coverage/> */
170
- "Coverage": textframe `
171
- <table class="coverage">
172
- <thead>
173
- <tr><th>Coverage</th><th>Covered</th><th>Total</th><th class="ratio">Ratio</th></tr>
174
- </thead>
175
- <tbody>
176
- {% for entry in Coverage %}
177
- <tr{% if entry.even %} class="even"{% endif %}><td>{{ entry.label }}</td><td>{{ entry.covered }}</td><td>{{ entry.total }}</td><td class="ratio"><span class="bar"><span style="width: {{ entry.ratio }}%"></span></span> {{ entry.ratio }}%</td></tr>
178
- {% endfor %}
179
- </tbody>
180
- </table>
181
- `,
182
- /* <Properties/> */
183
- "Properties": textframe `
184
- <table class="props"{% if Fold %} data-fold-height="{{ Fold }}"{% endif %}>
185
- {% for property in Properties %}
186
- <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>
187
- {% endfor %}
188
- </table>
189
- `,
190
- /* <Description/> */
191
- "Description": textframe `
192
- {%- if Description.block %}
193
- <div class="description">{{ Description.description }}{% if Description.rationale %}
194
- <p class="rationale">&mdash; <span class="keyword">BECAUSE</span> {{ Description.rationale }}</p>{% endif %}</div>
195
- {%- elif Description.description or Description.rationale %}
196
- <p class="description">{{ Description.description }}{% if Description.rationale %}
197
- <span class="rationale">&mdash; <span class="keyword">BECAUSE</span> {{ Description.rationale }}</span>{% endif %}</p>
198
- {%- endif %}
199
- {%- for embedding in Description.embeddings %}
200
- <div class="embedding">{{ embedding }}</div>
201
- {%- endfor %}
202
- `,
203
- /* <Table/> */
204
- "Table": textframe `
205
- <table class="objects"{% if Table.fold %} data-fold-height="{{ Table.fold }}"{% endif %}>
206
- <thead>
207
- <tr>
208
- <th class="object-kind"><span{% if Table.info %} data-info="{{ Table.info }}" data-info-path="{{ Table.infopath }}"{% endif %}>{{ Table.head }}</span></th>
209
- {%- for key in Table.keys %}<th class="property-name"><span{% if Table.info %} data-info="{{ Table.info }}" data-info-path="{{ Table.infopath }}" data-info-prop="{{ key }}"{% endif %}>{{ key }}</span></th>{% endfor %}
210
- {%- if Table.desc %}<th class="description" style="width: {{ Table.width }}%">Description</th>{% endif %}
211
- </tr>
212
- </thead>
213
- <tbody>
214
- {% for row in Table.rows %}
215
- <tr id="{{ row.id }}"{% if row.anchor %} data-id="{{ row.anchor }}"{% endif %}{% if row.even %} class="even"{% endif %}>
216
- <td><span{% if row.spec %} data-info-spec="{{ row.spec }}"{% endif %}>{{ row.name }}</span>{% if row.primary %} <span class="primary-marker">&#x2318;</span>{% endif %}{% if row.paren %} <span class="anchor-paren">({{ row.paren }})</span>{% endif %} <a href="#{{ row.id }}"><span class="anchor-symbol">&#x2693;&#xFE0E;</span></a></td>
217
- {%- for value in row.values %}<td>{{ value }}</td>{% endfor %}
218
- {%- if Table.desc %}<td>{{ row.description }}</td>{% endif %}
219
- </tr>
220
- {% endfor %}
221
- </tbody>
222
- </table>
223
- `,
224
- /* <TableChunked/> */
225
- "TableChunked": textframe `
226
- <table class="objects"{% if Table.fold %} data-fold-height="{{ Table.fold }}"{% endif %}>
227
- <thead>
228
- <tr>
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>
230
- <th class="description">Properties{% if Table.desc %} &amp; Description{% endif %}</th>
231
- </tr>
232
- </thead>
233
- <tbody>
234
- {% for row in Table.rows %}
235
- <tr id="{{ row.id }}"{% if row.anchor %} data-id="{{ row.anchor }}"{% endif %}{% if row.even %} class="even"{% endif %}>
236
- <td><span{% if row.spec %} data-info-spec="{{ row.spec }}"{% endif %}>{{ row.name }}</span>{% if row.primary %} <span class="primary-marker">&#x2318;</span>{% endif %}{% if row.paren %} <span class="anchor-paren">({{ row.paren }})</span>{% endif %} <a href="#{{ row.id }}"><span class="anchor-symbol">&#x2693;&#xFE0E;</span></a></td>
237
- <td class="chunks">
238
- <table class="chunks">
239
- {% for chunk in row.chunks %}
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>
241
- <tr>{% for cell in chunk %}<td{% if cell.span > 1 %} colspan="{{ cell.span }}"{% endif %}>{{ cell.value }}</td>{% endfor %}</tr>
242
- {% endfor %}
243
- </table>
244
- </td>
245
- </tr>
246
- {% endfor %}
247
- </tbody>
248
- </table>
249
- `
250
- };
251
- /* the client-side script of the color theme: it applies the stored
252
- choice (or, without one, the document default and finally the system
253
- preference) before the first paint and toggles it on demand. The
254
- live preview placeholder page carries it, too, as an in-place
255
- document update never re-executes a head script */
256
- const themeScript = textframe `
257
- (function () {
258
- let style = null
259
- try { style = localStorage.getItem("specbook-theme") }
260
- catch { /* an inaccessible storage just means no stored choice */ }
261
- const html = document.documentElement.classList
262
- if (style === null)
263
- style = html.contains("theme-dark") ? "dark" : (html.contains("theme-light") ? "light" : null)
264
- if (style === null)
265
- style = window.matchMedia("(prefers-color-scheme: dark)").matches ? "dark" : "light"
266
- html.remove("theme-light", "theme-dark")
267
- html.add("theme-" + style)
268
- })()
269
- function themeSwitch () {
270
- const html = document.documentElement.classList
271
- const style = html.contains("theme-dark") ? "light" : "dark"
272
- html.remove("theme-light", "theme-dark")
273
- html.add("theme-" + style)
274
- try { localStorage.setItem("specbook-theme", style) }
275
- catch { /* an inaccessible storage just loses the choice */ }
276
- }
277
- `;
278
- /* the minimal stylesheet of the live preview placeholder page: it also
279
- provides the <style> element the in-place document update swaps with
280
- the real stylesheet, so it carries the centered message only */
281
- const placeholderStylesheet = textframe `
282
- html.theme-light body { color: #333333; background-color: #ffffff }
283
- html.theme-dark body { color: #cccccc; background-color: #1a1a1a }
284
- body { font-family: sans-serif; margin: 0 }
285
- div.placeholder { position: absolute; top: 50%; left: 0; right: 0;
286
- transform: translateY(-50%); text-align: center; opacity: 0.6 }
287
- `;
288
- /* the client-side script of the live preview: it connects back to
289
- its own page over WebSocket (the URL scheme "http"/"https" replaced with
290
- "ws"/"wss"), re-connects every second after a lost connection, and
291
- updates the page on a received "RELOAD" command as well as on every
292
- re-established connection (as the server was restarted meanwhile).
293
- The update fetches the fresh page and replaces the document in place
294
- -- instead of reloading the page -- so the scroll position and the
295
- theme choice survive: the title, the stylesheet (if changed), and
296
- the body are swapped, and the scripts of the fresh body re-executed
297
- through clones (as parsed scripts never execute), except this
298
- realtime script itself, whose connection stays alive. The status
299
- icon (re-created by every body swap) reflects the connection state
300
- and blinks for 2s after every update, unless the connection is
301
- lost meanwhile */
302
- const realtimeScript = textframe `
303
- (function () {
304
- const url = window.location.protocol.replace(/^http/, "ws") + "//" +
305
- window.location.host + window.location.pathname
306
- const status = (state) => {
307
- const icon = document.querySelector("div.realtime-status")
308
- if (icon !== null)
309
- icon.className = "realtime-status " + state
310
- }
311
- let blink = 0
312
- let seq = 0
313
- const update = async () => {
314
- /* a superseded update (an overlapping newer one started
315
- meanwhile) is dropped, as its stale page could arrive last */
316
- const mine = ++seq
317
- let html
318
- try {
319
- const response = await fetch(window.location.pathname, { cache: "no-store" })
320
- if (!response.ok)
321
- return
322
- html = await response.text()
323
- }
324
- catch {
325
- /* an unreachable server just skips the update */
326
- return
327
- }
328
- if (mine !== seq)
329
- return
330
- const doc = new DOMParser().parseFromString(html, "text/html")
331
- const x = window.scrollX
332
- const y = window.scrollY
333
- document.title = doc.title
334
- const style = document.head.querySelector("style")
335
- const fresh = doc.head.querySelector("style")
336
- if (style !== null && fresh !== null && style.textContent !== fresh.textContent)
337
- style.replaceWith(fresh)
338
- document.body.replaceWith(doc.body)
339
-
340
- /* a swapped-out maximization overlay can no longer unlock the scrolling */
341
- document.documentElement.classList.remove("maximized")
342
- for (const script of document.body.querySelectorAll("script:not(.realtime)")) {
343
- const clone = document.createElement("script")
344
- clone.textContent = script.textContent
345
- script.replaceWith(clone)
346
- }
347
- window.scrollTo(x, y)
348
- status("reloaded")
349
- clearTimeout(blink)
350
- blink = setTimeout(() => { status("connected") }, 2000)
351
- }
352
- let lost = false
353
- const connect = () => {
354
- const ws = new WebSocket(url)
355
- ws.onopen = () => {
356
- status("connected")
357
- if (lost) {
358
- console.log("specbook: live preview connection re-established")
359
- lost = false
360
- update()
361
- }
362
- }
363
- ws.onmessage = (event) => {
364
- if (event.data === "RELOAD")
365
- update()
366
- }
367
- ws.onclose = () => {
368
- clearTimeout(blink)
369
- status("disconnected")
370
- if (!lost)
371
- console.log("specbook: live preview connection lost")
372
- lost = true
373
- setTimeout(connect, 1000)
374
- }
375
- }
376
- connect()
377
- })()
378
- `;
379
- /* the client-side script of the scroll progress meter: it drives the
380
- DONE arc of the ring (through the dash offset of its unit-length
381
- circle) with the scrolled fraction of the document, shows the meter
382
- once the page is scrolled beyond 400px, and scrolls the page back
383
- to the top on a click (stripping the URL hash, so a later reload
384
- stays at the top). The updates are throttled onto animation frames,
385
- and the window-bound listeners retire themselves once a live preview
386
- body swap replaced the meter (whose fresh script re-attaches) */
387
- const scrollProgressScript = textframe `
388
- (function () {
389
- const meter = document.querySelector("div.scroll-progress")
390
- const ring = meter.querySelector("circle.scroll-done")
391
- let ticking = false
392
- const update = () => {
393
- ticking = false
394
- if (!meter.isConnected) {
395
- window.removeEventListener("scroll", schedule)
396
- window.removeEventListener("resize", schedule)
397
- return
398
- }
399
- const doc = document.documentElement
400
- const max = doc.scrollHeight - doc.clientHeight
401
- ring.setAttribute("stroke-dashoffset", String(1 - (max > 0 ? doc.scrollTop / max : 0)))
402
- meter.classList.toggle("shown", doc.scrollTop > 400)
403
- }
404
- const schedule = () => {
405
- if (!ticking) {
406
- ticking = true
407
- requestAnimationFrame(update)
408
- }
409
- }
410
- meter.addEventListener("click", () => {
411
- window.scrollTo({ top: 0, behavior: "smooth" })
412
- if (window.location.hash)
413
- history.replaceState(null, "", window.location.pathname + window.location.search)
414
- })
415
- window.addEventListener("scroll", schedule, { passive: true })
416
- window.addEventListener("resize", schedule)
417
- update()
418
- })()
419
- `;
420
- /* the default percentage by which the text of a table cell may exceed
421
- the height of every other cell of its row before it folds (or is
422
- omitted), overridden per object kind by "maxCellHeight", and the
423
- minimum percentage of its own height a cell has to gain by that,
424
- shared by the client-side folding and the server-side omitting */
425
- const cellHeightDefault = 40;
426
- const cellGainMin = 25;
427
- /* the client-side script of the folding: it wraps every diagram of the
428
- content into a fold container carrying a chevron mark at its top
429
- left corner, folds the text of every table cell towering over the
430
- other cells of its row behind a chevron mark of its own, and lets
431
- the controls of the fold tab fold and unfold all diagrams of a type
432
- ("graph", "hub", "grid"), all diagrams from an object tree nesting
433
- level on (1, 2, 3), and all cell texts at once, with their state
434
- persisted across page loads and their icons marked while they are
435
- active (the diagram ones, OR-combined) or any cell text is folded
436
- (the tab icon while anything at all is). The script runs at
437
- the end of the body, as the content it wraps has to exist already,
438
- and a live preview body swap replaces the containers and their
439
- listeners along with the body. An export omitting aspects lacks
440
- their controls ("standins" lets detached elements take their place)
441
- and, with the long texts omitted, folds no cell text ("textless") */
442
- const foldScript = (standins, textless) => textframe `
443
- (function () {
444
- const tab = document.querySelector("div.fold-switch")
445
- if (tab === null)
446
- return
447
- const controls = {
448
- graph: tab.querySelector("div.fold-graphs"),
449
- hub: tab.querySelector("div.fold-hubs"),
450
- grid: tab.querySelector("div.fold-grids"),
451
- level1: tab.querySelector("div.fold-level1"),
452
- level2: tab.querySelector("div.fold-level2"),
453
- level3: tab.querySelector("div.fold-level3"),
454
- text: tab.querySelector("div.fold-texts")
455
- }${standins ? `
456
- for (const kind of Object.keys(controls))
457
- controls[kind] ??= document.createElement("div")` : ""}
458
-
459
- /* let the fold icon slide the controls out of the tab (and
460
- back in again), remembering the choice across page loads */
461
- try { if (localStorage.getItem("specbook-fold") === "open") tab.classList.add("open") }
462
- catch { /* an inaccessible storage just means no stored state */ }
463
- tab.querySelector("div.fold-toggle").addEventListener("click", () => {
464
- tab.classList.toggle("open")
465
- try { localStorage.setItem("specbook-fold", tab.classList.contains("open") ? "open" : "closed") }
466
- catch { /* an inaccessible storage just loses the state */ }
467
- })
468
-
469
- /* the default percentage by which the text of a table cell may
470
- exceed the height of every other cell of its row before it
471
- folds, overridden per object kind by the "data-fold-height"
472
- attribute the schema configuration puts onto its table */
473
- const cellHeight = ${cellHeightDefault}
474
-
475
- /* the chevron mark of a foldable element, rotated by the
476
- stylesheet while the element is folded */
477
- const svgNS = "http://www.w3.org/2000/svg"
478
- const mark = (name, title) => {
479
- const span = document.createElement("span")
480
- span.className = name
481
- span.title = title
482
- const svg = document.createElementNS(svgNS, "svg")
483
- svg.setAttribute("viewBox", "0 0 24 24")
484
- svg.setAttribute("fill", "none")
485
- svg.setAttribute("stroke", "currentColor")
486
- svg.setAttribute("stroke-width", "2.5")
487
- svg.setAttribute("stroke-linecap", "round")
488
- svg.setAttribute("stroke-linejoin", "round")
489
- const path = document.createElementNS(svgNS, "path")
490
- path.setAttribute("d", "M5 9l7 7 7-7")
491
- svg.appendChild(path)
492
- span.appendChild(svg)
493
- return span
494
- }
495
-
496
- /* wrap every diagram into its fold container, carrying the
497
- chevron mark plus the muted placeholder the diagram leaves
498
- behind while folded, which is the very icon of its type
499
- control; a diagram joins the set of its type plus the sets
500
- of all nesting levels up to its own one (3 standing for all
501
- deeper ones), so the sets of the controls overlap */
502
- const folds = { graph: [], hub: [], grid: [], level1: [], level2: [], level3: [], text: [] }
503
- document.querySelectorAll("article div.diagram, nav.doc div.diagram").forEach((el) => {
504
- const type = el.getAttribute("data-type") ?? "graph"
505
- const level = Math.min(Number(el.getAttribute("data-level")) || 1, 3)
506
- const fold = document.createElement("div")
507
- fold.className = "fold"
508
- el.parentNode.insertBefore(fold, el)
509
- const chevron = mark("fold-mark", "fold/unfold this diagram")
510
- const label = document.createElement("span")
511
- label.className = "fold-label"
512
- label.appendChild(controls[type].querySelector("svg").cloneNode(true))
513
- fold.append(chevron, label, el)
514
- chevron.addEventListener("click", () => {
515
- fold.classList.toggle("folded")
516
- sync()
517
- })
518
- folds[type].push(fold)
519
- for (let n = 1; n <= level; n++)
520
- folds["level" + n].push(fold)
521
- })
522
-
523
- /* the rendered height of the content of a table cell, or of its
524
- leading part up to a word position: a cell box itself always
525
- spans the whole row, so only a range over the content
526
- measures the text */
527
- const range = document.createRange()
528
- const height = (cell, upto) => {
529
- if (upto === undefined)
530
- range.selectNodeContents(cell)
531
- else {
532
- range.setStart(cell, 0)
533
- range.setEnd(upto.node, upto.offset)
534
- }
535
- return range.getBoundingClientRect().height
536
- }
537
-
538
- /* the word positions of a table cell, each right behind one word */
539
- const positions = (cell) => {
540
- const walker = document.createTreeWalker(cell, NodeFilter.SHOW_TEXT)
541
- const list = []
542
- let node
543
- while ((node = walker.nextNode()) !== null) {
544
- const regex = /\\S+/g
545
- let m
546
- while ((m = regex.exec(node.nodeValue ?? "")) !== null)
547
- list.push({ node, offset: m.index + m[0].length })
548
- }
549
- return list
550
- }
551
-
552
- /* a cell taking part in the comparison of its row: one with
553
- content of its own, i.e. neither empty (a not given property
554
- draws its marker through the stylesheet alone) nor holding
555
- further cells (a chunked or nested table, whose own cells are
556
- compared instead) nor a diagram (which folds on its own and
557
- whose SVG text cannot carry the wrapper of a hidden
558
- remainder) */
559
- const candidate = (cell) =>
560
- cell.querySelector("td") === null
561
- && cell.querySelector("div.diagram") === null
562
- && (cell.textContent.trim() !== "" || cell.querySelector("img") !== null)
563
-
564
- /* hide a node of the folded remainder of a table cell: an
565
- element carries the class itself, a text node gets wrapped */
566
- const rest = (node) => {
567
- if (node.nodeType === Node.ELEMENT_NODE)
568
- node.classList.add("fold-rest")
569
- else if (node.nodeType === Node.TEXT_NODE) {
570
- const span = document.createElement("span")
571
- span.className = "fold-rest fold-wrap"
572
- node.parentNode.insertBefore(span, node)
573
- span.appendChild(node)
574
- }
575
- }
576
-
577
- /* take the cut of a table cell back again: the chevron mark
578
- leaves, the remainder sheds its class or wrapper, and the
579
- split text nodes merge */
580
- const uncut = (cell) => {
581
- cell.classList.remove("folded")
582
- cell.querySelectorAll("span.fold-more").forEach((el) => { el.remove() })
583
- cell.querySelectorAll(".fold-rest").forEach((el) => {
584
- if (el.classList.contains("fold-wrap"))
585
- el.replaceWith(...el.childNodes)
586
- else
587
- el.classList.remove("fold-rest")
588
- })
589
- cell.normalize()
590
- }
591
-
592
- /* the minimum percentage of its own height a cell has to gain
593
- by folding, as hiding less than that is no visible relief
594
- and only costs the reader a chevron to click (measured after
595
- the cut, as the chevron itself can claim a line of its own) */
596
- const cellGain = ${cellGainMin}
597
-
598
- /* lay out the text folds, which holds for the current line
599
- breaks only and hence is redone on every change of them: the
600
- previous cuts are taken back, with their fold state carried
601
- over (a cell folding anew follows the others) */
602
- const layout = () => {${textless ? `
603
- return` : ""}
604
- const all = folds.text.length > 0 && folds.text.every((cell) => cell.classList.contains("folded"))
605
- const state = new Map(folds.text.map((cell) => [ cell, cell.classList.contains("folded") ]))
606
- folds.text.forEach(uncut)
607
- folds.text.length = 0
608
-
609
- /* decide which table cells fold: one which is taller than the
610
- configured percentage above every other cell of its row (the
611
- leading name column of a compact table excluded, as it is no
612
- text of its own). All rows are measured before the first cut,
613
- as every cut shifts the layout of the ones still to come */
614
- const cuts = []
615
- document.querySelectorAll("article table tr").forEach((row) => {
616
- const table = row.closest("table")
617
- let cells = Array.from(row.children).filter((el) => el.tagName === "TD")
618
- if (!table.classList.contains("chunks"))
619
- cells = cells.slice(1)
620
- cells = cells.filter(candidate)
621
- if (cells.length < 2)
622
- return
623
- const configured = row.closest("table[data-fold-height]")
624
- const percent = Number(configured?.getAttribute("data-fold-height")) || cellHeight
625
- const heights = cells.map((cell) => height(cell))
626
- cells.forEach((cell, i) => {
627
- const other = Math.max(...heights.filter((_, j) => j !== i))
628
- const limit = other * (1 + percent / 100)
629
- if (heights[i] > limit)
630
- cuts.push({ cell, limit, full: heights[i] })
631
- })
632
- })
633
-
634
- /* fold the decided cells back onto their height limit: the last
635
- word position still fitting is found by a binary search over
636
- the measured word positions, the text node is split there and
637
- the chevron mark takes its place, followed by the remainder
638
- hidden up the ancestor chain of the cell */
639
- for (const cut of cuts) {
640
- const list = positions(cut.cell)
641
- if (list.length === 0)
642
- continue
643
- let lo = 0
644
- let hi = list.length - 1
645
- while (lo < hi) {
646
- const mid = Math.ceil((lo + hi) / 2)
647
- if (height(cut.cell, list[mid]) <= cut.limit)
648
- lo = mid
649
- else
650
- hi = mid - 1
651
- }
652
- const tail = list[lo].node.splitText(list[lo].offset)
653
- const chevron = mark("fold-more", "fold/unfold the remaining text")
654
- tail.parentNode.insertBefore(chevron, tail)
655
- let current = chevron
656
- while (current !== cut.cell) {
657
- let next = current.nextSibling
658
- while (next !== null) {
659
- const following = next.nextSibling
660
- rest(next)
661
- next = following
662
- }
663
- current = current.parentNode
664
- }
665
-
666
- /* the gain is measured on the applied fold, as only that
667
- one tells the real one, and a fold gaining less than the
668
- minimum share is taken back again, while a kept fold
669
- sheds the class again, as every cell starts out unfolded */
670
- cut.cell.classList.add("folded")
671
- const gained = height(cut.cell) <= cut.full * (1 - cellGain / 100)
672
- if (!gained) {
673
- uncut(cut.cell)
674
- continue
675
- }
676
- cut.cell.classList.toggle("folded", state.get(cut.cell) ?? all)
677
- folds.text.push(cut.cell)
678
- chevron.addEventListener("click", (event) => {
679
- /* the cut can land inside a hyperlink, whose navigation
680
- the chevron has to suppress for its own click alone,
681
- so the remaining link text still follows the link */
682
- event.preventDefault()
683
- cut.cell.classList.toggle("folded")
684
- sync()
685
- })
686
- }
687
- }
688
- layout()
689
-
690
- /* mark the text control while any cell text is folded, a diagram
691
- control while it is active itself (as the sets of the diagram
692
- controls overlap, the fold state of their diagrams tells
693
- nothing), and the tab itself while anything at all is folded */
694
- const active = new Set()
695
- const sync = () => {
696
- for (const kind of Object.keys(folds))
697
- controls[kind].classList.toggle("folded", kind === "text" ?
698
- folds.text.some((fold) => fold.classList.contains("folded")) : active.has(kind))
699
- tab.classList.toggle("folded", Object.keys(folds).some((kind) =>
700
- folds[kind].some((fold) => fold.classList.contains("folded"))))
701
- }
702
-
703
- /* fold exactly the diagrams at least one active control covers */
704
- const apply = () => {
705
- folds.level1.forEach((fold) => {
706
- fold.classList.toggle("folded", Array.from(active).some((kind) => folds[kind].includes(fold)))
707
- })
708
- }
709
-
710
- /* without a stored state the rendered one stands, which is
711
- everything unfolded: the cell texts store their state as a
712
- whole, the diagrams as the list of their active controls */
713
- const stored = { text: null, diagrams: null }
714
- for (const name of Object.keys(stored)) {
715
- try { stored[name] = localStorage.getItem("specbook-fold-" + name) }
716
- catch { /* an inaccessible storage just means no stored state */ }
717
- }
718
- if (stored.text !== null)
719
- folds.text.forEach((fold) => { fold.classList.toggle("folded", stored.text === "folded") })
720
- if (stored.diagrams !== null) {
721
- stored.diagrams.split(",").filter((kind) => kind !== "text" && Object.hasOwn(folds, kind))
722
- .forEach((kind) => { active.add(kind) })
723
- apply()
724
- }
725
- for (const kind of Object.keys(folds)) {
726
- controls[kind].addEventListener("click", () => {
727
- try {
728
- if (kind === "text") {
729
- const all = !folds.text.every((fold) => fold.classList.contains("folded"))
730
- folds.text.forEach((fold) => { fold.classList.toggle("folded", all) })
731
- localStorage.setItem("specbook-fold-text", all ? "folded" : "unfolded")
732
- }
733
- else {
734
- if (!active.delete(kind))
735
- active.add(kind)
736
- apply()
737
- localStorage.setItem("specbook-fold-diagrams", Array.from(active).join(","))
738
- }
739
- }
740
- catch { /* an inaccessible storage just loses the state */ }
741
- sync()
742
- })
743
- }
744
- sync()
745
-
746
- /* redo the text folds once the line breaks change, which a new
747
- viewport width and the arrival of the embedded fonts (still
748
- loading while this script runs) cause. A running search
749
- (which shows the remainders) defers it, and a live preview
750
- body swap, which left this script behind, ends it */
751
- let width = document.documentElement.clientWidth
752
- let timer = 0
753
- const relayout = () => {
754
- if (!tab.isConnected)
755
- return
756
- if (document.body.classList.contains("searching")) {
757
- schedule()
758
- return
759
- }
760
- layout()
761
- sync()
762
- }
763
- const schedule = () => {
764
- clearTimeout(timer)
765
- timer = setTimeout(relayout, 250)
766
- }
767
- const resized = () => {
768
- if (!tab.isConnected)
769
- window.removeEventListener("resize", resized)
770
- else if (width !== document.documentElement.clientWidth) {
771
- width = document.documentElement.clientWidth
772
- schedule()
773
- }
774
- }
775
- window.addEventListener("resize", resized)
776
- document.fonts.ready.then(() => {
777
- if (tab.isConnected)
778
- schedule()
779
- })
780
- })()
781
- `;
782
- /* the client-side script of the diagram maximization: it attaches two
783
- controls to the top right corner of every diagram, revealed by the
784
- stylesheet while the diagram is hovered, which maximize the diagram
785
- temporarily in a single shared overlay covering the viewport (the
786
- SVG cloned into it and fitted by the stylesheet), the second one
787
- additionally taking the overlay into the browser fullscreen (a
788
- refused request leaves the viewport overlay), where the close mark
789
- at the top right corner, "Escape", a click beside the diagram, and
790
- a jump through a node hyperlink close the overlay again (a browser
791
- leaving the fullscreen on its own closes it, too). The script runs
792
- at the end of the body, as the diagrams it decorates have to exist
793
- already, and a live preview body swap replaces the overlay and the
794
- body-bound listeners along with the body, while the document-bound
795
- fullscreen listener retires itself */
796
- const maximizeScript = textframe `
797
- (function () {
798
- /* an icon control drawn like the tab icons */
799
- const svgNS = "http://www.w3.org/2000/svg"
800
- const mark = (name, title, paths) => {
801
- const span = document.createElement("span")
802
- span.className = name
803
- span.title = title
804
- const svg = document.createElementNS(svgNS, "svg")
805
- svg.setAttribute("viewBox", "0 0 24 24")
806
- svg.setAttribute("fill", "none")
807
- svg.setAttribute("stroke", "currentColor")
808
- svg.setAttribute("stroke-width", "2.5")
809
- svg.setAttribute("stroke-linecap", "round")
810
- svg.setAttribute("stroke-linejoin", "round")
811
- for (const d of paths) {
812
- const path = document.createElementNS(svgNS, "path")
813
- path.setAttribute("d", d)
814
- svg.appendChild(path)
815
- }
816
- span.appendChild(svg)
817
- return span
818
- }
819
-
820
- /* the shared overlay: the close mark plus the content holding
821
- the clone of the maximized diagram */
822
- const overlay = document.createElement("div")
823
- overlay.className = "maximize"
824
- const close = mark("maximize-close", "close the maximized diagram",
825
- [ "M18 6 6 18", "M6 6l12 12" ])
826
- const content = document.createElement("div")
827
- content.className = "maximize-content"
828
- overlay.append(close, content)
829
- document.body.appendChild(overlay)
830
-
831
- /* open the overlay onto a diagram, optionally in the browser
832
- fullscreen, and close it again (leaving the fullscreen, too),
833
- with the document scrolling locked while it is open */
834
- const open = (diagram, fullscreen) => {
835
- const svg = diagram.querySelector("svg").cloneNode(true)
836
- svg.removeAttribute("style")
837
- content.replaceChildren(svg)
838
- overlay.classList.add("open")
839
- document.documentElement.classList.add("maximized")
840
- if (fullscreen)
841
- overlay.requestFullscreen().catch(() => { /* a refused request leaves the viewport overlay */ })
842
- }
843
- const shut = () => {
844
- overlay.classList.remove("open")
845
- document.documentElement.classList.remove("maximized")
846
- content.replaceChildren()
847
- if (document.fullscreenElement === overlay)
848
- document.exitFullscreen().catch(() => { /* a fullscreen left already needs no exit */ })
849
- }
850
- close.addEventListener("click", shut)
851
- overlay.addEventListener("click", (event) => {
852
- /* a node hyperlink navigates on its own, the overlay just
853
- gets out of its way, while a click beside the diagram
854
- closes the overlay alone */
855
- if (event.target.closest("a") !== null || event.target.closest("svg") === null)
856
- shut()
857
- })
858
- document.body.addEventListener("keydown", (event) => {
859
- if (event.key === "Escape" && overlay.classList.contains("open"))
860
- shut()
861
- })
862
- const left = () => {
863
- if (!overlay.isConnected)
864
- document.removeEventListener("fullscreenchange", left)
865
- else if (document.fullscreenElement === null && overlay.classList.contains("open"))
866
- shut()
867
- }
868
- document.addEventListener("fullscreenchange", left)
869
-
870
- /* attach the two controls to every diagram */
871
- document.querySelectorAll("article div.diagram, nav.doc div.diagram").forEach((diagram) => {
872
- const controls = document.createElement("span")
873
- controls.className = "maximize-controls"
874
- const view = mark("maximize-view", "maximize this diagram in the viewport",
875
- [ "M15 3h6v6", "M21 3l-7 7", "M9 21H3v-6", "M3 21l7-7" ])
876
- const full = mark("maximize-full", "maximize this diagram in fullscreen",
877
- [ "M3 7V5a2 2 0 0 1 2-2h2", "M17 3h2a2 2 0 0 1 2 2v2",
878
- "M21 17v2a2 2 0 0 1-2 2h-2", "M7 21H5a2 2 0 0 1-2-2v-2" ])
879
- view.addEventListener("click", () => { open(diagram, false) })
880
- full.addEventListener("click", () => { open(diagram, true) })
881
- controls.append(view, full)
882
- diagram.appendChild(controls)
883
- })
884
- })()
885
- `;
886
- /* the client-side script of the table of contents side panel: it
887
- slides the panel out of and back into the viewport edge (closing it
888
- also on a jump, on "Escape", and on a click outside), remembers the
889
- open state across page loads, and marks the active entry (the first
890
- heading visible in the viewport, or, without any, the last heading
891
- above the viewport, i.e. the section scrolled into), scrolling it
892
- into the middle of the panel whenever it changes and is not visible
893
- in the panel already (even while the panel is slid in, so it opens
894
- at the right position), plus the anchored entry (the one the URL
895
- hash currently addresses, which a jump synchronizes with the active
896
- one, while a subsequent scrolling moves the active one only). The
897
- script runs at the end of the body, as the headings it targets
898
- have to exist already, and a live preview body swap replaces the
899
- body-bound listeners along with the panel, while the document- and
900
- window-bound ones (the outside click, as the body does not span
901
- the viewport margins, plus scroll and hash change) retire
902
- themselves */
903
- const tocPanelScript = textframe `
904
- (function () {
905
- const panel = document.querySelector("nav.toc-panel")
906
- const list = panel.querySelector("div.toc-list")
907
- const links = Array.from(panel.querySelectorAll("a"))
908
- const targets = links.map((a) => document.getElementById(decodeURIComponent(a.hash.slice(1))))
909
- let active = null
910
- const reveal = () => {
911
- const top = active.getBoundingClientRect().top - list.getBoundingClientRect().top
912
- if (top < 0 || top + active.offsetHeight > list.clientHeight)
913
- list.scrollTop += top - (list.clientHeight - active.offsetHeight) / 2
914
- }
915
- const spy = () => {
916
- if (!panel.isConnected) {
917
- window.removeEventListener("scroll", spy)
918
- return
919
- }
920
- let current = null
921
- for (let i = 0; i < targets.length; i++) {
922
- if (targets[i] === null)
923
- continue
924
- const top = targets[i].getBoundingClientRect().top
925
- if (top >= -1 && top < window.innerHeight) {
926
- current = links[i]
927
- break
928
- }
929
- if (top < -1)
930
- current = links[i]
931
- }
932
- if (current === active)
933
- return
934
- if (active !== null)
935
- active.classList.remove("active")
936
- active = current
937
- if (active !== null) {
938
- active.classList.add("active")
939
- reveal()
940
- }
941
- }
942
- let anchored = null
943
- const anchor = () => {
944
- if (!panel.isConnected) {
945
- window.removeEventListener("hashchange", anchor)
946
- return
947
- }
948
- const current = links.find((a) => a.hash === window.location.hash) ?? null
949
- if (current === anchored)
950
- return
951
- if (anchored !== null)
952
- anchored.classList.remove("anchored")
953
- anchored = current
954
- if (anchored !== null)
955
- anchored.classList.add("anchored")
956
- }
957
- const toggle = (open) => {
958
- panel.classList.toggle("open", open)
959
- try { localStorage.setItem("specbook-toc", panel.classList.contains("open") ? "open" : "closed") }
960
- catch { /* an inaccessible storage just loses the state */ }
961
- }
962
- try { if (localStorage.getItem("specbook-toc") === "open") panel.classList.add("open") }
963
- catch { /* an inaccessible storage just means no stored state */ }
964
- panel.querySelector("div.toc-tab").addEventListener("click", () => { toggle() })
965
- links.forEach((a) => { a.addEventListener("click", () => { toggle(false) }) })
966
- document.body.addEventListener("keydown", (event) => { if (event.key === "Escape") toggle(false) })
967
- const outside = (event) => {
968
- if (!panel.isConnected)
969
- document.removeEventListener("click", outside)
970
- else if (!panel.contains(event.target)
971
- && event.target.closest("div.search") === null
972
- && event.target.closest("div.theme-switch") === null
973
- && event.target.closest("div.info-switch") === null
974
- && event.target.closest("div.fold-switch") === null
975
- && event.target.closest("div.realtime-status") === null)
976
- toggle(false)
977
- }
978
- document.addEventListener("click", outside)
979
- window.addEventListener("scroll", spy, { passive: true })
980
- window.addEventListener("hashchange", anchor)
981
- spy()
982
- anchor()
983
- })()
984
- `;
985
- /* the client-side script of the description popups: the info tab
986
- toggles the popups (off by default, persisted across page loads),
987
- and while they are on, a mouse resting 400ms on an element carrying
988
- a "data-info" key (plus "data-info-prop" for a property) pops up the
989
- schema description of its object kind (or property), while one
990
- carrying a "data-info-spec" key pops up the corpus description of
991
- the object instance (a diagram node box resolves through its
992
- hyperlinked object anchor), titled with the object path (an instance
993
- popup trailing the anchor id of the object) and fed from the
994
- injected INFO/SPEC tables: as thousands of elements carry those
995
- keys, they are just the table indices, and a title path is composed
996
- from the SPEC table (the anchor id, parent index, kind, plain name,
997
- and pre-rendered description HTML per object) instead of being
998
- repeated on every element -- the path of an instance popup is the
999
- one of its own object, while "data-info-path" names the object of a
1000
- schema popup, a trailing "^" keeping its last segment kind-only;
1001
- the popup is capped at 40% viewport width and
1002
- attached above or below, whichever side offers more space; the
1003
- script runs at the end of the body, so a live preview body swap
1004
- replaces the popup and the body-bound listeners along with it */
1005
- const infoPopupScript = textframe `
1006
- (function (INFO, SPEC) {
1007
- const popup = document.createElement("div")
1008
- popup.className = "info-popup"
1009
- document.body.appendChild(popup)
1010
- let enabled = false
1011
- try { enabled = localStorage.getItem("specbook-info") === "on" }
1012
- catch { /* an inaccessible storage just means no stored choice */ }
1013
- let current = null
1014
- let pending = null
1015
- let pendingId = 0
1016
- const cancel = () => {
1017
- clearTimeout(pendingId)
1018
- pending = null
1019
- }
1020
- const hide = () => {
1021
- popup.classList.remove("open")
1022
- current = null
1023
- }
1024
- const apply = () => {
1025
- document.body.classList.toggle("info-on", enabled)
1026
- if (!enabled) {
1027
- cancel()
1028
- hide()
1029
- }
1030
- }
1031
- apply()
1032
- document.querySelector("div.info-switch").addEventListener("click", () => {
1033
- enabled = !enabled
1034
- try { localStorage.setItem("specbook-info", enabled ? "on" : "off") }
1035
- catch { /* an inaccessible storage just loses the choice */ }
1036
- apply()
1037
- })
1038
-
1039
- /* compose the title of a popup: the title path segments of the
1040
- object (the last one kind-only for a kind popup), the anchor
1041
- id trailing an instance popup, and the property name trailing
1042
- a property popup */
1043
- const titleOf = (source, spec, prop) => {
1044
- /* 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
- }
1051
- const title = document.createElement("div")
1052
- title.className = "info-title"
1053
-
1054
- /* create a pointer, separating the title path segments */
1055
- const pointer = () => {
1056
- const span = document.createElement("span")
1057
- span.className = "info-pointer"
1058
- span.textContent = "▷"
1059
- return span
1060
- }
1061
- const ref = spec ?? source.getAttribute("data-info-path") ?? ""
1062
- const index = parseInt(ref, 10)
1063
- const segments = SPEC[index] !== undefined ? pathOf(index, !ref.endsWith("^")) : []
1064
- for (const [ i, segment ] of segments.entries()) {
1065
- if (i > 0)
1066
- title.appendChild(pointer())
1067
- const path = document.createElement("span")
1068
- path.className = "info-object"
1069
-
1070
- /* a segment renders as in the document content (the kind
1071
- bold in the path color, the name semi-bold in the
1072
- description color), where a segment without a kind
1073
- prefix is the kind-only ending of a kind popup or the
1074
- name of a kind-less object */
1075
- const m = /^([^\\s:]+): (.*)$/.exec(segment)
1076
- const bare = i === segments.length - 1 && spec === null && prop === null
1077
- const name = document.createElement("span")
1078
- name.className = "info-name"
1079
- if (m !== null || bare) {
1080
- const kind = document.createElement("span")
1081
- kind.className = "info-kind"
1082
- kind.textContent = m !== null ? m[1] + ":" : segment
1083
- path.appendChild(kind)
1084
- if (m !== null) {
1085
- name.textContent = m[2]
1086
- path.append(" ", name)
1087
- }
1088
- }
1089
- else {
1090
- name.textContent = segment
1091
- path.appendChild(name)
1092
- }
1093
- title.appendChild(path)
1094
- }
1095
-
1096
- /* the anchor id of an object instance (explicit or derived
1097
- from its name) trails the last segment */
1098
- const anchor = spec !== null ? SPEC[spec]?.[0] : undefined
1099
- if (anchor !== undefined) {
1100
- const id = document.createElement("span")
1101
- id.className = "info-id"
1102
- const name = document.createElement("span")
1103
- name.className = "info-id-name"
1104
- name.textContent = anchor
1105
- id.append(" (#", name, ")")
1106
- title.appendChild(id)
1107
- }
1108
- if (prop !== null) {
1109
- title.appendChild(pointer())
1110
- const name = document.createElement("span")
1111
- name.className = "info-property"
1112
- name.textContent = prop
1113
- title.appendChild(name)
1114
- }
1115
- return title
1116
- }
1117
- const show = (el) => {
1118
- /* a diagram node box carries no popup attributes itself, but
1119
- hyperlinks its object, whose heading name or table row name
1120
- carries the instance popup attributes */
1121
- let source = el
1122
- if (!el.hasAttribute("data-info") && !el.hasAttribute("data-info-spec")) {
1123
- const target = document.getElementById(
1124
- decodeURIComponent((el.getAttribute("href") ?? "").replace(/^#/, "")))
1125
- source = target === null ? null :
1126
- (target.hasAttribute("data-info-spec") ? target : target.querySelector("[data-info-spec]"))
1127
- if (source === null)
1128
- return
1129
- }
1130
- current = el
1131
- const key = source.getAttribute("data-info")
1132
- const spec = source.getAttribute("data-info-spec")
1133
- const prop = source.getAttribute("data-info-prop")
1134
- popup.classList.toggle("spec", spec !== null)
1135
- popup.replaceChildren(titleOf(source, spec, prop))
1136
- const entry = key !== null ? INFO[key] : undefined
1137
- const desc = prop !== null ? entry?.p?.[prop] :
1138
- (spec !== null ? SPEC[spec]?.[4] : entry?.d)
1139
- if (desc !== undefined && desc !== "") {
1140
- const text = document.createElement("div")
1141
- text.className = "info-desc"
1142
- text.innerHTML = desc
1143
-
1144
- /* the origin label leads the description inline, so it is
1145
- hoisted into the leading paragraph (if there is one),
1146
- with its prefix word in a span of its own for the
1147
- rounded box styling */
1148
- const label = document.createElement("strong")
1149
- label.className = "info-label"
1150
- const origin = document.createElement("span")
1151
- origin.className = "info-origin"
1152
- origin.textContent = spec !== null ? "Specification" : "Schema"
1153
- label.append(origin, " ")
1154
- const lead = text.firstElementChild
1155
- if (lead !== null && lead.tagName === "P")
1156
- lead.insertBefore(label, lead.firstChild)
1157
- else
1158
- text.insertBefore(label, text.firstChild)
1159
- popup.appendChild(text)
1160
- }
1161
-
1162
- /* measure the popup at the viewport origin first, as a
1163
- position near the right or bottom edge would clamp it */
1164
- popup.style.left = "0px"
1165
- popup.style.top = "0px"
1166
- popup.classList.add("open")
1167
- const rect = el.getBoundingClientRect()
1168
- const left = Math.max(8, Math.min(rect.left, window.innerWidth - popup.offsetWidth - 8))
1169
- const above = rect.top > window.innerHeight - rect.bottom
1170
- popup.style.left = left + "px"
1171
- popup.style.top = (above ? rect.top - popup.offsetHeight - 6 : rect.bottom + 6) + "px"
1172
- }
1173
- const targets = "[data-info], [data-info-spec], div.diagram a"
1174
- document.body.addEventListener("mouseover", (event) => {
1175
- if (!enabled)
1176
- return
1177
- const el = event.target.closest(targets)
1178
- if (el === null || el === current || el === pending)
1179
- return
1180
- cancel()
1181
- pending = el
1182
- pendingId = setTimeout(() => { pending = null; show(el) }, 400)
1183
- })
1184
- document.body.addEventListener("mouseout", (event) => {
1185
- const el = event.target.closest(targets)
1186
- if (el === null || (event.relatedTarget instanceof Node && el.contains(event.relatedTarget)))
1187
- return
1188
- if (el === pending)
1189
- cancel()
1190
- if (el === current)
1191
- hide()
1192
- })
1193
- })(@INFO@, @SPEC@)
1194
- `;
1195
- /* ==== Rendering ==== */
1196
- /* the per-language double quote styles for the smart typography
1197
- (single quotes stay curly, as smartypants cannot distinguish a
1198
- closing single quote from an apostrophe) */
1199
- const quoteStyles = {
1200
- "en": ["&#8220;", "&#8221;"], /* “...” */
1201
- "nl": ["&#8220;", "&#8221;"], /* “...” */
1202
- "de": ["&#8222;", "&#8220;"], /* „...“ */
1203
- "cs": ["&#8222;", "&#8220;"], /* „...“ */
1204
- "sk": ["&#8222;", "&#8220;"], /* „...“ */
1205
- "pl": ["&#8222;", "&#8221;"], /* „...” */
1206
- "fr": ["&#171;&#160;", "&#160;&#187;"], /* « ... » */
1207
- "it": ["&#171;", "&#187;"], /* «...» */
1208
- "es": ["&#171;", "&#187;"], /* «...» */
1209
- "pt": ["&#171;", "&#187;"], /* «...» */
1210
- "no": ["&#171;", "&#187;"], /* «...» */
1211
- "da": ["&#187;", "&#171;"], /* »...« */
1212
- "sv": ["&#8221;", "&#8221;"], /* ”...” */
1213
- "fi": ["&#8221;", "&#8221;"] /* ”...” */
1214
- };
1215
- /* the active double quote style (set per document during rendering) */
1216
- let quotes = quoteStyles.en;
1217
- /* the private Markdown renderer instance, as the hooks below must not
1218
- leak into the "marked" singleton shared with the parser and with the
1219
- application embedding SpecBook as a library */
1220
- const marked = new Marked();
1221
- /* improve the typography of all rendered Markdown text (curly quotes,
1222
- "--" as the em dash, ellipsis); as smartypants requires unescaped text,
1223
- the extension switches off the text escaping of marked, so the stray
1224
- ampersands left behind have to be re-escaped afterwards; the English
1225
- double quotes of smartypants are remapped to the document language;
1226
- as marked runs later-registered pass-through hooks first, the
1227
- post-processing hook has to be registered before smartypants */
1228
- marked.use({ hooks: { postprocess: (html) => html.replace(/&#8220;/g, quotes[0]).replace(/&#8221;/g, quotes[1])
1229
- .replace(/&(?![a-zA-Z][a-zA-Z0-9]*;|#\d+;|#x[0-9a-fA-F]+;)/g, "&amp;") } });
1230
- marked.use(markedSmartypants({ config: 1 }));
1231
- /* the switched-off text escaping of marked also leaves the angle
1232
- brackets of the inline text unescaped, which a browser then takes
1233
- for tags ("Map<String, Object>"), so they are escaped upfront in
1234
- the inline text tokenizer (registered after smartypants, as the
1235
- later-registered tokenizer takes precedence) */
1236
- marked.use({ tokenizer: { inlineText(src) {
1237
- const cap = this.rules.inline.text.exec(src);
1238
- if (cap === null)
1239
- return undefined;
1240
- return { type: "text", raw: cap[0], escaped: true,
1241
- text: cap[0].replace(/</g, "&lt;").replace(/>/g, "&gt;") };
1242
- } } });
1243
- /* the Nunjucks environment with the @rse/nunjucks-addons extensions */
1244
- const env = new nunjucks.Environment(null, { autoescape: true });
1245
- nunjucksAddons(env);
1246
- /* mark pre-rendered HTML as safe for template interpolation */
1247
- const safe = (html) => new nunjucks.runtime.SafeString(html);
1248
- /* the built-in Nunjucks templates compiled on first use, as
1249
- renderString() would re-compile a template on every single call
1250
- (i.e. for every rendered object, property table, and description) */
1251
- const compiled = new Map();
1252
- /* render one of the built-in Nunjucks templates with a context */
1253
- const render = (name, context) => {
1254
- let template = compiled.get(name);
1255
- if (template === undefined) {
1256
- template = new nunjucks.Template(templates[name], env, undefined, true);
1257
- compiled.set(name, template);
1258
- }
1259
- return template.render(context);
1260
- };
1261
- /* the active per-document reference expander, fully-qualified
1262
- anchor paths, member-carrying property value constraints, object
1263
- schema nodes, pre-rendered diagram blocks, optimized embedded images,
1264
- reference coverages, description popup keys of the schema nodes,
1265
- description popup keys of the objects, and omitted aspects (all set
1266
- during HTML rendering) */
1267
- let linker = null;
1268
- let anchors = null;
1269
- let members = null;
1270
- let schemas = null;
1271
- let diagrams = null;
1272
- let images = null;
1273
- let coverages = null;
1274
- let infoKeys = null;
1275
- let infoObjects = null;
1276
- let omits = null;
1277
- /* the object whose texts are currently rendered, scoping the
1278
- resolution of the references inside them (nearest object wins),
1279
- established around every object rendering and restored afterwards */
1280
- let scope = null;
1281
- const scoped = (object, body) => {
1282
- const outer = scope;
1283
- scope = object;
1284
- try {
1285
- return body();
1286
- }
1287
- finally {
1288
- scope = outer;
1289
- }
1290
- };
1291
- /* determine the fully-qualified anchor path of an object */
1292
- const anchorOf = (object) => anchors?.get(object) ?? object.id;
1293
- /* determine the schema description popup key of an object (the
1294
- INFO table index of its schema node) */
1295
- const infoKeyOf = (object) => {
1296
- const schema = schemas?.get(object);
1297
- return schema !== undefined ? infoKeys?.get(schema) : undefined;
1298
- };
1299
- /* determine the corpus description popup key of an object (its SPEC
1300
- table index), which a schema popup references for its title path,
1301
- too: "named = false" keeps the last path segment kind-only there,
1302
- as the kind popups (and the group headers of the compact tables)
1303
- describe the object class instead of the single instance */
1304
- const infoRefOf = (object, named = true) => {
1305
- const key = infoObjects?.get(object);
1306
- return key !== undefined ? key + (named ? "" : "^") : undefined;
1307
- };
1308
- /* render the schema description popup key and kind-ending title path
1309
- reference of an object as "data-info" attributes for the manually
1310
- assembled hyperlink markup (the kind popup of the full hyperlinks) */
1311
- const infoAttr = (object) => {
1312
- const key = infoKeyOf(object);
1313
- const ref = infoRefOf(object, false);
1314
- return key !== undefined && ref !== undefined ?
1315
- ` data-info="${key}" data-info-path="${ref}"` : "";
1316
- };
1317
- /* render the corpus description popup key of an object as the
1318
- "data-info-spec" attribute for the manually assembled hyperlink
1319
- markup (the instance popup of the compact and full hyperlinks) */
1320
- const specAttr = (object) => {
1321
- const ref = infoRefOf(object);
1322
- return ref !== undefined ? ` data-info-spec="${ref}"` : "";
1323
- };
1324
- /* collect the description popup table of the schema nodes and their
1325
- keys (the table indices), with the description Markdown of the
1326
- objects and properties pre-rendered to HTML */
1327
- const collectInfo = (nodes, keys, info) => {
1328
- for (const schema of nodes) {
1329
- keys.set(schema, String(info.length));
1330
- const entry = {};
1331
- if (schema.desc !== undefined)
1332
- entry.d = marked.parse(schema.desc, { async: false }).trim();
1333
- for (const property of schema.props ?? []) {
1334
- if (property.desc === undefined)
1335
- continue;
1336
- entry.p ??= {};
1337
- entry.p[property.name] = marked.parse(property.desc, { async: false }).trim();
1338
- }
1339
- info.push(entry);
1340
- collectInfo(schema.children ?? [], keys, info);
1341
- }
1342
- };
1343
- /* inject the description popup tables into their client-side script ("<"
1344
- escaped, so no embedded HTML can close the surrounding <script>
1345
- element, and the trailing table first, so a placeholder text
1346
- inside an injected table is never taken for the placeholder) */
1347
- const infoScript = (info, spec) => infoPopupScript
1348
- .replace("@SPEC@", () => JSON.stringify(spec).replace(/</g, "\\u003c"))
1349
- .replace("@INFO@", () => JSON.stringify(info).replace(/</g, "\\u003c"));
1350
- /* expand the inline Markdown of a text (code spans, emphasis, etc.),
1351
- with Wiki-style references expanded upfront (in their compact form
1352
- for prose, i.e. descriptions, in their full form otherwise) */
1353
- const inline = (text, compact = false) => safe(marked.parseInline(linker !== null ? linker(text, compact) : text, { async: false }));
1354
- /* expand the full Markdown of a text, keeping its block-level
1355
- constructs (lists, quotes, code blocks, tables) intact */
1356
- const block = (text, compact = false) => safe(marked.parse(linker !== null ? linker(text, compact) : text, { async: false }));
1357
- /* check whether a text carries any block-level Markdown, i.e. is
1358
- anything other than the single paragraph a description usually is */
1359
- const isBlock = (text) => {
1360
- const tokens = marked.lexer(text).filter((token) => token.type !== "space");
1361
- return tokens.length > 0 && !(tokens.length === 1 && tokens[0].type === "paragraph");
1362
- };
1363
- /* render a single embedded image file (in its optimized form) onto an
1364
- <img> tag with a self-contained data: URL (converting the SVG text
1365
- into one, as an SVG inlined as-is would leak its document-global
1366
- <style> rules into all other inlined SVGs sharing the same class names) */
1367
- const renderImage = (original, alt) => {
1368
- const content = images?.get(original) ?? original;
1369
- const url = content.startsWith("data:") ? content :
1370
- `data:image/svg+xml;base64,${Buffer.from(content, "utf8").toString("base64")}`;
1371
- return `<img src="${url}" alt="${escapeHtml(alt)}"/>`;
1372
- };
1373
- /* wrap the theme variants of an image into their layout-neutral theme
1374
- containers, of which the stylesheet shows just the one matching the
1375
- color theme currently active in the document */
1376
- const renderThemed = (variants) => variants.map((variant, i) => `<span class="theme-${embeddingThemes[i]}-only">${variant}</span>`).join("");
1377
- /* render the embedded image files of a text into HTML, taking the image
1378
- alternate texts from the corresponding "![alt](file)" markups and
1379
- pairing up the consecutive theme variants of a "{theme}" markup
1380
- (the empty entries of unreadable files are skipped) */
1381
- const renderEmbeddings = (text, embedding) => {
1382
- const result = new Array();
1383
- let i = 0;
1384
- for (const m of text.matchAll(embeddingRegex)) {
1385
- const count = embeddingCount(m[2]);
1386
- if (count === 0)
1387
- continue;
1388
- const contents = embedding.slice(i, i + count);
1389
- const variants = contents.filter((content) => content !== "")
1390
- .map((content) => renderImage(content, m[1].trim()));
1391
- i += count;
1392
- if (count > 1 && variants.length === count)
1393
- result.push(renderThemed(variants));
1394
- else
1395
- result.push(...variants);
1396
- }
1397
- return result;
1398
- };
1399
- /* the image embedding markup including the horizontal whitespace
1400
- before it, so that its removal leaves no double space behind */
1401
- const embeddingMarkup = new RegExp(`[ \\t]*${embeddingRegex.source}`, "g");
1402
- /* render a description into HTML, expanding its inline Markdown and
1403
- moving the file embeddings to the end of the description */
1404
- const renderDescription = (description) => {
1405
- const text = description.description
1406
- .replace(embeddingMarkup, (markup, _alt, reference) => embeddingCount(reference) > 0 ? "" : markup)
1407
- .trim();
1408
- const embeddings = renderEmbeddings(description.description, description.embedding ?? []).map((content) => safe(content));
1409
- const blocked = isBlock(text);
1410
- return render("Description", { Description: {
1411
- block: blocked,
1412
- description: text !== "" ? (blocked ? block(text, true) : inline(text, true)) : "",
1413
- rationale: description.rationale !== undefined ?
1414
- inline(description.rationale, true) : undefined,
1415
- embeddings
1416
- } });
1417
- };
1418
- /* collect the description popups of the object instances: the corpus
1419
- description Markdown of every object, pre-rendered to HTML into
1420
- its SPEC table entry (the embedded images and the rationale are
1421
- left out, as the popup shows the prose alone) */
1422
- const collectSpec = (objects, spec) => {
1423
- for (const object of objects) {
1424
- const text = (object.description?.description ?? "")
1425
- .replace(embeddingMarkup, (markup, _alt, reference) => embeddingCount(reference) > 0 ? "" : markup)
1426
- .trim();
1427
- const key = infoObjects?.get(object);
1428
- if (key !== undefined && text !== "")
1429
- spec[Number(key)][4] = scoped(object, () => block(text, true).toString().trim());
1430
- collectSpec(object.children, spec);
1431
- }
1432
- };
1433
- /* the literal members of an "enum(...)"/"tags(...)" expression, or
1434
- of the "enum(...)"/"tags(...)" alternatives of a "list(...)" one
1435
- (empty for every other expression) */
1436
- const literalMembers = (expr) => expr.kind === "enum" || expr.kind === "tags" ? expr.members :
1437
- expr.kind === "list" ? expr.alternatives.flatMap(literalMembers) : [];
1438
- /* collect the properties of the schema configuration constrained by
1439
- literal members (an "enum(...)", a "tags(...)", or a "list(...)"
1440
- with such alternatives), keyed by object kind and property name */
1441
- const collectMembers = (nodes, result) => {
1442
- for (const schema of nodes) {
1443
- for (const property of schema.props ?? []) {
1444
- if (property.value === undefined)
1445
- continue;
1446
- try {
1447
- const expr = compileValueExpr(property.value);
1448
- if (literalMembers(expr).length > 0)
1449
- result.set(`${schema.kind} ${property.name}`, expr);
1450
- }
1451
- catch {
1452
- /* an invalid expression is the concern of lint */
1453
- }
1454
- }
1455
- collectMembers(schema.children ?? [], result);
1456
- }
1457
- return result;
1458
- };
1459
- /* render a property value, badging the individual members of an
1460
- "enum(...)" (a single member) or "tags(...)" (a member set) value
1461
- and the literal member items of a "list(...)" value (the other items,
1462
- like references, stay prose), moving its image embeddings behind the
1463
- value, rendered from the embedded image contents (as for a
1464
- description), not from the markup; an absent property renders as the
1465
- marker telling it apart from a property given with an empty value */
1466
- const inlineValue = (kind, property) => {
1467
- if (property === undefined)
1468
- return safe("<span class=\"value-absent\"></span>");
1469
- const { key, value, embedding } = property;
1470
- const expr = members?.get(`${kind} ${key}`);
1471
- const text = value
1472
- .replace(embeddingMarkup, (markup, _alt, reference) => embeddingCount(reference) > 0 ? "" : markup)
1473
- .trim();
1474
- const embeddings = renderEmbeddings(value, embedding ?? [])
1475
- .map((content) => `<div class="embedding">${content}</div>`).join("");
1476
- if (expr === undefined || text === "")
1477
- return safe(`${inline(text)}${embeddings}`);
1478
- const badge = (item) => `<span class="value-member">${inline(item)}</span>`;
1479
- if (expr.kind === "enum")
1480
- return safe(badge(text) + embeddings);
1481
- const items = splitItems(text).filter((item) => item !== "");
1482
- if (expr.kind === "tags")
1483
- return safe(items.map(badge).join(" ") + embeddings);
1484
- const literals = new Set(literalMembers(expr));
1485
- return safe(items.map((item) => literals.has(item) ? badge(item) : inline(item)).join(", ") + embeddings);
1486
- };
1487
- /* expand the inline Markdown of the property values, with the
1488
- entries injected for unused properties (foreign to the object)
1489
- rendered as absent */
1490
- const inlineProperties = (object, properties) => properties.map((property) => ({ key: property.key,
1491
- info: infoKeyOf(object), infopath: infoRefOf(object),
1492
- value: inlineValue(object.kind, object.properties.includes(property) ? property : undefined) }));
1493
- /* resolve the format configuration of the kind of an object */
1494
- const formatOf = (object) => schemas?.get(object)?.format;
1495
- /* determine the maximum table columns configured on the kind of an
1496
- object (at least two, as a single column cannot carry a name and a
1497
- value) */
1498
- const maxColumnsOf = (object) => Math.max(2, formatOf(object)?.maxTableColumns ?? 4);
1499
- /* determine the effective properties of an object in schema order
1500
- (unknown keys appended in document order), with "withUnusedProps"
1501
- injecting the defined but still unused schema properties as empty
1502
- key/value entries */
1503
- const effectiveProperties = (object) => {
1504
- const schema = schemas?.get(object);
1505
- if (schema === undefined)
1506
- return object.properties;
1507
- const unused = schema.format?.withUnusedProps === true;
1508
- const merged = (schema.props ?? []).flatMap((prop) => {
1509
- const present = object.properties.filter((property) => property.key === prop.name);
1510
- return present.length > 0 ? present : (unused ? [{ key: prop.name, value: "" }] : []);
1511
- });
1512
- return [...merged, ...object.properties.filter((property) => !merged.includes(property))];
1513
- };
1514
- /* determine the column shape of a potential compact table in schema
1515
- order (unknown keys appended in occurrence order), with
1516
- "withUnusedProps" injecting the defined but still unused schema
1517
- properties as additional columns */
1518
- const tableShape = (children) => {
1519
- const used = [...new Set(children.flatMap((child) => child.properties.map((property) => property.key)))];
1520
- const schema = schemas?.get(children[0]);
1521
- const names = (schema?.props ?? []).map((prop) => prop.name);
1522
- const known = schema?.format?.withUnusedProps === true ?
1523
- names : names.filter((name) => used.includes(name));
1524
- const keys = [...known, ...used.filter((key) => !names.includes(key))];
1525
- return { keys, desc: children.some((child) => child.description !== undefined || child.children.length > 0) };
1526
- };
1527
- /* provide the children of an object taking part in the regular document
1528
- flow: a nested title object leaves the flow, as it is rendered as the
1529
- title page instead (a top-level one instead removes its whole artifact) */
1530
- const flowChildren = (object) => object.children.filter((child) => !isTitleObject(child));
1531
- /* decide whether a single-kind group of children collapses into the
1532
- concise (tabular) rendering: an explicit "format" type of the kind
1533
- wins, "auto" collapses the deepest level only, and inside an already
1534
- concise rendering context "auto" groups implicitly stay concise, too */
1535
- const conciseGroup = (group, schemaMap, concise) => {
1536
- const type = schemaMap?.get(group[0])?.format?.type ?? "auto";
1537
- if (type !== "auto")
1538
- return type === "concise";
1539
- return concise || group.every((child) => child.children.length === 0);
1540
- };
1541
- /* group the children of an object by their kind, preserving order */
1542
- const groupChildren = (children) => {
1543
- const groups = new Map();
1544
- for (const child of children) {
1545
- const group = groups.get(child.kind);
1546
- if (group === undefined)
1547
- groups.set(child.kind, [child]);
1548
- else
1549
- group.push(child);
1550
- }
1551
- return [...groups.values()];
1552
- };
1553
- /* render the children of an object group-wise by kind: a concise group
1554
- as one compact table, a complex group as regular nested object
1555
- renderings */
1556
- const renderChildren = (object, level, concise) => groupChildren(flowChildren(object)).map((group) => conciseGroup(group, schemas, concise) ?
1557
- renderTable(group, maxColumnsOf(group[0])) :
1558
- group.map((child) => renderObject(child, level, concise)).join("")).join("");
1559
- /* render the description cell of a table row: the description of the
1560
- object followed by its recursively rendered children (implicitly or
1561
- explicitly concise groups as nested sub-tables, explicitly complex
1562
- ones as regular nested object renderings pressed into the cell);
1563
- a cell left without any content renders as the absent marker,
1564
- exactly like a property cell of a not given property */
1565
- const renderCell = (child) => {
1566
- let html = child.description !== undefined ? renderDescription(child.description) : "";
1567
- html += renderChildren(child, 6, true);
1568
- return html.trim() !== "" ? html : "<span class=\"value-absent\"></span>";
1569
- };
1570
- /* the pre-rendered diagram of an object as an embeddable block
1571
- (empty for an object without a configured or renderable diagram) */
1572
- const diagramOf = (object) => {
1573
- const diagram = diagrams?.get(object);
1574
- return diagram !== undefined ? safe(diagram) : "";
1575
- };
1576
- /* the reference coverage of an object as a table: one row per
1577
- configured pattern, labeled by the kinds of the matching objects,
1578
- marked as object kinds and carrying the description popup of the
1579
- first matching object of each kind (by the bare pattern, if none
1580
- matches), with the counts and the ratio as a bar (empty for an
1581
- object without a configured coverage) */
1582
- const coverageOf = (object) => {
1583
- const coverage = coverages?.get(object);
1584
- if (coverage === undefined)
1585
- return "";
1586
- const kindLabel = (entry, kind) => {
1587
- const first = [...entry.covered, ...entry.uncovered].find((target) => target.kind === kind);
1588
- return `<span class="object-kind"${first !== undefined ? infoAttr(first) : ""}>${escapeHtml(kind)}</span>`;
1589
- };
1590
- return safe(render("Coverage", { Coverage: coverage.map((entry, i) => ({
1591
- label: entry.kinds.length > 0 ?
1592
- safe(entry.kinds.map((kind) => kindLabel(entry, kind)).join(", ")) :
1593
- entry.pattern.replace(/^\[\[(.*)\]\]$/s, "$1"),
1594
- covered: entry.covered.length,
1595
- total: entry.covered.length + entry.uncovered.length,
1596
- ratio: coverageRatio(entry.covered.length, entry.covered.length + entry.uncovered.length),
1597
- even: i % 2 === 1
1598
- })) }));
1599
- };
1600
- /* the estimated number of characters a text line spanning the
1601
- entire content width holds (60rem at the document font) */
1602
- const lineChars = 140;
1603
- /* omit the long texts of the comparable cells of a table row, the
1604
- server-side heuristic counterpart of the client-side cell text
1605
- folding, judging a cell by its estimated number of text lines (its
1606
- plain text per block, wrapped at the characters its column width
1607
- share holds per line) instead of its rendered height: a cell
1608
- exceeding every other one by more than the "maxCellHeight"
1609
- percentage is cut back onto the whole lines of that limit (less the
1610
- room of the mark) at a word boundary, its open elements closed
1611
- again, and ends in a grey "[...]" mark,
1612
- unless that hides less than 25% of it, where the empty cells and the
1613
- ones carrying further cells or a diagram take no part at all */
1614
- const omitLong = (cells, shares, percent = 0) => {
1615
- if (omits?.has("text:long") !== true)
1616
- return cells;
1617
- const htmls = cells.map(String);
1618
- const plain = (html) => html.replace(/<[^>]*>/g, "").trim().length;
1619
- const lengths = htmls.map((html) => (/<td[\s>]|<div class="diagram"/).test(html) ? 0 : plain(html));
1620
- if (lengths.filter((length) => length > 0).length < 2)
1621
- return cells;
1622
- const chars = shares.map((share) => Math.max(1, Math.floor(lineChars * share)));
1623
- const lines = htmls.map((html, i) => lengths[i] === 0 ? 0 :
1624
- html.split(/<\/(?:p|li|div|pre)>|<br\s*\/?>/)
1625
- .reduce((sum, block) => sum + Math.ceil(plain(block) / chars[i]), 0));
1626
- return htmls.map((html, i) => {
1627
- const other = Math.max(...lines.filter((_, j) => j !== i)) *
1628
- (1 + (percent > 0 ? percent : cellHeightDefault) / 100);
1629
- const limit = Math.floor(other) * chars[i] - " [...]".length;
1630
- if (lines[i] === 0 || lines[i] <= other || limit > lengths[i] * (1 - cellGainMin / 100))
1631
- return cells[i];
1632
- const open = new Array();
1633
- let out = "";
1634
- let count = 0;
1635
- for (const [token] of html.matchAll(/<[^>]*>|[^<]+/g)) {
1636
- if (token.startsWith("<")) {
1637
- const tag = (/^<(\/?)([a-zA-Z][^\s/>]*)[^>]*?(\/?)>$/).exec(token);
1638
- if (tag?.[1] === "/")
1639
- open.pop();
1640
- else if (tag !== null && tag[3] !== "/" && !(/^(?:br|hr|img|input|wbr)$/).test(tag[2]))
1641
- open.push(tag[2]);
1642
- }
1643
- else if (count + token.length > limit)
1644
- return safe(out + token.slice(0, limit - count).replace(/\S*$/, "") +
1645
- "<span class=\"omit\">[...]</span>" +
1646
- open.reverse().map((name) => `</${name}>`).join(""));
1647
- else
1648
- count += token.length;
1649
- out += token;
1650
- }
1651
- return cells[i];
1652
- });
1653
- };
1654
- /* render a single-kind group of children into one compact table:
1655
- the name first, then the property columns, then the description;
1656
- a group wider than maxColumns, or carrying diagrams, instead chunks
1657
- the property and description cells (and the leading diagram) of
1658
- every object into an embedded per-object table, as a diagram is
1659
- identifiable only under a header of its own, not under the column
1660
- headers of a plain table */
1661
- const renderTable = (children, maxColumns) => {
1662
- const { keys, desc } = tableShape(children);
1663
- const diagrammed = children.some((child) => diagrams?.has(child) === true);
1664
- if (!diagrammed && 1 + keys.length + (desc ? 1 : 0) <= maxColumns)
1665
- return render("Table", { Table: {
1666
- head: children[0].kind !== "" ? children[0].kind : "Name",
1667
- info: infoKeyOf(children[0]),
1668
- infopath: infoRefOf(children[0], false),
1669
- keys,
1670
- desc,
1671
- fold: formatOf(children[0])?.maxCellHeight,
1672
- /* under the fixed table layout the description column claims
1673
- twice the share of a regular column, compressing the others */
1674
- width: Math.round(200 / (keys.length + 3)),
1675
- rows: children.map((child, i) => scoped(child, () => {
1676
- const values = keys.map((key) => inlineValue(child.kind, child.properties.find((property) => property.key === key)));
1677
- const shares = desc ? [...keys.map(() => 1), 2] : keys.map(() => 1);
1678
- const cells = omitLong(desc ? [...values, safe(renderCell(child))] : values, shares.map((share) => share / (keys.length + (desc ? 3 : 1))), formatOf(children[0])?.maxCellHeight);
1679
- return {
1680
- id: anchorOf(child),
1681
- anchor: child.anchor,
1682
- paren: child.paren,
1683
- primary: child.primary,
1684
- spec: infoRefOf(child),
1685
- name: inline(child.name),
1686
- even: i % 2 === 1,
1687
- values: cells.slice(0, keys.length),
1688
- description: cells[keys.length]
1689
- };
1690
- }))
1691
- } });
1692
- /* the embedded rows hold at most maxColumns - 1 cells (of the
1693
- group-wide union of property keys, plus the trailing description),
1694
- with the last cell spanning the leftover columns of the final row */
1695
- const size = Math.max(1, maxColumns - 1);
1696
- return render("TableChunked", { Table: {
1697
- head: children[0].kind !== "" ? children[0].kind : "Name",
1698
- info: infoKeyOf(children[0]),
1699
- infopath: infoRefOf(children[0], false),
1700
- desc,
1701
- fold: formatOf(children[0])?.maxCellHeight,
1702
- width: Math.round(100 / maxColumns),
1703
- rows: children.map((child, i) => scoped(child, () => {
1704
- const cells = keys.map((key) => ({ key, desc: false, span: 1,
1705
- value: inlineValue(child.kind, child.properties.find((property) => property.key === key)) }));
1706
- if (desc)
1707
- cells.push({ key: "Description", desc: true, span: 1,
1708
- value: safe(renderCell(child)) });
1709
- const chunks = new Array();
1710
- for (let pos = 0; pos < cells.length; pos += size)
1711
- chunks.push(cells.slice(pos, pos + size));
1712
- if (chunks.length > 0) {
1713
- const last = chunks[chunks.length - 1];
1714
- last[last.length - 1].span = size - last.length + 1;
1715
- }
1716
- for (const chunk of chunks)
1717
- omitLong(chunk.map((cell) => cell.value), chunk.map((cell) => cell.span / maxColumns), formatOf(children[0])?.maxCellHeight)
1718
- .forEach((value, k) => { chunk[k].value = value; });
1719
- /* the diagram leads the chunks as a full-width chunk of its
1720
- own, headed like the description */
1721
- const diagram = diagramOf(child);
1722
- if (diagram !== "")
1723
- chunks.unshift([{ key: "Diagram", desc: true, span: size, value: diagram }]);
1724
- return {
1725
- id: anchorOf(child),
1726
- anchor: child.anchor,
1727
- paren: child.paren,
1728
- primary: child.primary,
1729
- spec: infoRefOf(child),
1730
- name: inline(child.name),
1731
- even: i % 2 === 1,
1732
- chunks
1733
- };
1734
- }))
1735
- } });
1736
- };
1737
- /* recursively render an object into HTML */
1738
- const renderObject = (object, level, concise) => {
1739
- const properties = effectiveProperties(object);
1740
- return scoped(object, () => render("Object", { Object: {
1741
- level: Math.min(level, 6),
1742
- kind: object.kind,
1743
- info: infoKeyOf(object),
1744
- infopath: infoRefOf(object, false),
1745
- spec: infoRefOf(object),
1746
- id: anchorOf(object),
1747
- anchor: object.anchor,
1748
- paren: object.paren,
1749
- primary: object.primary,
1750
- name: inline(object.name),
1751
- diagram: diagramOf(object),
1752
- properties: properties.length > 0 ?
1753
- safe(render("Properties", { Properties: inlineProperties(object, properties),
1754
- Fold: formatOf(object)?.maxCellHeight })) : "",
1755
- description: object.description !== undefined ?
1756
- safe(renderDescription(object.description)) : "",
1757
- coverage: coverageOf(object),
1758
- children: safe(renderChildren(object, level + 1, concise))
1759
- } }));
1760
- };
1761
- /* format a timestamp as its calendar date in local time (the
1762
- frontmatter timestamps are parsed in local time, too, so the UTC
1763
- date of toISOString() could shift the day) */
1764
- const formatDate = (date) => `${date.getFullYear()}-${String(date.getMonth() + 1).padStart(2, "0")}-` +
1765
- String(date.getDate()).padStart(2, "0");
1766
- /* render the title object into a title page */
1767
- const renderTitlePage = (object, created, modified) => {
1768
- const prop = (name) => object.properties.find((property) => property.key === name)?.value;
1769
- const inlineProp = (name) => {
1770
- const value = prop(name);
1771
- return value !== undefined && value.trim() !== "" ? inline(value) : "";
1772
- };
1773
- const rest = object.properties.filter((property) => !["LOGO", "TITLE", "SUBTITLE", "AUTHOR", "VERSION",
1774
- "LANG", "CHARSET", "PAPER-SIZE", "THEME-STYLE", "THEME-TONE"].includes(property.key));
1775
- /* the logo is rendered above the title, from the embedded image content
1776
- of the LOGO property or, for a non-embeddable reference, as its inline
1777
- Markdown; without a (non-empty) LOGO property the built-in SpecBook
1778
- logo is used, in both its theme variants */
1779
- const logo = object.properties.find((property) => property.key === "LOGO" && property.value.trim() !== "");
1780
- const image = logo !== undefined ? renderEmbeddings(logo.value, logo.embedding ?? []) : [];
1781
- return scoped(object, () => render("TitlePage", { TitlePage: {
1782
- logo: logo === undefined ?
1783
- safe(renderThemed(embeddingThemes.map((theme) => `<img src="${fallbackLogo(theme)}" alt="SpecBook"/>`))) :
1784
- (image.length > 0 ? safe(image.join("")) : inline(logo.value)),
1785
- title: inline(prop("TITLE") ?? object.name),
1786
- subtitle: inlineProp("SUBTITLE"),
1787
- author: inlineProp("AUTHOR"),
1788
- version: inlineProp("VERSION"),
1789
- description: object.description !== undefined ?
1790
- safe(renderDescription(object.description)) : "",
1791
- properties: rest.length > 0 ?
1792
- safe(render("Properties", { Properties: inlineProperties(object, rest) })) : "",
1793
- created, modified
1794
- } }));
1795
- };
1796
- /* render an artifact into HTML */
1797
- const renderArtifact = (artifact) => render("Artifact", { Artifact: {
1798
- objects: safe(artifact.objects.map((object) => renderObject(object, 1, false)).join(""))
1799
- } });
1800
- /* provide the children of an object rendered with headings of their own
1801
- (skipping the child groups collapsing into compact tables), which
1802
- are the ones taking part in the table of contents and its side panel */
1803
- const headingChildren = (object) => groupChildren(flowChildren(object))
1804
- .filter((group) => !conciseGroup(group, schemas, false))
1805
- .flat();
1806
- /* flatten the hierarchy of the rendered object headings (exactly like
1807
- the PDF outline) into the entries of the table of contents, each
1808
- carrying its nesting level and its optional page number */
1809
- const tocEntries = (objects, pages) => {
1810
- const entries = [];
1811
- const collect = (objects, level) => {
1812
- for (const object of objects) {
1813
- const id = anchorOf(object);
1814
- entries.push({ id, kind: object.kind,
1815
- info: infoKeyOf(object), infopath: infoRefOf(object, false), spec: infoRefOf(object),
1816
- name: inline(object.name), level: Math.min(level, 6), page: pages?.get(id) });
1817
- collect(headingChildren(object), level + 1);
1818
- }
1819
- };
1820
- collect(objects, 1);
1821
- return entries;
1822
- };
1823
- /* render the side panel of the table of contents (its behavior is
1824
- driven by the client-side script above): the unnumbered entries of
1825
- the front matter (title page, table and diagram of contents, as far
1826
- as present) are followed by the hierarchical entries of the rendered
1827
- object headings, exactly like the table of contents itself */
1828
- const renderTocPanel = (objects, title, doc) => {
1829
- const entries = (objects) => objects.length === 0 ? "" : render("TocPanelEntries", { Entries: objects.map((object) => ({
1830
- id: anchorOf(object),
1831
- kind: object.kind,
1832
- info: infoKeyOf(object),
1833
- infopath: infoRefOf(object, false),
1834
- spec: infoRefOf(object),
1835
- name: inline(object.name),
1836
- children: safe(entries(headingChildren(object)))
1837
- })) });
1838
- return render("TocPanel", { TocPanel: { title, doc, entries: safe(entries(objects)) } });
1839
- };
11
+ import { optimizeImages, analyzeImages } from "./specbook-export-image.js";
12
+ import { renderEmbeddedDiagrams, objectLevels } from "./specbook-export-diagram.js";
13
+ import { prepareListings } from "./specbook-export-code.js";
14
+ import { specCoverage } from "./specbook-coverage.js";
15
+ import { safe, render } from "./specbook-export-html-template.js";
16
+ import { themeScript, placeholderStylesheet, realtimeScript, scrollProgressScript, maximizeScript, tocPanelScript, infoScript } from "./specbook-export-html-widget.js";
17
+ import { foldScript, omittedControls } from "./specbook-export-html-fold.js";
18
+ import { withDocument, makeLinker, collectInfo, collectSpec, collectMembers, conciseGroup, flowChildren, groupChildren, formatDate, renderTitlePage, renderArtifact, tocEntries, renderTocPanel } from "./specbook-export-html-render.js";
1840
19
  /* derive the hierarchy of the rendered object headings, skipping the
1841
20
  title page object and the child groups collapsing into compact tables */
1842
21
  export const htmlOutline = (specification, config) => {
@@ -1904,27 +83,6 @@ const scaledDiagrams = async (specification, config, verbose, omit) => {
1904
83
  }
1905
84
  return { svgs: rendered, css: Array.from(rules).join("\n") };
1906
85
  };
1907
- /* create the reference expander of a document: "[[xxx]]" references
1908
- expand into hyperlinks (an unresolvable or ambiguous reference stays
1909
- literal, marked as broken), resolved from the object currently
1910
- rendered and targeting the fully-qualified anchor paths of the
1911
- objects: in the full form (kind, name, and link symbol), or in the
1912
- compact form for prose (the object icon and the name only, via CSS
1913
- and markup, with the full form shown by the description popups) */
1914
- const makeLinker = (index) => (text, compact) => expandReferences(text, (reference) => {
1915
- const target = resolveUnique(index, reference, scope ?? undefined).target;
1916
- if (target === undefined)
1917
- return `<span class="link-broken">[[${escapeHtml(reference)}]]</span>`;
1918
- /* the full form carries the popup attributes on its kind and
1919
- name, with the kind bold in the accent color and the name
1920
- bold in the regular text color (via the stylesheet) */
1921
- const full = `<strong class="object-kind"${infoAttr(target)}>${escapeHtml(target.kind)}:</strong>` +
1922
- ` <span class="object-name"${specAttr(target)}>${target.name}</span>` +
1923
- " <span class=\"link-symbol\">&#x26AD;</span>";
1924
- return compact ?
1925
- `<a href="#${escapeHtml(anchorOf(target))}" class="link-compact"${specAttr(target)}>${target.name}</a>` :
1926
- `<a href="#${escapeHtml(anchorOf(target))}" class="link-full">${full}</a>`;
1927
- });
1928
86
  /* render the placeholder page of the live preview, served instead of
1929
87
  the document before the first successful export: it carries the very
1930
88
  same client-side script as the regular export, so the page recovers
@@ -1938,44 +96,15 @@ export const renderPlaceholder = (message) => render("Placeholder", { Placeholde
1938
96
  realtime: safe(realtimeScript),
1939
97
  message
1940
98
  } });
1941
- /* the fold controls the omitted aspects drop, along with the implied
1942
- ones (no type control without diagrams of that type, no level
1943
- control without diagrams from that level on, no fold tab without
1944
- any control, which lets the tabs below it move up), plus the extra
1945
- style rules the omission brings (the "[...]" mark of the omitted
1946
- long texts and the moved-up tabs of a dropped fold tab) */
1947
- const omittedControls = (omit) => {
1948
- const none = omit.has("diagram:1")
1949
- || (omit.has("diagram:graph") && omit.has("diagram:hub") && omit.has("diagram:grid"));
1950
- const omitted = {
1951
- graph: none || omit.has("diagram:graph"),
1952
- hub: none || omit.has("diagram:hub"),
1953
- grid: none || omit.has("diagram:grid"),
1954
- level1: none,
1955
- level2: none || omit.has("diagram:2"),
1956
- level3: none || omit.has("diagram:2") || omit.has("diagram:3"),
1957
- text: omit.has("text:long"),
1958
- all: none && omit.has("text:long")
1959
- };
1960
- const css = (omitted.text ? "\nspan.omit { color: var(--theme-color-specbook-muted) }" : "") +
1961
- (omitted.all ? "\nnav.toc-panel div.toc-tab { top: 10.66rem }" +
1962
- "\ndiv.realtime-status { top: 13.89rem }" : "");
1963
- return { omitted, css };
1964
- };
1965
99
  /* wrap the pre-rendered diagrams into their blocks, carrying the
1966
100
  diagram type and the object tree nesting level the folding
1967
101
  distinguishes (the top-level objects of an artifact being level 1) */
1968
- const wrapDiagrams = (index, svgs) => {
102
+ const wrapDiagrams = (svgs, schemas, levels) => {
1969
103
  const blocks = new Map();
1970
- for (const node of index) {
1971
- const svg = svgs.get(node.object);
1972
- if (svg === undefined)
1973
- continue;
1974
- let level = 1;
1975
- for (let parent = node.parent; parent !== undefined; parent = parent.parent)
1976
- level++;
1977
- const type = schemas?.get(node.object)?.diagram?.type ?? "graph";
1978
- blocks.set(node.object, `<div class="diagram" data-type="${type}" data-level="${level}">${svg}</div>`);
104
+ for (const [object, svg] of svgs) {
105
+ const level = levels.get(object) ?? 1;
106
+ const type = schemas?.get(object)?.diagram?.type ?? "graph";
107
+ blocks.set(object, `<div class="diagram" data-type="${type}" data-level="${level}">${svg}</div>`);
1979
108
  }
1980
109
  return blocks;
1981
110
  };
@@ -1984,8 +113,9 @@ const wrapDiagrams = (index, svgs) => {
1984
113
  artifact timestamps aggregated into min(Created)/max(Modified),
1985
114
  optional per-anchor page numbers attached to the ToC entries,
1986
115
  optionally the client-side script of the live preview injected and
1987
- aspects omitted (see "ExportOptions"), and the embedded images
1988
- optimized for the screen or for print (the PDF) */
116
+ aspects omitted (see "ExportOptions"), the embedded images
117
+ optimized for the screen or for print (the PDF), and the embedded
118
+ Mermaid/D2 diagrams rendered in the theme colors */
1989
119
  export const renderHtml = async (specification, config, tocPages, css, options = {}, verbose, print = false) => {
1990
120
  /* pre-render the configured diagrams as scaled embeddable SVGs
1991
121
  (except for the omitted ones, which hence leave no trace at all) */
@@ -1994,47 +124,63 @@ export const renderHtml = async (specification, config, tocPages, css, options =
1994
124
  await scaledDiagrams(specification, config, verbose, omit) : null;
1995
125
  /* the fold controls and the extra style rules of the omitted aspects */
1996
126
  const { omitted, css: omitCss } = omittedControls(omit);
1997
- /* pre-optimize the embedded images (downscaled and re-encoded) */
127
+ /* pre-optimize the embedded images (downscaled and re-encoded)
128
+ and judge the raster ones for their treatment on the dark theme */
1998
129
  const optimized = await optimizeImages(specification, print, verbose);
130
+ const analyzed = await analyzeImages(specification, verbose);
131
+ /* pre-render the embedded Mermaid/D2 diagrams (except the omitted ones)
132
+ and prepare the syntax highlighting of the code listings */
133
+ const sources = await renderEmbeddedDiagrams(specification, verbose, omit);
134
+ const renderer = await prepareListings(specification, verbose);
135
+ /* collect the per-document rendering state, where the description
136
+ popup keys are filled in below, once the state is established */
137
+ const index = buildLinkIndex(specification);
138
+ const schemas = config !== undefined ? collectSchemas(specification, config) : null;
139
+ const levels = objectLevels(specification);
140
+ const infoKeys = new Map();
141
+ const infoObjects = new Map();
142
+ /* wrap the diagrams into their blocks, carrying the diagram type
143
+ and the object tree nesting level the folding distinguishes */
144
+ const diagrams = rendered !== null ? wrapDiagrams(rendered.svgs, schemas, levels) : null;
1999
145
  /* the document language selects the smart typography quote style */
2000
146
  const lang = documentLang(specification);
2001
- quotes = quoteStyles[lang?.toLowerCase().split(/[-_]/)[0] ?? "en"] ?? quoteStyles.en;
2002
147
  /* establish the per-document rendering state (released again
2003
- below, also on a failure of the collecting or rendering) */
2004
- const index = buildLinkIndex(specification);
2005
- try {
2006
- anchors = anchorPaths(index);
2007
- members = config !== undefined ? collectMembers(config, new Map()) : null;
2008
- schemas = config !== undefined ? collectSchemas(specification, config) : null;
2009
- images = optimized;
2010
- coverages = schemas !== null ? specCoverage(index, schemas) : null;
2011
- omits = omit;
2012
- /* wrap the diagrams into their blocks, carrying the diagram type
2013
- and the object tree nesting level the folding distinguishes */
2014
- diagrams = rendered !== null ? wrapDiagrams(index, rendered.svgs) : null;
148
+ afterwards, also on a failure of the collecting or rendering) */
149
+ return withDocument({
150
+ lang,
151
+ linker: makeLinker(index),
152
+ anchors: anchorPaths(index),
153
+ members: config !== undefined ? collectMembers(config, new Map()) : null,
154
+ schemas,
155
+ diagrams,
156
+ images: optimized,
157
+ rasters: analyzed,
158
+ embedded: sources,
159
+ listings: renderer,
160
+ levels,
161
+ coverages: schemas !== null ? specCoverage(index, schemas) : null,
162
+ infoKeys: config !== undefined ? infoKeys : null,
163
+ infoObjects: config !== undefined ? infoObjects : null,
164
+ omits: omit
165
+ }, () => {
2015
166
  /* collect the schema descriptions for the description popups,
2016
167
  plus the object table composing their title paths */
2017
168
  let info = null;
2018
169
  const spec = [];
2019
170
  if (config !== undefined) {
2020
- infoKeys = new Map();
2021
171
  info = [];
2022
172
  collectInfo(config, infoKeys, info);
2023
- const objects = new Map();
2024
- for (const node of index)
2025
- objects.set(node.object, String(objects.size));
2026
- for (const node of index)
2027
- spec.push([node.object.id, node.parent !== undefined ? Number(objects.get(node.parent.object)) : -1,
173
+ for (const node of index) {
174
+ infoObjects.set(node.object, String(node.pos));
175
+ spec.push([node.object.id, node.parent?.pos ?? -1,
2028
176
  node.object.kind, plainText(node.object.name).trim(), ""]);
2029
- infoObjects = objects;
2030
- }
2031
- linker = makeLinker(index);
2032
- /* collect the corpus descriptions of the object instances for the
2033
- description popups (after the linker is in place, as the
2034
- pre-rendered descriptions expand their references, too) */
2035
- if (info !== null)
177
+ }
178
+ /* collect the corpus descriptions of the object instances for the
179
+ description popups (after the object table, as the
180
+ pre-rendered descriptions expand their references, too) */
2036
181
  for (const artifact of specification.artifacts)
2037
182
  collectSpec(artifact.objects, spec);
183
+ }
2038
184
  /* the artifact timestamps aggregate into the earliest creation
2039
185
  and the latest modification timestamp of the document */
2040
186
  const created = new Date(Math.min(...specification.artifacts.map((artifact) => artifact.created.getTime())));
@@ -2070,20 +216,5 @@ export const renderHtml = async (specification, config, tocPages, css, options =
2070
216
  doc: doc !== undefined ? safe(render("Doc", { Doc: { diagram: safe(doc) } })) : "",
2071
217
  artifacts: safe(artifacts.map((artifact) => renderArtifact(artifact)).join(""))
2072
218
  } });
2073
- }
2074
- finally {
2075
- /* release the per-document state (also on a rendering failure),
2076
- as it would otherwise retain the specification (and its
2077
- embedded images) until the next rendering */
2078
- linker = null;
2079
- anchors = null;
2080
- members = null;
2081
- schemas = null;
2082
- diagrams = null;
2083
- images = null;
2084
- coverages = null;
2085
- infoKeys = null;
2086
- infoObjects = null;
2087
- omits = null;
2088
- }
219
+ });
2089
220
  };