@rse/specbook 1.0.3 → 1.0.4
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 +106 -19
- package/dst/specbook-api.d.ts +29 -9
- package/dst/specbook-api.js +108 -33
- package/dst/specbook-cli.js +84 -25
- package/dst/specbook-cmd-describe.d.ts +7 -1
- package/dst/specbook-cmd-describe.js +56 -11
- package/dst/specbook-cmd-export.d.ts +2 -1
- package/dst/specbook-cmd-export.js +46 -3
- package/dst/specbook-cmd-lint.d.ts +2 -1
- package/dst/specbook-cmd-lint.js +12 -8
- package/dst/specbook-cmd-preview.d.ts +12 -0
- package/dst/specbook-cmd-preview.js +57 -0
- package/dst/specbook-config.d.ts +1 -1
- package/dst/specbook-config.js +187 -37
- package/dst/specbook-diagnostic.d.ts +2 -0
- package/dst/specbook-diagnostic.js +4 -2
- package/dst/specbook-diagram.d.ts +1 -0
- package/dst/specbook-diagram.js +0 -0
- package/dst/specbook-export-ast.js +4 -2
- package/dst/specbook-export-common.js +5 -3
- package/dst/specbook-export-html-search.js +118 -30
- package/dst/specbook-export-html.css +1140 -233
- package/dst/specbook-export-html.d.ts +1 -1
- package/dst/specbook-export-html.js +764 -120
- package/dst/specbook-export-md.js +4 -2
- package/dst/specbook-format-schema.d.ts +33 -1
- package/dst/specbook-format-schema.js +34 -2
- package/dst/specbook-format.md +193 -25
- package/dst/specbook-format.yaml +2291 -299
- package/dst/specbook-link.d.ts +2 -1
- package/dst/specbook-link.js +43 -3
- package/dst/specbook-mcp.js +19 -10
- package/dst/specbook-parse-common.d.ts +4 -2
- package/dst/specbook-parse-common.js +6 -3
- package/dst/specbook-parse-semantic.js +290 -30
- package/dst/specbook-parse-syntax.js +3 -1
- package/dst/specbook-parse-value.d.ts +1 -0
- package/dst/specbook-parse-value.js +17 -3
- package/dst/specbook-parse.js +1 -1
- package/package.json +14 -6
|
@@ -50,17 +50,31 @@ const templates = {
|
|
|
50
50
|
</script>
|
|
51
51
|
</head>
|
|
52
52
|
<body>
|
|
53
|
-
<div class="
|
|
53
|
+
{% 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 %}
|
|
54
|
+
<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>
|
|
55
|
+
{% 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 %}
|
|
56
|
+
<div class="search" id="search">
|
|
57
|
+
<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"><circle cx="10" cy="10" r="6.5"/><line x1="15" y1="15" x2="21" y2="21"/></svg></div>
|
|
58
|
+
<div class="search-field">
|
|
59
|
+
<input type="text" id="search-input" placeholder="Search…" autocomplete="off" spellcheck="false"/>
|
|
60
|
+
<span class="search-clear" id="search-clear" title="clear search">×</span>
|
|
61
|
+
</div>
|
|
62
|
+
</div>
|
|
63
|
+
{{ Document.tocpanel }}
|
|
54
64
|
{{ Document.titlepage }}
|
|
55
65
|
{{ Document.toc }}
|
|
66
|
+
{{ Document.doc }}
|
|
56
67
|
{{ Document.artifacts }}
|
|
57
68
|
{% if Document.search %}<script>{{ Document.search }}</script>{% endif %}
|
|
69
|
+
{% if Document.tocpanel %}<script>{{ Document.tocscript }}</script>{% endif %}
|
|
70
|
+
{% if Document.info %}<script>{{ Document.info }}</script>{% endif %}
|
|
71
|
+
{% if Document.realtime %}<script class="realtime">{{ Document.realtime }}</script>{% endif %}
|
|
58
72
|
</body>
|
|
59
73
|
</html>
|
|
60
74
|
`,
|
|
61
75
|
/* <TitlePage/> */
|
|
62
76
|
"TitlePage": textframe `
|
|
63
|
-
<div class="titlepage">
|
|
77
|
+
<div class="titlepage" id="titlepage">
|
|
64
78
|
{% if TitlePage.logo %}<div class="logo">{{ TitlePage.logo }}</div>{% endif %}
|
|
65
79
|
<div class="title">{{ TitlePage.title }}</div>
|
|
66
80
|
{% if TitlePage.subtitle %}<div class="subtitle">{{ TitlePage.subtitle }}</div>{% endif %}
|
|
@@ -72,23 +86,48 @@ const templates = {
|
|
|
72
86
|
</table>
|
|
73
87
|
{{ TitlePage.description }}
|
|
74
88
|
{{ TitlePage.properties }}
|
|
75
|
-
<div class="search">
|
|
76
|
-
<input type="text" id="search-input" placeholder="Search…" autocomplete="off" spellcheck="false"/>
|
|
77
|
-
<span class="search-clear" id="search-clear" title="clear search">×</span>
|
|
78
|
-
</div>
|
|
79
89
|
</div>
|
|
80
90
|
`,
|
|
81
91
|
/* <Toc/> */
|
|
82
92
|
"Toc": textframe `
|
|
83
|
-
<nav class="toc">
|
|
93
|
+
<nav class="toc" id="toc">
|
|
84
94
|
<h1>Table of Contents</h1>
|
|
85
95
|
<table>
|
|
86
96
|
{% for entry in Toc.entries %}
|
|
87
|
-
<tr><td><a href="#{{ entry.id }}"><span class="object-kind">{{ entry.kind }}:</span> {{ entry.name }} <span class="link-symbol">⚭</span></a></td>{% if entry.page %}<td class="page"><a href="#{{ entry.id }}">{{ entry.page }}</a></td>{% endif %}</tr>
|
|
97
|
+
<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 }}" data-info-path="{{ entry.specpath }}"{% 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>
|
|
88
98
|
{% endfor %}
|
|
89
99
|
</table>
|
|
90
100
|
</nav>
|
|
91
101
|
`,
|
|
102
|
+
/* <TocPanel/> */
|
|
103
|
+
"TocPanel": textframe `
|
|
104
|
+
<nav class="toc-panel">
|
|
105
|
+
<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>
|
|
106
|
+
<div class="toc-list">
|
|
107
|
+
<ul class="extra">
|
|
108
|
+
{% if TocPanel.title %}<li><a href="#titlepage"><span class="entry">Title</span></a></li>{% endif %}
|
|
109
|
+
<li><a href="#toc"><span class="entry">Table of Contents</span></a></li>
|
|
110
|
+
{% if TocPanel.doc %}<li><a href="#doc"><span class="entry">Diagram of Contents</span></a></li>{% endif %}
|
|
111
|
+
</ul>
|
|
112
|
+
{{ TocPanel.entries }}
|
|
113
|
+
</div>
|
|
114
|
+
</nav>
|
|
115
|
+
`,
|
|
116
|
+
/* <TocPanelEntries/> */
|
|
117
|
+
"TocPanelEntries": textframe `
|
|
118
|
+
<ul>
|
|
119
|
+
{% for entry in Entries %}
|
|
120
|
+
<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 }}" data-info-path="{{ entry.specpath }}"{% endif %}>{{ entry.name }}</span></span></a>{{ entry.childs }}</li>
|
|
121
|
+
{% endfor %}
|
|
122
|
+
</ul>
|
|
123
|
+
`,
|
|
124
|
+
/* <Doc/> */
|
|
125
|
+
"Doc": textframe `
|
|
126
|
+
<nav class="doc" id="doc">
|
|
127
|
+
<h1>Diagram of Contents</h1>
|
|
128
|
+
<div class="diagram">{{ Doc.diagram }}</div>
|
|
129
|
+
</nav>
|
|
130
|
+
`,
|
|
92
131
|
/* <Artifact/> */
|
|
93
132
|
"Artifact": textframe `
|
|
94
133
|
<article>
|
|
@@ -98,7 +137,7 @@ const templates = {
|
|
|
98
137
|
/* <Object/> */
|
|
99
138
|
"Object": textframe `
|
|
100
139
|
<section>
|
|
101
|
-
<h{{ Object.level }} id="{{ Object.id }}"><span class="object-kind">{{ Object.kind }}:</span> {{ Object.name }}{% 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 }}>
|
|
140
|
+
<h{{ Object.level }} id="{{ Object.id }}"><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 }}" data-info-path="{{ Object.specpath }}"{% 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 }}>
|
|
102
141
|
{{ Object.diagram }}
|
|
103
142
|
{{ Object.properties }}
|
|
104
143
|
{{ Object.description }}
|
|
@@ -109,7 +148,7 @@ const templates = {
|
|
|
109
148
|
"Properties": textframe `
|
|
110
149
|
<table class="props">
|
|
111
150
|
{% for property in Properties %}
|
|
112
|
-
<tr><td class="key property-name">{{ property.key }}</td><td>{{ property.value }}</td></tr>
|
|
151
|
+
<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>
|
|
113
152
|
{% endfor %}
|
|
114
153
|
</table>
|
|
115
154
|
`,
|
|
@@ -131,15 +170,15 @@ const templates = {
|
|
|
131
170
|
<table class="objects">
|
|
132
171
|
<thead>
|
|
133
172
|
<tr>
|
|
134
|
-
<th class="object-kind">{{ Table.head }}</th>
|
|
135
|
-
{%- for key in Table.keys %}<th class="property-name">{{ key }}</th>{% endfor %}
|
|
173
|
+
<th class="object-kind"><span{% if Table.info %} data-info="{{ Table.info }}" data-info-path="{{ Table.infopath }}"{% endif %}>{{ Table.head }}</span></th>
|
|
174
|
+
{%- 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 %}
|
|
136
175
|
{%- if Table.desc %}<th class="description" style="width: {{ Table.width }}%">Description</th>{% endif %}
|
|
137
176
|
</tr>
|
|
138
177
|
</thead>
|
|
139
178
|
<tbody>
|
|
140
179
|
{% for row in Table.rows %}
|
|
141
|
-
<tr id="{{ row.id }}">
|
|
142
|
-
<td>{{ row.name }}{% 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>
|
|
180
|
+
<tr id="{{ row.id }}"{% if row.even %} class="even"{% endif %}>
|
|
181
|
+
<td><span{% if row.spec %} data-info-spec="{{ row.spec }}" data-info-path="{{ row.specpath }}"{% 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>
|
|
143
182
|
{%- for value in row.values %}<td>{{ value }}</td>{% endfor %}
|
|
144
183
|
{%- if Table.desc %}<td>{{ row.description }}</td>{% endif %}
|
|
145
184
|
</tr>
|
|
@@ -152,18 +191,18 @@ const templates = {
|
|
|
152
191
|
<table class="objects">
|
|
153
192
|
<thead>
|
|
154
193
|
<tr>
|
|
155
|
-
<th class="object-kind" style="width: {{ Table.width }}%">{{ Table.head }}</th>
|
|
194
|
+
<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>
|
|
156
195
|
<th class="description">Properties</th>
|
|
157
196
|
</tr>
|
|
158
197
|
</thead>
|
|
159
198
|
<tbody>
|
|
160
199
|
{% for row in Table.rows %}
|
|
161
|
-
<tr id="{{ row.id }}">
|
|
162
|
-
<td>{{ row.name }}{% 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>
|
|
200
|
+
<tr id="{{ row.id }}"{% if row.even %} class="even"{% endif %}>
|
|
201
|
+
<td><span{% if row.spec %} data-info-spec="{{ row.spec }}" data-info-path="{{ row.specpath }}"{% 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>
|
|
163
202
|
<td class="chunks">
|
|
164
203
|
<table class="chunks">
|
|
165
204
|
{% for chunk in row.chunks %}
|
|
166
|
-
<tr>{% for cell in chunk %}<th{% if not cell.desc %} class="property-name"{% endif %}{% if cell.span > 1 %} colspan="{{ cell.span }}"{% endif %}>{{ cell.key }}</th>{% endfor %}</tr>
|
|
205
|
+
<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.specpath }}" data-info-prop="{{ cell.key }}"{% endif %}>{{ cell.key }}</span></th>{% endfor %}</tr>
|
|
167
206
|
<tr>{% for cell in chunk %}<td{% if cell.span > 1 %} colspan="{{ cell.span }}"{% endif %}>{{ cell.value }}</td>{% endfor %}</tr>
|
|
168
207
|
{% endfor %}
|
|
169
208
|
</table>
|
|
@@ -174,6 +213,368 @@ const templates = {
|
|
|
174
213
|
</table>
|
|
175
214
|
`
|
|
176
215
|
};
|
|
216
|
+
/* the client-side script of the live preview: it connects back to the
|
|
217
|
+
own page over WebSocket (the URL scheme "http"/"https" replaced with
|
|
218
|
+
"ws"/"wss"), re-connects every second after a lost connection, and
|
|
219
|
+
updates the page on a received "RELOAD" command as well as on every
|
|
220
|
+
re-established connection (as the server was restarted meanwhile).
|
|
221
|
+
The update fetches the fresh page and replaces the document in place
|
|
222
|
+
-- instead of reloading the page -- so the scroll position and the
|
|
223
|
+
theme choice survive: the title, the stylesheet (if changed), and
|
|
224
|
+
the body are swapped, and the scripts of the fresh body re-executed
|
|
225
|
+
through clones (as parsed scripts never execute), except this
|
|
226
|
+
realtime script itself, whose connection stays alive. The status
|
|
227
|
+
icon (re-created by every body swap) reflects the connection state
|
|
228
|
+
and blinks for 2s after every update, unless the connection is
|
|
229
|
+
lost meanwhile */
|
|
230
|
+
const realtimeScript = textframe `
|
|
231
|
+
(function () {
|
|
232
|
+
const url = window.location.protocol.replace(/^http/, "ws") + "//" +
|
|
233
|
+
window.location.host + window.location.pathname
|
|
234
|
+
const status = (state) => {
|
|
235
|
+
const icon = document.querySelector("div.realtime-status")
|
|
236
|
+
if (icon !== null)
|
|
237
|
+
icon.className = "realtime-status " + state
|
|
238
|
+
}
|
|
239
|
+
let blink = 0
|
|
240
|
+
const update = async () => {
|
|
241
|
+
const response = await fetch(window.location.pathname, { cache: "no-store" })
|
|
242
|
+
if (!response.ok)
|
|
243
|
+
return
|
|
244
|
+
const doc = new DOMParser().parseFromString(await response.text(), "text/html")
|
|
245
|
+
const x = window.scrollX
|
|
246
|
+
const y = window.scrollY
|
|
247
|
+
document.title = doc.title
|
|
248
|
+
const style = document.head.querySelector("style")
|
|
249
|
+
const fresh = doc.head.querySelector("style")
|
|
250
|
+
if (style !== null && fresh !== null && style.textContent !== fresh.textContent)
|
|
251
|
+
style.replaceWith(fresh)
|
|
252
|
+
document.body.replaceWith(doc.body)
|
|
253
|
+
for (const script of document.body.querySelectorAll("script:not(.realtime)")) {
|
|
254
|
+
const clone = document.createElement("script")
|
|
255
|
+
clone.textContent = script.textContent
|
|
256
|
+
script.replaceWith(clone)
|
|
257
|
+
}
|
|
258
|
+
window.scrollTo(x, y)
|
|
259
|
+
status("reloaded")
|
|
260
|
+
clearTimeout(blink)
|
|
261
|
+
blink = setTimeout(() => { status("connected") }, 2000)
|
|
262
|
+
}
|
|
263
|
+
let lost = false
|
|
264
|
+
const connect = () => {
|
|
265
|
+
const ws = new WebSocket(url)
|
|
266
|
+
ws.onopen = () => {
|
|
267
|
+
status("connected")
|
|
268
|
+
if (lost) {
|
|
269
|
+
console.log("specbook: live preview connection re-established")
|
|
270
|
+
lost = false
|
|
271
|
+
update()
|
|
272
|
+
}
|
|
273
|
+
}
|
|
274
|
+
ws.onmessage = (event) => {
|
|
275
|
+
if (event.data === "RELOAD")
|
|
276
|
+
update()
|
|
277
|
+
}
|
|
278
|
+
ws.onclose = () => {
|
|
279
|
+
clearTimeout(blink)
|
|
280
|
+
status("disconnected")
|
|
281
|
+
if (!lost)
|
|
282
|
+
console.log("specbook: live preview connection lost")
|
|
283
|
+
lost = true
|
|
284
|
+
setTimeout(connect, 1000)
|
|
285
|
+
}
|
|
286
|
+
}
|
|
287
|
+
connect()
|
|
288
|
+
})()
|
|
289
|
+
`;
|
|
290
|
+
/* the client-side script of the table of contents side panel: it
|
|
291
|
+
slides the panel out of and back into the viewport edge (closing it
|
|
292
|
+
also on a jump, on "Escape", and on a click outside), remembers the
|
|
293
|
+
open state across page loads, and marks the active entry (the first
|
|
294
|
+
heading visible in the viewport, or, without any, the last heading
|
|
295
|
+
above the viewport, i.e. the section scrolled into), scrolling it
|
|
296
|
+
into the middle of the panel whenever it changes and is not visible
|
|
297
|
+
in the panel already (even while the panel is slid in, so it opens
|
|
298
|
+
at the right position), plus the anchored entry (the one the URL
|
|
299
|
+
hash currently addresses, which a jump synchronizes with the active
|
|
300
|
+
one, while a subsequent scrolling moves the active one only). The
|
|
301
|
+
script runs at the end of the body, as the headings it targets
|
|
302
|
+
have to exist already, and a live preview body swap replaces the
|
|
303
|
+
body-bound listeners along with the panel, while the document- and
|
|
304
|
+
window-bound ones (the outside click, as the body does not span
|
|
305
|
+
the viewport margins, plus scroll and hash change) retire
|
|
306
|
+
themselves */
|
|
307
|
+
const tocPanelScript = textframe `
|
|
308
|
+
(function () {
|
|
309
|
+
const panel = document.querySelector("nav.toc-panel")
|
|
310
|
+
const list = panel.querySelector("div.toc-list")
|
|
311
|
+
const links = Array.from(panel.querySelectorAll("a"))
|
|
312
|
+
const targets = links.map((a) => document.getElementById(decodeURIComponent(a.hash.slice(1))))
|
|
313
|
+
let active = null
|
|
314
|
+
const reveal = () => {
|
|
315
|
+
const top = active.getBoundingClientRect().top - list.getBoundingClientRect().top
|
|
316
|
+
if (top < 0 || top + active.offsetHeight > list.clientHeight)
|
|
317
|
+
list.scrollTop += top - (list.clientHeight - active.offsetHeight) / 2
|
|
318
|
+
}
|
|
319
|
+
const spy = () => {
|
|
320
|
+
if (!panel.isConnected) {
|
|
321
|
+
window.removeEventListener("scroll", spy)
|
|
322
|
+
return
|
|
323
|
+
}
|
|
324
|
+
let current = null
|
|
325
|
+
for (let i = 0; i < targets.length; i++) {
|
|
326
|
+
if (targets[i] === null)
|
|
327
|
+
continue
|
|
328
|
+
const top = targets[i].getBoundingClientRect().top
|
|
329
|
+
if (top >= -1 && top < window.innerHeight) {
|
|
330
|
+
current = links[i]
|
|
331
|
+
break
|
|
332
|
+
}
|
|
333
|
+
if (top < -1)
|
|
334
|
+
current = links[i]
|
|
335
|
+
}
|
|
336
|
+
if (current === active)
|
|
337
|
+
return
|
|
338
|
+
if (active !== null)
|
|
339
|
+
active.classList.remove("active")
|
|
340
|
+
active = current
|
|
341
|
+
if (active !== null) {
|
|
342
|
+
active.classList.add("active")
|
|
343
|
+
reveal()
|
|
344
|
+
}
|
|
345
|
+
}
|
|
346
|
+
let anchored = null
|
|
347
|
+
const anchor = () => {
|
|
348
|
+
if (!panel.isConnected) {
|
|
349
|
+
window.removeEventListener("hashchange", anchor)
|
|
350
|
+
return
|
|
351
|
+
}
|
|
352
|
+
const current = links.find((a) => a.hash === window.location.hash) ?? null
|
|
353
|
+
if (current === anchored)
|
|
354
|
+
return
|
|
355
|
+
if (anchored !== null)
|
|
356
|
+
anchored.classList.remove("anchored")
|
|
357
|
+
anchored = current
|
|
358
|
+
if (anchored !== null)
|
|
359
|
+
anchored.classList.add("anchored")
|
|
360
|
+
}
|
|
361
|
+
const toggle = (open) => {
|
|
362
|
+
panel.classList.toggle("open", open)
|
|
363
|
+
try { localStorage.setItem("specbook-toc", panel.classList.contains("open") ? "open" : "closed") }
|
|
364
|
+
catch { /* an inaccessible storage just loses the state */ }
|
|
365
|
+
}
|
|
366
|
+
try { if (localStorage.getItem("specbook-toc") === "open") panel.classList.add("open") }
|
|
367
|
+
catch { /* an inaccessible storage just means no stored state */ }
|
|
368
|
+
panel.querySelector("div.toc-tab").addEventListener("click", () => { toggle() })
|
|
369
|
+
links.forEach((a) => { a.addEventListener("click", () => { toggle(false) }) })
|
|
370
|
+
document.body.addEventListener("keydown", (event) => { if (event.key === "Escape") toggle(false) })
|
|
371
|
+
const outside = (event) => {
|
|
372
|
+
if (!panel.isConnected)
|
|
373
|
+
document.removeEventListener("click", outside)
|
|
374
|
+
else if (!panel.contains(event.target)
|
|
375
|
+
&& event.target.closest("div.search") === null
|
|
376
|
+
&& event.target.closest("div.theme-switch") === null
|
|
377
|
+
&& event.target.closest("div.info-switch") === null
|
|
378
|
+
&& event.target.closest("div.realtime-status") === null)
|
|
379
|
+
toggle(false)
|
|
380
|
+
}
|
|
381
|
+
document.addEventListener("click", outside)
|
|
382
|
+
window.addEventListener("scroll", spy, { passive: true })
|
|
383
|
+
window.addEventListener("hashchange", anchor)
|
|
384
|
+
spy()
|
|
385
|
+
anchor()
|
|
386
|
+
})()
|
|
387
|
+
`;
|
|
388
|
+
/* the client-side script of the description popups: the info tab
|
|
389
|
+
toggles the popups on and off (default), persisting the choice
|
|
390
|
+
across page loads, and while they are on, hovering an element
|
|
391
|
+
carrying a "data-info" key shows the schema description of its
|
|
392
|
+
object kind (or, with "data-info-prop", of its property), while
|
|
393
|
+
hovering an element carrying a "data-info-spec" key (an object
|
|
394
|
+
name) shows the corpus description of the object instance, in a
|
|
395
|
+
single shared popup -- a hovered diagram node box resolves through
|
|
396
|
+
its hyperlinked object anchor to the instance popup attributes of
|
|
397
|
+
the object's heading name or table row name -- opened only after
|
|
398
|
+
the mouse rested 400ms on the element (so mere mouse movements
|
|
399
|
+
across the page pop nothing up), titled with the "data-info-path"
|
|
400
|
+
object path (one filled-circle "KIND: Name" segment per level,
|
|
401
|
+
arbitrarily deep and separated by the icons alone, ending for
|
|
402
|
+
a kind in the bare kind and extended for a property with the
|
|
403
|
+
not-filled-circle property name, styled as in the document
|
|
404
|
+
content: the kinds and the property bold in the path color, the
|
|
405
|
+
names bold in the description color)
|
|
406
|
+
and fed from the injected INFO
|
|
407
|
+
map of pre-rendered schema description HTML or SPEC map of
|
|
408
|
+
pre-rendered corpus description HTML (a missing entry pops up
|
|
409
|
+
the title path alone), the description led by a bold "Schema:"
|
|
410
|
+
or "Specification:" label naming its origin (a corpus-fed
|
|
411
|
+
instance popup additionally carrying the page background colors
|
|
412
|
+
instead of the accent), where the popup is
|
|
413
|
+
capped at 40% viewport width and attached above or below the
|
|
414
|
+
hovered element, whichever side offers more viewport space; the
|
|
415
|
+
script runs at the end of the body, so a live preview body swap
|
|
416
|
+
replaces the popup element and the body-bound listeners along
|
|
417
|
+
with the re-executed script */
|
|
418
|
+
const infoPopupScript = textframe `
|
|
419
|
+
(function (INFO, SPEC) {
|
|
420
|
+
const popup = document.createElement("div")
|
|
421
|
+
popup.className = "info-popup"
|
|
422
|
+
document.body.appendChild(popup)
|
|
423
|
+
let enabled = false
|
|
424
|
+
try { enabled = localStorage.getItem("specbook-info") === "on" }
|
|
425
|
+
catch { /* an inaccessible storage just means no stored choice */ }
|
|
426
|
+
let current = null
|
|
427
|
+
let pending = null
|
|
428
|
+
let pendingId = 0
|
|
429
|
+
const cancel = () => {
|
|
430
|
+
clearTimeout(pendingId)
|
|
431
|
+
pending = null
|
|
432
|
+
}
|
|
433
|
+
const hide = () => {
|
|
434
|
+
popup.classList.remove("open")
|
|
435
|
+
current = null
|
|
436
|
+
}
|
|
437
|
+
const apply = () => {
|
|
438
|
+
document.body.classList.toggle("info-on", enabled)
|
|
439
|
+
if (!enabled) {
|
|
440
|
+
cancel()
|
|
441
|
+
hide()
|
|
442
|
+
}
|
|
443
|
+
}
|
|
444
|
+
apply()
|
|
445
|
+
document.querySelector("div.info-switch").addEventListener("click", () => {
|
|
446
|
+
enabled = !enabled
|
|
447
|
+
try { localStorage.setItem("specbook-info", enabled ? "on" : "off") }
|
|
448
|
+
catch { /* an inaccessible storage just loses the choice */ }
|
|
449
|
+
apply()
|
|
450
|
+
})
|
|
451
|
+
const show = (el) => {
|
|
452
|
+
/* a diagram node box carries no popup attributes itself, but
|
|
453
|
+
hyperlinks its object, whose heading name or table row name
|
|
454
|
+
carries the instance popup attributes */
|
|
455
|
+
let source = el
|
|
456
|
+
if (!el.hasAttribute("data-info") && !el.hasAttribute("data-info-spec")) {
|
|
457
|
+
const target = document.getElementById(
|
|
458
|
+
decodeURIComponent((el.getAttribute("href") ?? "").replace(/^#/, "")))
|
|
459
|
+
source = target === null ? null :
|
|
460
|
+
(target.hasAttribute("data-info-spec") ? target : target.querySelector("[data-info-spec]"))
|
|
461
|
+
if (source === null)
|
|
462
|
+
return
|
|
463
|
+
}
|
|
464
|
+
current = el
|
|
465
|
+
const key = source.getAttribute("data-info")
|
|
466
|
+
const spec = source.getAttribute("data-info-spec")
|
|
467
|
+
const prop = source.getAttribute("data-info-prop")
|
|
468
|
+
popup.classList.toggle("spec", spec !== null)
|
|
469
|
+
const title = document.createElement("div")
|
|
470
|
+
title.className = "info-title"
|
|
471
|
+
|
|
472
|
+
/* create a pointer, separating the title path segments */
|
|
473
|
+
const pointer = () => {
|
|
474
|
+
const span = document.createElement("span")
|
|
475
|
+
span.className = "info-pointer"
|
|
476
|
+
span.textContent = "▷"
|
|
477
|
+
return span
|
|
478
|
+
}
|
|
479
|
+
const segments = (source.getAttribute("data-info-path") ?? key ?? spec).split(" . ")
|
|
480
|
+
for (const [ i, segment ] of segments.entries()) {
|
|
481
|
+
if (i > 0)
|
|
482
|
+
title.appendChild(pointer())
|
|
483
|
+
const path = document.createElement("span")
|
|
484
|
+
path.className = "info-object"
|
|
485
|
+
|
|
486
|
+
/* a segment renders as in the document content (the kind
|
|
487
|
+
bold in the path color, the name semi-bold in the
|
|
488
|
+
description color), where a segment without a kind
|
|
489
|
+
prefix is the kind-only ending of a kind popup or the
|
|
490
|
+
name of a kind-less object */
|
|
491
|
+
const m = /^([^\s:]+): (.*)$/.exec(segment)
|
|
492
|
+
const bare = i === segments.length - 1 && spec === null && prop === null
|
|
493
|
+
const name = document.createElement("span")
|
|
494
|
+
name.className = "info-name"
|
|
495
|
+
if (m !== null || bare) {
|
|
496
|
+
const kind = document.createElement("span")
|
|
497
|
+
kind.className = "info-kind"
|
|
498
|
+
kind.textContent = m !== null ? m[1] + ":" : segment
|
|
499
|
+
path.appendChild(kind)
|
|
500
|
+
if (m !== null) {
|
|
501
|
+
name.textContent = m[2]
|
|
502
|
+
path.append(" ", name)
|
|
503
|
+
}
|
|
504
|
+
}
|
|
505
|
+
else {
|
|
506
|
+
name.textContent = segment
|
|
507
|
+
path.appendChild(name)
|
|
508
|
+
}
|
|
509
|
+
title.appendChild(path)
|
|
510
|
+
}
|
|
511
|
+
if (prop !== null) {
|
|
512
|
+
title.appendChild(pointer())
|
|
513
|
+
const name = document.createElement("span")
|
|
514
|
+
name.className = "info-property"
|
|
515
|
+
name.textContent = prop
|
|
516
|
+
title.appendChild(name)
|
|
517
|
+
}
|
|
518
|
+
popup.replaceChildren(title)
|
|
519
|
+
const entry = INFO[key]
|
|
520
|
+
const desc = prop !== null ? entry?.p?.[prop] :
|
|
521
|
+
(spec !== null ? SPEC[spec] : entry?.d)
|
|
522
|
+
if (desc !== undefined) {
|
|
523
|
+
const text = document.createElement("div")
|
|
524
|
+
text.className = "info-desc"
|
|
525
|
+
text.innerHTML = desc
|
|
526
|
+
|
|
527
|
+
/* the origin label leads the description inline, so it is
|
|
528
|
+
hoisted into the leading paragraph (if there is one),
|
|
529
|
+
with its prefix word in a span of its own for the
|
|
530
|
+
rounded box styling */
|
|
531
|
+
const label = document.createElement("strong")
|
|
532
|
+
label.className = "info-label"
|
|
533
|
+
const origin = document.createElement("span")
|
|
534
|
+
origin.className = "info-origin"
|
|
535
|
+
origin.textContent = spec !== null ? "Specification" : "Schema"
|
|
536
|
+
label.append(origin, " ")
|
|
537
|
+
const lead = text.firstElementChild
|
|
538
|
+
if (lead !== null && lead.tagName === "P")
|
|
539
|
+
lead.insertBefore(label, lead.firstChild)
|
|
540
|
+
else
|
|
541
|
+
text.insertBefore(label, text.firstChild)
|
|
542
|
+
popup.appendChild(text)
|
|
543
|
+
}
|
|
544
|
+
|
|
545
|
+
/* measure the popup at the viewport origin first, as a
|
|
546
|
+
position near the right or bottom edge would clamp it */
|
|
547
|
+
popup.style.left = "0px"
|
|
548
|
+
popup.style.top = "0px"
|
|
549
|
+
popup.classList.add("open")
|
|
550
|
+
const rect = el.getBoundingClientRect()
|
|
551
|
+
const left = Math.max(8, Math.min(rect.left, window.innerWidth - popup.offsetWidth - 8))
|
|
552
|
+
const above = rect.top > window.innerHeight - rect.bottom
|
|
553
|
+
popup.style.left = left + "px"
|
|
554
|
+
popup.style.top = (above ? rect.top - popup.offsetHeight - 6 : rect.bottom + 6) + "px"
|
|
555
|
+
}
|
|
556
|
+
const targets = "[data-info], [data-info-spec], div.diagram a"
|
|
557
|
+
document.body.addEventListener("mouseover", (event) => {
|
|
558
|
+
if (!enabled)
|
|
559
|
+
return
|
|
560
|
+
const el = event.target.closest(targets)
|
|
561
|
+
if (el === null || el === current || el === pending)
|
|
562
|
+
return
|
|
563
|
+
cancel()
|
|
564
|
+
pending = el
|
|
565
|
+
pendingId = setTimeout(() => { pending = null; show(el) }, 400)
|
|
566
|
+
})
|
|
567
|
+
document.body.addEventListener("mouseout", (event) => {
|
|
568
|
+
const el = event.target.closest(targets)
|
|
569
|
+
if (el === null || (event.relatedTarget instanceof Node && el.contains(event.relatedTarget)))
|
|
570
|
+
return
|
|
571
|
+
if (el === pending)
|
|
572
|
+
cancel()
|
|
573
|
+
if (el === current)
|
|
574
|
+
hide()
|
|
575
|
+
})
|
|
576
|
+
})(@INFO@, @SPEC@)
|
|
577
|
+
`;
|
|
177
578
|
/* ==== Rendering ==== */
|
|
178
579
|
/* the per-language double quote styles for the smart typography
|
|
179
580
|
(single quotes stay curly, as smartypants cannot distinguish a
|
|
@@ -230,24 +631,105 @@ const safe = (html) => new nunjucks.runtime.SafeString(html);
|
|
|
230
631
|
/* render one of the built-in Nunjucks templates with a context */
|
|
231
632
|
const render = (name, context) => env.renderString(templates[name], context);
|
|
232
633
|
/* the active per-document reference expander, fully-qualified
|
|
233
|
-
anchor paths,
|
|
234
|
-
|
|
634
|
+
anchor paths, member-carrying property value constraints, object
|
|
635
|
+
schema nodes, pre-rendered diagram SVGs, description popup keys
|
|
636
|
+
of the schema nodes, and object parents of the description popup
|
|
637
|
+
title paths (all set during HTML rendering) */
|
|
235
638
|
let linker = null;
|
|
236
639
|
let anchors = null;
|
|
237
640
|
let members = null;
|
|
238
641
|
let schemas = null;
|
|
239
642
|
let diagrams = null;
|
|
643
|
+
let infoKeys = null;
|
|
644
|
+
let infoParents = null;
|
|
645
|
+
/* the object whose texts are currently rendered, scoping the
|
|
646
|
+
resolution of the references inside them (nearest object wins),
|
|
647
|
+
established around every object rendering and restored afterwards */
|
|
648
|
+
let scope = null;
|
|
649
|
+
const scoped = (object, render) => {
|
|
650
|
+
const outer = scope;
|
|
651
|
+
scope = object;
|
|
652
|
+
try {
|
|
653
|
+
return render();
|
|
654
|
+
}
|
|
655
|
+
finally {
|
|
656
|
+
scope = outer;
|
|
657
|
+
}
|
|
658
|
+
};
|
|
240
659
|
/* the cache of the pre-rendered diagram SVGs, keyed by specification,
|
|
241
660
|
as the PDF export renders the very same document multiple times */
|
|
242
661
|
const diagramCache = new WeakMap();
|
|
243
662
|
/* determine the fully-qualified anchor path of an object */
|
|
244
663
|
const anchorOf = (object) => anchors?.get(object) ?? object.id;
|
|
664
|
+
/* determine the description popup key of an object (the qualified
|
|
665
|
+
title path of its schema node) */
|
|
666
|
+
const infoKeyOf = (object) => {
|
|
667
|
+
const schema = schemas?.get(object);
|
|
668
|
+
return schema !== undefined ? infoKeys?.get(schema) : undefined;
|
|
669
|
+
};
|
|
670
|
+
/* determine the description popup title path of an object: the chain
|
|
671
|
+
of "KIND: Name" segments from the artifact down to the object
|
|
672
|
+
(arbitrarily deep), where "named = false" keeps the last segment
|
|
673
|
+
kind-only, as the kind popups (and the group headers of the compact
|
|
674
|
+
tables) describe the object class instead of the single instance */
|
|
675
|
+
const infoPathOf = (object, named = true) => {
|
|
676
|
+
const parent = infoParents?.get(object);
|
|
677
|
+
const prefix = parent !== undefined ? `${infoPathOf(parent)} . ` : "";
|
|
678
|
+
const name = named ? plainText(object.name).trim() : "";
|
|
679
|
+
const segment = name !== "" && object.kind !== "" ? `${object.kind}: ${name}` :
|
|
680
|
+
(name !== "" ? name : object.kind);
|
|
681
|
+
return prefix + segment;
|
|
682
|
+
};
|
|
683
|
+
/* render the schema description popup key and kind-ending title path
|
|
684
|
+
of an object as "data-info" attributes for the manually assembled
|
|
685
|
+
hyperlink markup (the kind popup of the full hyperlinks) */
|
|
686
|
+
const infoAttr = (object) => {
|
|
687
|
+
const key = infoKeyOf(object);
|
|
688
|
+
return key !== undefined ?
|
|
689
|
+
` data-info="${escapeHtml(key)}" data-info-path="${escapeHtml(infoPathOf(object, false))}"` : "";
|
|
690
|
+
};
|
|
691
|
+
/* render the corpus description popup key (the fully-qualified anchor
|
|
692
|
+
path) and name-ending title path of an object as "data-info-spec"
|
|
693
|
+
attributes for the manually assembled hyperlink markup (the
|
|
694
|
+
instance popup of the compact and full hyperlinks) */
|
|
695
|
+
const specAttr = (object) => infoParents !== null ?
|
|
696
|
+
` data-info-spec="${escapeHtml(anchorOf(object))}" data-info-path="${escapeHtml(infoPathOf(object))}"` : "";
|
|
697
|
+
/* collect the description popup map and the qualified title paths of
|
|
698
|
+
the schema nodes (the artifact kind leading its name, the nested
|
|
699
|
+
kinds appended), with the description Markdown of the objects and
|
|
700
|
+
properties pre-rendered to HTML */
|
|
701
|
+
const collectInfo = (nodes, prefix, keys, info) => {
|
|
702
|
+
for (const schema of nodes) {
|
|
703
|
+
const path = prefix !== "" ? `${prefix} . ${schema.kind}` :
|
|
704
|
+
(schema.name !== undefined ? `${schema.kind}: ${schema.name}` : schema.kind);
|
|
705
|
+
keys.set(schema, path);
|
|
706
|
+
const entry = {};
|
|
707
|
+
if (schema.desc !== undefined)
|
|
708
|
+
entry.d = marked.parse(schema.desc, { async: false }).trim();
|
|
709
|
+
for (const property of schema.props ?? []) {
|
|
710
|
+
if (property.desc === undefined)
|
|
711
|
+
continue;
|
|
712
|
+
entry.p ??= {};
|
|
713
|
+
entry.p[property.name] = marked.parse(property.desc, { async: false }).trim();
|
|
714
|
+
}
|
|
715
|
+
if (entry.d !== undefined || entry.p !== undefined)
|
|
716
|
+
info[path] = entry;
|
|
717
|
+
collectInfo(schema.childs ?? [], path, keys, info);
|
|
718
|
+
}
|
|
719
|
+
};
|
|
720
|
+
/* inject the description popup maps into their client-side script ("<"
|
|
721
|
+
escaped, so no embedded HTML can close the surrounding <script>
|
|
722
|
+
element) */
|
|
723
|
+
const infoScript = (info, spec) => infoPopupScript
|
|
724
|
+
.replace("@INFO@", () => JSON.stringify(info).replace(/</g, "\\u003c"))
|
|
725
|
+
.replace("@SPEC@", () => JSON.stringify(spec).replace(/</g, "\\u003c"));
|
|
245
726
|
/* expand the inline Markdown of a text (code spans, emphasis, etc.),
|
|
246
|
-
with Wiki-style references expanded upfront
|
|
247
|
-
|
|
727
|
+
with Wiki-style references expanded upfront (in their compact form
|
|
728
|
+
for prose, i.e. descriptions, in their full form otherwise) */
|
|
729
|
+
const inline = (text, compact = false) => safe(marked.parseInline(linker !== null ? linker(text, compact) : text, { async: false }));
|
|
248
730
|
/* expand the full Markdown of a text, keeping its block-level
|
|
249
731
|
constructs (lists, quotes, code blocks, tables) intact */
|
|
250
|
-
const block = (text) => safe(marked.parse(linker !== null ? linker(text) : text, { async: false }));
|
|
732
|
+
const block = (text, compact = false) => safe(marked.parse(linker !== null ? linker(text, compact) : text, { async: false }));
|
|
251
733
|
/* check whether a text carries any block-level Markdown, i.e. is
|
|
252
734
|
anything other than the single paragraph a description usually is */
|
|
253
735
|
const isBlock = (text) => {
|
|
@@ -303,14 +785,34 @@ const renderDescription = (description) => {
|
|
|
303
785
|
const blocked = isBlock(text);
|
|
304
786
|
return render("Description", { Description: {
|
|
305
787
|
block: blocked,
|
|
306
|
-
description: text !== "" ? (blocked ? block(text) : inline(text)) : "",
|
|
788
|
+
description: text !== "" ? (blocked ? block(text, true) : inline(text, true)) : "",
|
|
307
789
|
rationale: description.rationale !== undefined ?
|
|
308
|
-
inline(description.rationale) : undefined,
|
|
790
|
+
inline(description.rationale, true) : undefined,
|
|
309
791
|
embeddings
|
|
310
792
|
} });
|
|
311
793
|
};
|
|
312
|
-
/* collect the
|
|
313
|
-
|
|
794
|
+
/* collect the description popup map of the object instances: the
|
|
795
|
+
corpus description Markdown of every object, pre-rendered to HTML
|
|
796
|
+
and keyed by the fully-qualified anchor path (the embedded images
|
|
797
|
+
and the rationale are left out, as the popup shows the prose alone) */
|
|
798
|
+
const collectSpec = (objects, spec) => {
|
|
799
|
+
for (const object of objects) {
|
|
800
|
+
const text = (object.description?.description ?? "")
|
|
801
|
+
.replace(embeddingMarkup, (markup, _alt, reference) => embeddingMimeType(reference.trim()) !== undefined ? "" : markup)
|
|
802
|
+
.trim();
|
|
803
|
+
if (text !== "")
|
|
804
|
+
spec[anchorOf(object)] = scoped(object, () => block(text, true).toString().trim());
|
|
805
|
+
collectSpec(object.childs, spec);
|
|
806
|
+
}
|
|
807
|
+
};
|
|
808
|
+
/* the literal members of an "enum(...)"/"tags(...)" expression, or
|
|
809
|
+
of the "enum(...)"/"tags(...)" alternatives of a "list(...)" one
|
|
810
|
+
(empty for every other expression) */
|
|
811
|
+
const literalMembers = (expr) => expr.kind === "enum" || expr.kind === "tags" ? expr.members :
|
|
812
|
+
expr.kind === "list" ? expr.alternatives.flatMap(literalMembers) : [];
|
|
813
|
+
/* collect the properties of the schema configuration constrained by
|
|
814
|
+
literal members (an "enum(...)", a "tags(...)", or a "list(...)"
|
|
815
|
+
with such alternatives), keyed by object kind and property name */
|
|
314
816
|
const collectMembers = (nodes, result) => {
|
|
315
817
|
for (const schema of nodes) {
|
|
316
818
|
for (const property of schema.props ?? []) {
|
|
@@ -318,8 +820,8 @@ const collectMembers = (nodes, result) => {
|
|
|
318
820
|
continue;
|
|
319
821
|
try {
|
|
320
822
|
const expr = compileValueExpr(property.value);
|
|
321
|
-
if (expr.
|
|
322
|
-
result.set(`${schema.kind}
|
|
823
|
+
if (literalMembers(expr).length > 0)
|
|
824
|
+
result.set(`${schema.kind} ${property.name}`, expr);
|
|
323
825
|
}
|
|
324
826
|
catch {
|
|
325
827
|
/* an invalid expression is the concern of lint */
|
|
@@ -331,70 +833,85 @@ const collectMembers = (nodes, result) => {
|
|
|
331
833
|
};
|
|
332
834
|
/* render a property value, badging the individual members of an
|
|
333
835
|
"enum(...)" (a single member) or "tags(...)" (a member set) value
|
|
334
|
-
and
|
|
335
|
-
|
|
336
|
-
|
|
337
|
-
|
|
836
|
+
and the literal member items of a "list(...)" value (the other items,
|
|
837
|
+
like references, stay prose), moving its image embeddings behind the
|
|
838
|
+
value, rendered from the embedded image contents (as for a
|
|
839
|
+
description), not from the markup; an absent property renders as the
|
|
840
|
+
marker telling it apart from a property given with an empty value */
|
|
841
|
+
const inlineValue = (kind, property) => {
|
|
842
|
+
if (property === undefined)
|
|
843
|
+
return safe("<span class=\"value-absent\"></span>");
|
|
844
|
+
const { key, value, embedding } = property;
|
|
845
|
+
const expr = members?.get(`${kind} ${key}`);
|
|
338
846
|
const text = value
|
|
339
847
|
.replace(embeddingMarkup, (markup, _alt, reference) => embeddingMimeType(reference.trim()) !== undefined ? "" : markup)
|
|
340
848
|
.trim();
|
|
341
849
|
const embeddings = renderEmbeddings(value, embedding ?? [])
|
|
342
850
|
.map((content) => `<div class="embedding">${content}</div>`).join("");
|
|
343
|
-
if (
|
|
851
|
+
if (expr === undefined || text === "")
|
|
344
852
|
return safe(`${inline(text)}${embeddings}`);
|
|
345
|
-
const
|
|
346
|
-
|
|
347
|
-
|
|
853
|
+
const badge = (item) => `<span class="value-member">${inline(item)}</span>`;
|
|
854
|
+
if (expr.kind === "enum")
|
|
855
|
+
return safe(badge(text) + embeddings);
|
|
856
|
+
const items = splitItems(text).filter((item) => item !== "");
|
|
857
|
+
if (expr.kind === "tags")
|
|
858
|
+
return safe(items.map(badge).join(" ") + embeddings);
|
|
859
|
+
const literals = new Set(literalMembers(expr));
|
|
860
|
+
return safe(items.map((item) => literals.has(item) ? badge(item) : inline(item)).join(", ") + embeddings);
|
|
348
861
|
};
|
|
349
|
-
/* expand the inline Markdown of the property values
|
|
350
|
-
|
|
351
|
-
|
|
352
|
-
|
|
862
|
+
/* expand the inline Markdown of the property values, with the
|
|
863
|
+
entries injected for unused properties (foreign to the object)
|
|
864
|
+
rendered as absent */
|
|
865
|
+
const inlineProperties = (object, properties) => properties.map((property) => ({ key: property.key,
|
|
866
|
+
info: infoKeyOf(object), infopath: infoPathOf(object),
|
|
867
|
+
value: inlineValue(object.kind, object.properties.includes(property) ? property : undefined) }));
|
|
868
|
+
/* resolve the format configuration of the kind of an object */
|
|
353
869
|
const formatOf = (object) => schemas?.get(object)?.format;
|
|
354
|
-
/* determine the maximum table columns configured on
|
|
355
|
-
least two, as a single column cannot carry a name and a
|
|
870
|
+
/* determine the maximum table columns configured on the kind of an
|
|
871
|
+
object (at least two, as a single column cannot carry a name and a
|
|
872
|
+
value) */
|
|
356
873
|
const maxColumnsOf = (object) => Math.max(2, formatOf(object)?.maxTableColumns ?? 4);
|
|
357
|
-
/* determine the effective properties of an object
|
|
358
|
-
|
|
359
|
-
|
|
874
|
+
/* determine the effective properties of an object in schema order
|
|
875
|
+
(unknown keys appended in document order), with "withUnusedProps"
|
|
876
|
+
injecting the defined but still unused schema properties as empty
|
|
877
|
+
key/value entries */
|
|
360
878
|
const effectiveProperties = (object) => {
|
|
361
879
|
const schema = schemas?.get(object);
|
|
362
|
-
if (schema
|
|
880
|
+
if (schema === undefined)
|
|
363
881
|
return object.properties;
|
|
882
|
+
const unused = schema.format?.withUnusedProps === true;
|
|
364
883
|
const merged = (schema.props ?? []).flatMap((prop) => {
|
|
365
884
|
const present = object.properties.filter((property) => property.key === prop.name);
|
|
366
|
-
return present.length > 0 ? present : [{ key: prop.name, value: "" }];
|
|
885
|
+
return present.length > 0 ? present : (unused ? [{ key: prop.name, value: "" }] : []);
|
|
367
886
|
});
|
|
368
887
|
return [...merged, ...object.properties.filter((property) => !merged.includes(property))];
|
|
369
888
|
};
|
|
370
|
-
/* determine the column shape of a potential compact table
|
|
889
|
+
/* determine the column shape of a potential compact table in schema
|
|
890
|
+
order (unknown keys appended in occurrence order), with
|
|
371
891
|
"withUnusedProps" injecting the defined but still unused schema
|
|
372
|
-
properties
|
|
892
|
+
properties as additional columns */
|
|
373
893
|
const tableShape = (childs) => {
|
|
374
|
-
|
|
894
|
+
const used = [...new Set(childs.flatMap((child) => child.properties.map((property) => property.key)))];
|
|
375
895
|
const schema = schemas?.get(childs[0]);
|
|
376
|
-
|
|
377
|
-
|
|
378
|
-
|
|
379
|
-
|
|
896
|
+
const names = (schema?.props ?? []).map((prop) => prop.name);
|
|
897
|
+
const known = schema?.format?.withUnusedProps === true ?
|
|
898
|
+
names : names.filter((name) => used.includes(name));
|
|
899
|
+
const keys = [...known, ...used.filter((key) => !names.includes(key))];
|
|
380
900
|
return { keys, desc: childs.some((child) => child.description !== undefined || child.childs.length > 0) };
|
|
381
901
|
};
|
|
382
902
|
/* provide the childs of an object taking part in the regular document
|
|
383
903
|
flow: a nested title object leaves the flow, as it is rendered as the
|
|
384
904
|
title page instead (a top-level one instead removes its whole artifact) */
|
|
385
905
|
const flowChilds = (object) => object.childs.filter((child) => !isTitleObject(child));
|
|
386
|
-
/* decide whether
|
|
387
|
-
(tabular) rendering: an explicit "format" type
|
|
388
|
-
the deepest level only, and inside an already
|
|
389
|
-
context "auto"
|
|
390
|
-
const
|
|
391
|
-
const
|
|
392
|
-
if (childs.length === 0)
|
|
393
|
-
return false;
|
|
394
|
-
const type = schemaMap?.get(object)?.format?.type ?? "auto";
|
|
906
|
+
/* decide whether a single-kind group of childs collapses into the
|
|
907
|
+
concise (tabular) rendering: an explicit "format" type of the kind
|
|
908
|
+
wins, "auto" collapses the deepest level only, and inside an already
|
|
909
|
+
concise rendering context "auto" groups implicitly stay concise, too */
|
|
910
|
+
const conciseGroup = (group, schemaMap, concise) => {
|
|
911
|
+
const type = schemaMap?.get(group[0])?.format?.type ?? "auto";
|
|
395
912
|
if (type !== "auto")
|
|
396
913
|
return type === "concise";
|
|
397
|
-
return concise ||
|
|
914
|
+
return concise || group.every((child) => child.childs.length === 0);
|
|
398
915
|
};
|
|
399
916
|
/* group the childs of an object by their kind, preserving order */
|
|
400
917
|
const groupChilds = (childs) => {
|
|
@@ -408,44 +925,54 @@ const groupChilds = (childs) => {
|
|
|
408
925
|
}
|
|
409
926
|
return [...groups.values()];
|
|
410
927
|
};
|
|
928
|
+
/* render the childs of an object group-wise by kind: a concise group
|
|
929
|
+
as one compact table, a complex group as regular nested object
|
|
930
|
+
renderings */
|
|
931
|
+
const renderChilds = (object, level, concise) => groupChilds(flowChilds(object)).map((group) => conciseGroup(group, schemas, concise) ?
|
|
932
|
+
renderTable(group, maxColumnsOf(group[0])) :
|
|
933
|
+
group.map((child) => renderObject(child, level, concise)).join("")).join("");
|
|
411
934
|
/* render the description cell of a table row: the description of the
|
|
412
935
|
object followed by its recursively rendered childs (implicitly or
|
|
413
|
-
explicitly concise
|
|
414
|
-
ones as regular nested object renderings pressed into the cell)
|
|
936
|
+
explicitly concise groups as nested sub-tables, explicitly complex
|
|
937
|
+
ones as regular nested object renderings pressed into the cell);
|
|
938
|
+
a cell left without any content renders as the absent marker,
|
|
939
|
+
exactly like a property cell of a not given property */
|
|
415
940
|
const renderCell = (child) => {
|
|
416
941
|
let html = child.description !== undefined ? renderDescription(child.description) : "";
|
|
417
|
-
|
|
418
|
-
|
|
419
|
-
html += conciseChilds(child, schemas, true) ?
|
|
420
|
-
groupChilds(childs).map((group) => renderTable(group, maxColumnsOf(child))).join("") :
|
|
421
|
-
childs.map((sub) => renderObject(sub, 6, true)).join("");
|
|
422
|
-
return html;
|
|
942
|
+
html += renderChilds(child, 6, true);
|
|
943
|
+
return html.trim() !== "" ? html : "<span class=\"value-absent\"></span>";
|
|
423
944
|
};
|
|
424
945
|
/* render a single-kind group of childs into one compact table:
|
|
425
946
|
the name first, then the property columns, then the description;
|
|
426
|
-
a group wider than maxColumns
|
|
427
|
-
|
|
947
|
+
a group wider than maxColumns, or carrying diagrams, instead chunks
|
|
948
|
+
the property and description cells (and the leading diagram) of
|
|
949
|
+
every object into an embedded per-object table, as a diagram is
|
|
950
|
+
identifiable only under a header of its own, not under the column
|
|
951
|
+
headers of a plain table */
|
|
428
952
|
const renderTable = (childs, maxColumns) => {
|
|
429
953
|
const { keys, desc } = tableShape(childs);
|
|
430
|
-
|
|
954
|
+
const diagrams = childs.some((child) => diagramOf(child) !== "");
|
|
955
|
+
if (!diagrams && 1 + keys.length + (desc ? 1 : 0) <= maxColumns)
|
|
431
956
|
return render("Table", { Table: {
|
|
432
957
|
head: childs[0].kind !== "" ? childs[0].kind : "Name",
|
|
958
|
+
info: infoKeyOf(childs[0]),
|
|
959
|
+
infopath: infoPathOf(childs[0], false),
|
|
433
960
|
keys,
|
|
434
961
|
desc,
|
|
435
962
|
/* under the fixed table layout the description column claims
|
|
436
963
|
twice the share of a regular column, compressing the others */
|
|
437
964
|
width: Math.round(200 / (keys.length + 3)),
|
|
438
|
-
rows: childs.map((child) => ({
|
|
965
|
+
rows: childs.map((child, i) => scoped(child, () => ({
|
|
439
966
|
id: anchorOf(child),
|
|
440
967
|
paren: child.paren,
|
|
441
968
|
primary: child.primary,
|
|
969
|
+
spec: infoParents !== null ? anchorOf(child) : undefined,
|
|
970
|
+
specpath: infoPathOf(child),
|
|
442
971
|
name: inline(child.name),
|
|
443
|
-
|
|
444
|
-
|
|
445
|
-
return property !== undefined ? inlineValue(child.kind, property) : "";
|
|
446
|
-
}),
|
|
972
|
+
even: i % 2 === 1,
|
|
973
|
+
values: keys.map((key) => inlineValue(child.kind, child.properties.find((property) => property.key === key))),
|
|
447
974
|
description: safe(renderCell(child))
|
|
448
|
-
}))
|
|
975
|
+
})))
|
|
449
976
|
} });
|
|
450
977
|
/* the embedded rows hold at most maxColumns - 1 cells (of the
|
|
451
978
|
group-wide union of property keys, plus the trailing description),
|
|
@@ -453,51 +980,67 @@ const renderTable = (childs, maxColumns) => {
|
|
|
453
980
|
const size = Math.max(1, maxColumns - 1);
|
|
454
981
|
return render("TableChunked", { Table: {
|
|
455
982
|
head: childs[0].kind !== "" ? childs[0].kind : "Name",
|
|
983
|
+
info: infoKeyOf(childs[0]),
|
|
984
|
+
infopath: infoPathOf(childs[0], false),
|
|
456
985
|
width: Math.round(100 / maxColumns),
|
|
457
|
-
rows: childs.map((child) => {
|
|
458
|
-
const cells = keys.map((key) => {
|
|
459
|
-
|
|
460
|
-
return { key, desc: false, span: 1,
|
|
461
|
-
value: property !== undefined ? inlineValue(child.kind, property) : "" };
|
|
462
|
-
});
|
|
986
|
+
rows: childs.map((child, i) => scoped(child, () => {
|
|
987
|
+
const cells = keys.map((key) => ({ key, desc: false, span: 1,
|
|
988
|
+
value: inlineValue(child.kind, child.properties.find((property) => property.key === key)) }));
|
|
463
989
|
if (desc)
|
|
464
990
|
cells.push({ key: "Description", desc: true, span: 1,
|
|
465
991
|
value: safe(renderCell(child)) });
|
|
466
992
|
const chunks = new Array();
|
|
467
993
|
for (let i = 0; i < cells.length; i += size)
|
|
468
994
|
chunks.push(cells.slice(i, i + size));
|
|
469
|
-
|
|
470
|
-
|
|
995
|
+
if (chunks.length > 0) {
|
|
996
|
+
const last = chunks[chunks.length - 1];
|
|
997
|
+
last[last.length - 1].span = size - last.length + 1;
|
|
998
|
+
}
|
|
999
|
+
/* the diagram leads the chunks as a full-width chunk of its
|
|
1000
|
+
own, headed like the description */
|
|
1001
|
+
const diagram = diagramOf(child);
|
|
1002
|
+
if (diagram !== "")
|
|
1003
|
+
chunks.unshift([{ key: "Diagram", desc: true, span: size, value: diagram }]);
|
|
471
1004
|
return {
|
|
472
1005
|
id: anchorOf(child),
|
|
473
1006
|
paren: child.paren,
|
|
474
1007
|
primary: child.primary,
|
|
1008
|
+
spec: infoParents !== null ? anchorOf(child) : undefined,
|
|
1009
|
+
specpath: infoPathOf(child),
|
|
475
1010
|
name: inline(child.name),
|
|
1011
|
+
even: i % 2 === 1,
|
|
476
1012
|
chunks
|
|
477
1013
|
};
|
|
478
|
-
})
|
|
1014
|
+
}))
|
|
479
1015
|
} });
|
|
480
1016
|
};
|
|
1017
|
+
/* the pre-rendered diagram of an object as an embeddable block
|
|
1018
|
+
(empty for an object without a configured or renderable diagram) */
|
|
1019
|
+
const diagramOf = (object) => {
|
|
1020
|
+
const diagram = diagrams?.get(object);
|
|
1021
|
+
return diagram !== undefined ? safe(`<div class="diagram">${diagram}</div>`) : "";
|
|
1022
|
+
};
|
|
481
1023
|
/* recursively render an object into HTML */
|
|
482
1024
|
const renderObject = (object, level, concise) => {
|
|
483
1025
|
const properties = effectiveProperties(object);
|
|
484
|
-
|
|
485
|
-
return render("Object", { Object: {
|
|
1026
|
+
return scoped(object, () => render("Object", { Object: {
|
|
486
1027
|
level: Math.min(level, 6),
|
|
487
1028
|
kind: object.kind,
|
|
1029
|
+
info: infoKeyOf(object),
|
|
1030
|
+
infopath: infoPathOf(object, false),
|
|
1031
|
+
spec: infoParents !== null ? anchorOf(object) : undefined,
|
|
1032
|
+
specpath: infoPathOf(object),
|
|
488
1033
|
id: anchorOf(object),
|
|
489
1034
|
paren: object.paren,
|
|
490
1035
|
primary: object.primary,
|
|
491
1036
|
name: inline(object.name),
|
|
492
|
-
diagram:
|
|
1037
|
+
diagram: diagramOf(object),
|
|
493
1038
|
properties: properties.length > 0 ?
|
|
494
|
-
safe(render("Properties", { Properties: inlineProperties(object
|
|
1039
|
+
safe(render("Properties", { Properties: inlineProperties(object, properties) })) : "",
|
|
495
1040
|
description: object.description !== undefined ?
|
|
496
1041
|
safe(renderDescription(object.description)) : "",
|
|
497
|
-
childs:
|
|
498
|
-
|
|
499
|
-
safe(flowChilds(object).map((child) => renderObject(child, level + 1, concise)).join(""))
|
|
500
|
-
} });
|
|
1042
|
+
childs: safe(renderChilds(object, level + 1, concise))
|
|
1043
|
+
} }));
|
|
501
1044
|
};
|
|
502
1045
|
/* format a timestamp as its calendar date in local time (the
|
|
503
1046
|
frontmatter timestamps are parsed in local time, too, so the UTC
|
|
@@ -519,7 +1062,7 @@ const renderTitlePage = (object, created, modified) => {
|
|
|
519
1062
|
in both its theme variants */
|
|
520
1063
|
const logo = object.properties.find((property) => property.key === "LOGO");
|
|
521
1064
|
const image = logo !== undefined ? renderEmbeddings(logo.value, logo.embedding ?? []) : [];
|
|
522
|
-
return render("TitlePage", { TitlePage: {
|
|
1065
|
+
return scoped(object, () => render("TitlePage", { TitlePage: {
|
|
523
1066
|
logo: logo === undefined ?
|
|
524
1067
|
safe(renderThemed(embeddingThemes.map((theme) => `<img src="${fallbackLogo(theme)}" alt="SpecBook"/>`))) :
|
|
525
1068
|
(image.length > 0 ? safe(image.join("")) : inline(logo.value)),
|
|
@@ -530,23 +1073,67 @@ const renderTitlePage = (object, created, modified) => {
|
|
|
530
1073
|
description: object.description !== undefined ?
|
|
531
1074
|
safe(renderDescription(object.description)) : "",
|
|
532
1075
|
properties: rest.length > 0 ?
|
|
533
|
-
safe(render("Properties", { Properties: inlineProperties(object
|
|
1076
|
+
safe(render("Properties", { Properties: inlineProperties(object, rest) })) : "",
|
|
534
1077
|
created, modified
|
|
535
|
-
} });
|
|
1078
|
+
} }));
|
|
536
1079
|
};
|
|
537
1080
|
/* render an artifact into HTML */
|
|
538
1081
|
const renderArtifact = (artifact) => render("Artifact", { Artifact: {
|
|
539
1082
|
objects: safe(artifact.objects.map((object) => renderObject(object, 1, false)).join(""))
|
|
540
1083
|
} });
|
|
1084
|
+
/* provide the childs of an object rendered with headings of their own
|
|
1085
|
+
(skipping the child groups collapsing into compact tables), which
|
|
1086
|
+
are the ones taking part in the table of contents and its side panel */
|
|
1087
|
+
const headingChilds = (object) => groupChilds(flowChilds(object))
|
|
1088
|
+
.filter((group) => !conciseGroup(group, schemas, false))
|
|
1089
|
+
.flat();
|
|
1090
|
+
/* flatten the hierarchy of the rendered object headings (exactly like
|
|
1091
|
+
the PDF outline) into the entries of the table of contents, each
|
|
1092
|
+
carrying its nesting level and its optional page number */
|
|
1093
|
+
const tocEntries = (objects, pages) => {
|
|
1094
|
+
const entries = [];
|
|
1095
|
+
const collect = (objects, level) => {
|
|
1096
|
+
for (const object of objects) {
|
|
1097
|
+
const id = anchorOf(object);
|
|
1098
|
+
entries.push({ id, kind: object.kind,
|
|
1099
|
+
info: infoKeyOf(object), infopath: infoPathOf(object, false),
|
|
1100
|
+
spec: infoParents !== null ? id : undefined, specpath: infoPathOf(object),
|
|
1101
|
+
name: inline(object.name), level: Math.min(level, 6), page: pages?.get(id) });
|
|
1102
|
+
collect(headingChilds(object), level + 1);
|
|
1103
|
+
}
|
|
1104
|
+
};
|
|
1105
|
+
collect(objects, 1);
|
|
1106
|
+
return entries;
|
|
1107
|
+
};
|
|
1108
|
+
/* render the side panel of the table of contents (its behavior is
|
|
1109
|
+
driven by the client-side script above): the unnumbered entries of
|
|
1110
|
+
the front matter (title page, table and diagram of contents, as far
|
|
1111
|
+
as present) are followed by the hierarchical entries of the rendered
|
|
1112
|
+
object headings, exactly like the table of contents itself */
|
|
1113
|
+
const renderTocPanel = (objects, title, doc) => {
|
|
1114
|
+
const entries = (objects) => objects.length === 0 ? "" : render("TocPanelEntries", { Entries: objects.map((object) => ({
|
|
1115
|
+
id: anchorOf(object),
|
|
1116
|
+
kind: object.kind,
|
|
1117
|
+
info: infoKeyOf(object),
|
|
1118
|
+
infopath: infoPathOf(object, false),
|
|
1119
|
+
spec: infoParents !== null ? anchorOf(object) : undefined,
|
|
1120
|
+
specpath: infoPathOf(object),
|
|
1121
|
+
name: inline(object.name),
|
|
1122
|
+
childs: safe(entries(headingChilds(object)))
|
|
1123
|
+
})) });
|
|
1124
|
+
return render("TocPanel", { TocPanel: { title, doc, entries: safe(entries(objects)) } });
|
|
1125
|
+
};
|
|
541
1126
|
/* derive the hierarchy of the rendered object headings, skipping the
|
|
542
|
-
title page object and the
|
|
1127
|
+
title page object and the child groups collapsing into compact tables */
|
|
543
1128
|
export const htmlOutline = (specification, config) => {
|
|
544
1129
|
const paths = anchorPaths(buildLinkIndex(specification));
|
|
545
1130
|
const schemaMap = config !== undefined ? collectSchemas(specification, config) : null;
|
|
546
1131
|
const entry = (object) => ({
|
|
547
1132
|
title: (object.kind !== "" ? `${object.kind}: ` : "") + plainText(object.name),
|
|
548
1133
|
anchor: paths.get(object) ?? object.id,
|
|
549
|
-
childs:
|
|
1134
|
+
childs: groupChilds(flowChilds(object))
|
|
1135
|
+
.filter((group) => !conciseGroup(group, schemaMap, false))
|
|
1136
|
+
.flatMap((group) => group.map(entry))
|
|
550
1137
|
});
|
|
551
1138
|
return specification.artifacts
|
|
552
1139
|
.filter((artifact) => !artifact.objects.some(isTitleObject))
|
|
@@ -564,9 +1151,10 @@ const titlePageObject = (specification) => {
|
|
|
564
1151
|
};
|
|
565
1152
|
/* render the entire specification into a self-contained HTML document,
|
|
566
1153
|
with the build-time pre-assembled stylesheet embedded inline, the
|
|
567
|
-
artifact timestamps aggregated into min(Created)/max(Modified),
|
|
568
|
-
optional per-anchor page numbers attached to the ToC entries
|
|
569
|
-
|
|
1154
|
+
artifact timestamps aggregated into min(Created)/max(Modified),
|
|
1155
|
+
optional per-anchor page numbers attached to the ToC entries, and
|
|
1156
|
+
optionally the client-side script of the live preview injected */
|
|
1157
|
+
export const renderHtml = async (specification, config, tocPages, css, realtime = false) => {
|
|
570
1158
|
/* pre-render the configured diagrams as embeddable SVGs (a runtime
|
|
571
1159
|
rendering failure omits the diagram, as the statically detectable
|
|
572
1160
|
invalid situations are already reported as lint diagnostics),
|
|
@@ -585,7 +1173,17 @@ export const renderHtml = async (specification, config, tocPages, css) => {
|
|
|
585
1173
|
continue;
|
|
586
1174
|
try {
|
|
587
1175
|
const svg = await Gradia.render(result.spec, { format: "svg:embedded", config: result.config });
|
|
588
|
-
|
|
1176
|
+
/* a "hub" diagram is capped to the width share it would
|
|
1177
|
+
occupy on its full three-column canvas (padded by the
|
|
1178
|
+
minimum widths of the absent columns and their channels),
|
|
1179
|
+
so all hub diagrams share the zoom level of the
|
|
1180
|
+
three-column ones and are centered in the leftover space */
|
|
1181
|
+
const absent = result.columns !== undefined ? 3 - result.columns : 0;
|
|
1182
|
+
const pad = absent * ((result.config?.["size-node-width-min"] ?? Gradia.config["size-node-width-min"]) +
|
|
1183
|
+
(result.config?.["hub-channel-width-min"] ?? Gradia.config["hub-channel-width-min"]));
|
|
1184
|
+
rendered.set(object, svg.replace(/(<svg[^>]*) width="([0-9.]+)" height="([0-9.]+)"/, (_, head, w, h) => `${head} width="${Number(w) * scale}" height="${Number(h) * scale}"` +
|
|
1185
|
+
(result.columns !== undefined ? " class=\"hub\"" : "") +
|
|
1186
|
+
(absent > 0 ? ` style="max-width: ${(Number(w) / (Number(w) + pad) * 100).toFixed(2)}%"` : "")));
|
|
589
1187
|
}
|
|
590
1188
|
catch {
|
|
591
1189
|
/* intentionally omitted */
|
|
@@ -597,31 +1195,69 @@ export const renderHtml = async (specification, config, tocPages, css) => {
|
|
|
597
1195
|
const lang = documentLang(specification);
|
|
598
1196
|
quotes = quoteStyles[lang?.toLowerCase().split(/[-_]/)[0] ?? "en"] ?? quoteStyles.en;
|
|
599
1197
|
/* expand "[[xxx]]" references into hyperlinks (an unresolvable or
|
|
600
|
-
ambiguous reference stays literal, marked as broken),
|
|
601
|
-
the
|
|
1198
|
+
ambiguous reference stays literal, marked as broken), resolved
|
|
1199
|
+
from the object currently rendered and targeting the
|
|
1200
|
+
fully-qualified anchor paths of the objects: in the full form
|
|
1201
|
+
(kind, name, and link symbol), or in the compact form for prose
|
|
1202
|
+
(the object icon and the name only, via CSS and markup, with
|
|
1203
|
+
the full form shown by the description popups) */
|
|
602
1204
|
const index = buildLinkIndex(specification);
|
|
603
1205
|
anchors = anchorPaths(index);
|
|
604
1206
|
members = config !== undefined ? collectMembers(config, new Map()) : null;
|
|
605
1207
|
schemas = config !== undefined ? collectSchemas(specification, config) : null;
|
|
606
1208
|
diagrams = rendered;
|
|
607
|
-
|
|
608
|
-
|
|
1209
|
+
/* collect the schema descriptions for the description popups,
|
|
1210
|
+
plus the object parents composing their title paths */
|
|
1211
|
+
let info = null;
|
|
1212
|
+
if (config !== undefined) {
|
|
1213
|
+
infoKeys = new Map();
|
|
1214
|
+
info = {};
|
|
1215
|
+
collectInfo(config, "", infoKeys, info);
|
|
1216
|
+
const parents = new Map();
|
|
1217
|
+
const link = (object) => {
|
|
1218
|
+
for (const child of object.childs) {
|
|
1219
|
+
parents.set(child, object);
|
|
1220
|
+
link(child);
|
|
1221
|
+
}
|
|
1222
|
+
};
|
|
1223
|
+
for (const artifact of specification.artifacts)
|
|
1224
|
+
artifact.objects.forEach(link);
|
|
1225
|
+
infoParents = parents;
|
|
1226
|
+
}
|
|
1227
|
+
linker = (text, compact) => expandReferences(text, (reference) => {
|
|
1228
|
+
const target = resolveUnique(index, reference, scope ?? undefined).target;
|
|
609
1229
|
if (target === undefined)
|
|
610
1230
|
return `<span class="link-broken">[[${escapeHtml(reference)}]]</span>`;
|
|
611
|
-
|
|
612
|
-
|
|
613
|
-
|
|
1231
|
+
/* the full form carries the popup attributes on its kind and
|
|
1232
|
+
name, with the kind bold in the accent color and the name
|
|
1233
|
+
bold in the regular text color (via the stylesheet) */
|
|
1234
|
+
const full = `<strong class="object-kind"${infoAttr(target)}>${escapeHtml(target.kind)}:</strong>` +
|
|
1235
|
+
` <span class="object-name"${specAttr(target)}>${target.name}</span>` +
|
|
1236
|
+
" <span class=\"link-symbol\">⚭</span>";
|
|
1237
|
+
return compact ?
|
|
1238
|
+
`<a href="#${escapeHtml(anchorOf(target))}" class="link-compact"${specAttr(target)}>${target.name}</a>` :
|
|
1239
|
+
`<a href="#${escapeHtml(anchorOf(target))}" class="link-full">${full}</a>`;
|
|
614
1240
|
});
|
|
1241
|
+
/* collect the corpus descriptions of the object instances for the
|
|
1242
|
+
description popups (after the linker is in place, as the
|
|
1243
|
+
pre-rendered descriptions expand their references, too) */
|
|
1244
|
+
let spec = null;
|
|
1245
|
+
if (info !== null) {
|
|
1246
|
+
spec = {};
|
|
1247
|
+
for (const artifact of specification.artifacts)
|
|
1248
|
+
collectSpec(artifact.objects, spec);
|
|
1249
|
+
}
|
|
615
1250
|
const created = new Date(Math.min(...specification.artifacts.map((artifact) => artifact.created.getTime())));
|
|
616
1251
|
const modified = new Date(Math.max(...specification.artifacts.map((artifact) => artifact.modified.getTime())));
|
|
617
|
-
/* a "META: Title" object becomes the title page and leaves the regular
|
|
1252
|
+
/* a "META: Title" object becomes the title page and leaves the regular
|
|
1253
|
+
flow, while its diagram becomes the "Diagram of Contents" page */
|
|
618
1254
|
const title = titlePageObject(specification);
|
|
619
1255
|
const artifacts = specification.artifacts
|
|
620
1256
|
.filter((artifact) => !artifact.objects.some(isTitleObject));
|
|
1257
|
+
const meta = titleObject(specification);
|
|
1258
|
+
const doc = meta !== undefined ? rendered?.get(meta) : undefined;
|
|
621
1259
|
try {
|
|
622
|
-
const entries = artifacts.flatMap((artifact) => artifact.objects)
|
|
623
|
-
.map((object) => ({ id: anchorOf(object), kind: object.kind, name: inline(object.name),
|
|
624
|
-
page: tocPages?.get(anchorOf(object)) }));
|
|
1260
|
+
const entries = tocEntries(artifacts.flatMap((artifact) => artifact.objects), tocPages);
|
|
625
1261
|
return render("Document", { Document: {
|
|
626
1262
|
title: documentTitle(specification).title,
|
|
627
1263
|
lang,
|
|
@@ -629,8 +1265,14 @@ export const renderHtml = async (specification, config, tocPages, css) => {
|
|
|
629
1265
|
css: safe(css ?? stylesheet()),
|
|
630
1266
|
titlepage: title !== undefined ?
|
|
631
1267
|
safe(renderTitlePage(title, formatDate(created), formatDate(modified))) : "",
|
|
632
|
-
search:
|
|
1268
|
+
search: safe(searchScript()),
|
|
1269
|
+
info: info !== null ? safe(infoScript(info, spec ?? {})) : "",
|
|
1270
|
+
realtime: realtime ? safe(realtimeScript) : "",
|
|
633
1271
|
toc: entries.length > 0 ? safe(render("Toc", { Toc: { entries } })) : "",
|
|
1272
|
+
tocpanel: entries.length > 0 ?
|
|
1273
|
+
safe(renderTocPanel(artifacts.flatMap((artifact) => artifact.objects), title !== undefined, doc !== undefined)) : "",
|
|
1274
|
+
tocscript: entries.length > 0 ? safe(tocPanelScript) : "",
|
|
1275
|
+
doc: doc !== undefined ? safe(render("Doc", { Doc: { diagram: safe(doc) } })) : "",
|
|
634
1276
|
artifacts: safe(artifacts.map((artifact) => renderArtifact(artifact)).join(""))
|
|
635
1277
|
} });
|
|
636
1278
|
}
|
|
@@ -643,5 +1285,7 @@ export const renderHtml = async (specification, config, tocPages, css) => {
|
|
|
643
1285
|
members = null;
|
|
644
1286
|
schemas = null;
|
|
645
1287
|
diagrams = null;
|
|
1288
|
+
infoKeys = null;
|
|
1289
|
+
infoParents = null;
|
|
646
1290
|
}
|
|
647
1291
|
};
|