@rse/specbook 1.3.1 → 1.4.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +5 -2
- package/dst/specbook-api.js +7 -5
- package/dst/specbook-cli.js +0 -0
- package/dst/specbook-cmd-export.js +2 -2
- package/dst/specbook-coverage.js +1 -1
- package/dst/specbook-diagram.d.ts +1 -1
- package/dst/specbook-diagram.js +9 -3
- package/dst/specbook-export-ast.d.ts +1 -1
- package/dst/specbook-export-ast.js +21 -4
- package/dst/specbook-export-code.d.ts +14 -0
- package/dst/specbook-export-code.js +155 -0
- package/dst/specbook-export-common.d.ts +4 -2
- package/dst/specbook-export-common.js +110 -6
- package/dst/specbook-export-diagram.d.ts +15 -0
- package/dst/specbook-export-diagram.js +211 -0
- package/dst/specbook-export-html-fold.d.ts +20 -0
- package/dst/specbook-export-html-fold.js +458 -0
- package/dst/specbook-export-html-render.d.ts +51 -0
- package/dst/specbook-export-html-render.js +749 -0
- package/dst/specbook-export-html-search.js +6 -5
- package/dst/specbook-export-html-template.d.ts +21 -0
- package/dst/specbook-export-html-template.js +282 -0
- package/dst/specbook-export-html-widget.d.ts +12 -0
- package/dst/specbook-export-html-widget.js +605 -0
- package/dst/specbook-export-html.css +325 -50
- package/dst/specbook-export-html.js +62 -1931
- package/dst/specbook-export-image.d.ts +2 -0
- package/dst/specbook-export-image.js +161 -9
- package/dst/specbook-export-md.js +60 -12
- package/dst/specbook-format-schema.d.ts +1 -1
- package/dst/specbook-format-schema.js +1 -1
- package/dst/specbook-format-spec.d.ts +1 -0
- package/dst/specbook-format-spec.js +1 -0
- package/dst/specbook-format.md +169 -17
- package/dst/specbook-format.yaml +224 -23
- package/dst/specbook-ignore.js +2 -0
- package/dst/specbook-link.js +21 -5
- package/dst/specbook-parse-common.d.ts +24 -0
- package/dst/specbook-parse-common.js +137 -10
- package/dst/specbook-parse-semantic.js +2 -0
- package/dst/specbook-parse-syntax.js +81 -20
- package/dst/specbook-parse.js +1 -1
- package/dst/specbook-theme.d.ts +1 -0
- package/dst/specbook-theme.js +2 -0
- package/package.json +17 -11
|
@@ -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,
|
|
13
|
-
import {
|
|
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 {
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
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… (fuzzy matched keywords)" autocomplete="off" spellcheck="false"/>
|
|
45
|
-
<span class="search-clear" id="search-clear" title="clear search">×</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">⚭</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">⌘</span>{% endif %}{% if Object.paren %} <span class="anchor-paren">({{ Object.paren }})</span>{% endif %} <a href="#{{ Object.id }}"><span class="anchor-symbol">⚓︎</span></a></h{{ Object.level }}>
|
|
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">— <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">— <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">⌘</span>{% endif %}{% if row.paren %} <span class="anchor-paren">({{ row.paren }})</span>{% endif %} <a href="#{{ row.id }}"><span class="anchor-symbol">⚓︎</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 %} & 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">⌘</span>{% endif %}{% if row.paren %} <span class="anchor-paren">({{ row.paren }})</span>{% endif %} <a href="#{{ row.id }}"><span class="anchor-symbol">⚓︎</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": ["“", "”"], /* “...” */
|
|
1201
|
-
"nl": ["“", "”"], /* “...” */
|
|
1202
|
-
"de": ["„", "“"], /* „...“ */
|
|
1203
|
-
"cs": ["„", "“"], /* „...“ */
|
|
1204
|
-
"sk": ["„", "“"], /* „...“ */
|
|
1205
|
-
"pl": ["„", "”"], /* „...” */
|
|
1206
|
-
"fr": ["« ", " »"], /* « ... » */
|
|
1207
|
-
"it": ["«", "»"], /* «...» */
|
|
1208
|
-
"es": ["«", "»"], /* «...» */
|
|
1209
|
-
"pt": ["«", "»"], /* «...» */
|
|
1210
|
-
"no": ["«", "»"], /* «...» */
|
|
1211
|
-
"da": ["»", "«"], /* »...« */
|
|
1212
|
-
"sv": ["”", "”"], /* ”...” */
|
|
1213
|
-
"fi": ["”", "”"] /* ”...” */
|
|
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(/“/g, quotes[0]).replace(/”/g, quotes[1])
|
|
1229
|
-
.replace(/&(?![a-zA-Z][a-zA-Z0-9]*;|#\d+;|#x[0-9a-fA-F]+;)/g, "&") } });
|
|
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, "<").replace(/>/g, ">") };
|
|
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 "" 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\">⚭</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 = (
|
|
102
|
+
const wrapDiagrams = (svgs, schemas, levels) => {
|
|
1969
103
|
const blocks = new Map();
|
|
1970
|
-
for (const
|
|
1971
|
-
const
|
|
1972
|
-
|
|
1973
|
-
|
|
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"),
|
|
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
|
-
|
|
2004
|
-
|
|
2005
|
-
|
|
2006
|
-
|
|
2007
|
-
|
|
2008
|
-
|
|
2009
|
-
|
|
2010
|
-
|
|
2011
|
-
|
|
2012
|
-
|
|
2013
|
-
|
|
2014
|
-
|
|
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
|
|
2024
|
-
|
|
2025
|
-
|
|
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
|
-
|
|
2030
|
-
|
|
2031
|
-
|
|
2032
|
-
|
|
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
|
};
|