@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
|
@@ -0,0 +1,605 @@
|
|
|
1
|
+
/*
|
|
2
|
+
** Specification Book (SpecBook)
|
|
3
|
+
** Copyright (c) 2026 Dr. Ralf S. Engelschall <rse@engelschall.com>
|
|
4
|
+
** Licensed under Apache 2.0 <https://spdx.org/licenses/Apache-2.0>
|
|
5
|
+
*/
|
|
6
|
+
import textframe from "textframe";
|
|
7
|
+
/* ==== Widgets ==== */
|
|
8
|
+
/* the client-side script of the color theme: it applies the stored
|
|
9
|
+
choice (or, without one, the document default and finally the system
|
|
10
|
+
preference) before the first paint and toggles it on demand. The
|
|
11
|
+
live preview placeholder page carries it, too, as an in-place
|
|
12
|
+
document update never re-executes a head script */
|
|
13
|
+
export const themeScript = textframe `
|
|
14
|
+
(function () {
|
|
15
|
+
let style = null
|
|
16
|
+
try { style = localStorage.getItem("specbook-theme") }
|
|
17
|
+
catch { /* an inaccessible storage just means no stored choice */ }
|
|
18
|
+
const html = document.documentElement.classList
|
|
19
|
+
if (style === null)
|
|
20
|
+
style = html.contains("theme-dark") ? "dark" : (html.contains("theme-light") ? "light" : null)
|
|
21
|
+
if (style === null)
|
|
22
|
+
style = window.matchMedia("(prefers-color-scheme: dark)").matches ? "dark" : "light"
|
|
23
|
+
html.remove("theme-light", "theme-dark")
|
|
24
|
+
html.add("theme-" + style)
|
|
25
|
+
})()
|
|
26
|
+
function themeSwitch () {
|
|
27
|
+
const html = document.documentElement.classList
|
|
28
|
+
const style = html.contains("theme-dark") ? "light" : "dark"
|
|
29
|
+
html.remove("theme-light", "theme-dark")
|
|
30
|
+
html.add("theme-" + style)
|
|
31
|
+
try { localStorage.setItem("specbook-theme", style) }
|
|
32
|
+
catch { /* an inaccessible storage just loses the choice */ }
|
|
33
|
+
}
|
|
34
|
+
`;
|
|
35
|
+
/* the minimal stylesheet of the live preview placeholder page: it also
|
|
36
|
+
provides the <style> element the in-place document update swaps with
|
|
37
|
+
the real stylesheet, so it carries the centered message only */
|
|
38
|
+
export const placeholderStylesheet = textframe `
|
|
39
|
+
html.theme-light body { color: #333333; background-color: #ffffff }
|
|
40
|
+
html.theme-dark body { color: #cccccc; background-color: #1a1a1a }
|
|
41
|
+
body { font-family: sans-serif; margin: 0 }
|
|
42
|
+
div.placeholder { position: absolute; top: 50%; left: 0; right: 0;
|
|
43
|
+
transform: translateY(-50%); text-align: center; opacity: 0.6 }
|
|
44
|
+
`;
|
|
45
|
+
/* the client-side script of the live preview: it connects back to
|
|
46
|
+
its own page over WebSocket (the URL scheme "http"/"https" replaced with
|
|
47
|
+
"ws"/"wss"), re-connects every second after a lost connection, and
|
|
48
|
+
updates the page on a received "RELOAD" command as well as on every
|
|
49
|
+
re-established connection (as the server was restarted meanwhile).
|
|
50
|
+
The update fetches the fresh page and replaces the document in place
|
|
51
|
+
-- instead of reloading the page -- so the scroll position and the
|
|
52
|
+
theme choice survive: the title, the stylesheet (if changed), and
|
|
53
|
+
the body are swapped, and the scripts of the fresh body re-executed
|
|
54
|
+
through clones (as parsed scripts never execute), except this
|
|
55
|
+
realtime script itself, whose connection stays alive. The status
|
|
56
|
+
icon (re-created by every body swap) reflects the connection state
|
|
57
|
+
and blinks for 2s after every update, unless the connection is
|
|
58
|
+
lost meanwhile */
|
|
59
|
+
export const realtimeScript = textframe `
|
|
60
|
+
(function () {
|
|
61
|
+
const url = window.location.protocol.replace(/^http/, "ws") + "//" +
|
|
62
|
+
window.location.host + window.location.pathname
|
|
63
|
+
const status = (state) => {
|
|
64
|
+
const icon = document.querySelector("div.realtime-status")
|
|
65
|
+
if (icon !== null)
|
|
66
|
+
icon.className = "realtime-status " + state
|
|
67
|
+
}
|
|
68
|
+
let blink = 0
|
|
69
|
+
let seq = 0
|
|
70
|
+
let lost = false
|
|
71
|
+
const update = async () => {
|
|
72
|
+
/* a superseded update (an overlapping newer one started
|
|
73
|
+
meanwhile) is dropped, as its stale page could arrive last */
|
|
74
|
+
const mine = ++seq
|
|
75
|
+
let html
|
|
76
|
+
try {
|
|
77
|
+
const response = await fetch(window.location.pathname, { cache: "no-store" })
|
|
78
|
+
if (!response.ok)
|
|
79
|
+
return
|
|
80
|
+
html = await response.text()
|
|
81
|
+
}
|
|
82
|
+
catch {
|
|
83
|
+
/* an unreachable server just skips the update */
|
|
84
|
+
return
|
|
85
|
+
}
|
|
86
|
+
if (mine !== seq)
|
|
87
|
+
return
|
|
88
|
+
const doc = new DOMParser().parseFromString(html, "text/html")
|
|
89
|
+
const x = window.scrollX
|
|
90
|
+
const y = window.scrollY
|
|
91
|
+
document.title = doc.title
|
|
92
|
+
const style = document.head.querySelector("style")
|
|
93
|
+
const fresh = doc.head.querySelector("style")
|
|
94
|
+
if (style !== null && fresh !== null && style.textContent !== fresh.textContent)
|
|
95
|
+
style.replaceWith(fresh)
|
|
96
|
+
document.body.replaceWith(doc.body)
|
|
97
|
+
|
|
98
|
+
/* a swapped-out maximization overlay can no longer unlock the scrolling */
|
|
99
|
+
document.documentElement.classList.remove("maximized")
|
|
100
|
+
for (const script of document.body.querySelectorAll("script:not(.realtime)")) {
|
|
101
|
+
const clone = document.createElement("script")
|
|
102
|
+
clone.textContent = script.textContent
|
|
103
|
+
script.replaceWith(clone)
|
|
104
|
+
}
|
|
105
|
+
window.scrollTo(x, y)
|
|
106
|
+
if (lost)
|
|
107
|
+
return
|
|
108
|
+
status("reloaded")
|
|
109
|
+
clearTimeout(blink)
|
|
110
|
+
blink = setTimeout(() => { status("connected") }, 2000)
|
|
111
|
+
}
|
|
112
|
+
const connect = () => {
|
|
113
|
+
const ws = new WebSocket(url)
|
|
114
|
+
ws.onopen = () => {
|
|
115
|
+
status("connected")
|
|
116
|
+
if (lost) {
|
|
117
|
+
console.log("specbook: live preview connection re-established")
|
|
118
|
+
lost = false
|
|
119
|
+
update()
|
|
120
|
+
}
|
|
121
|
+
}
|
|
122
|
+
ws.onmessage = (event) => {
|
|
123
|
+
if (event.data === "RELOAD")
|
|
124
|
+
update()
|
|
125
|
+
}
|
|
126
|
+
ws.onclose = () => {
|
|
127
|
+
clearTimeout(blink)
|
|
128
|
+
status("disconnected")
|
|
129
|
+
if (!lost)
|
|
130
|
+
console.log("specbook: live preview connection lost")
|
|
131
|
+
lost = true
|
|
132
|
+
setTimeout(connect, 1000)
|
|
133
|
+
}
|
|
134
|
+
}
|
|
135
|
+
connect()
|
|
136
|
+
})()
|
|
137
|
+
`;
|
|
138
|
+
/* the client-side script of the scroll progress meter: it drives the
|
|
139
|
+
DONE arc of the ring (through the dash offset of its unit-length
|
|
140
|
+
circle) with the scrolled fraction of the document, shows the meter
|
|
141
|
+
once the page is scrolled beyond 400px, and scrolls the page back
|
|
142
|
+
to the top on a click (stripping the URL hash, so a later reload
|
|
143
|
+
stays at the top). The updates are throttled onto animation frames,
|
|
144
|
+
and the window-bound listeners retire themselves once a live preview
|
|
145
|
+
body swap replaced the meter (whose fresh script re-attaches) */
|
|
146
|
+
export const scrollProgressScript = textframe `
|
|
147
|
+
(function () {
|
|
148
|
+
const meter = document.querySelector("div.scroll-progress")
|
|
149
|
+
const ring = meter.querySelector("circle.scroll-done")
|
|
150
|
+
let ticking = false
|
|
151
|
+
const update = () => {
|
|
152
|
+
ticking = false
|
|
153
|
+
if (!meter.isConnected) {
|
|
154
|
+
window.removeEventListener("scroll", schedule)
|
|
155
|
+
window.removeEventListener("resize", schedule)
|
|
156
|
+
return
|
|
157
|
+
}
|
|
158
|
+
const doc = document.documentElement
|
|
159
|
+
const max = doc.scrollHeight - doc.clientHeight
|
|
160
|
+
ring.setAttribute("stroke-dashoffset", String(1 - (max > 0 ? doc.scrollTop / max : 0)))
|
|
161
|
+
meter.classList.toggle("shown", doc.scrollTop > 400)
|
|
162
|
+
}
|
|
163
|
+
const schedule = () => {
|
|
164
|
+
if (!ticking) {
|
|
165
|
+
ticking = true
|
|
166
|
+
requestAnimationFrame(update)
|
|
167
|
+
}
|
|
168
|
+
}
|
|
169
|
+
meter.addEventListener("click", () => {
|
|
170
|
+
window.scrollTo({ top: 0, behavior: "smooth" })
|
|
171
|
+
if (window.location.hash)
|
|
172
|
+
history.replaceState(null, "", window.location.pathname + window.location.search)
|
|
173
|
+
})
|
|
174
|
+
window.addEventListener("scroll", schedule, { passive: true })
|
|
175
|
+
window.addEventListener("resize", schedule)
|
|
176
|
+
update()
|
|
177
|
+
})()
|
|
178
|
+
`;
|
|
179
|
+
/* the client-side script of the diagram maximization: it attaches two
|
|
180
|
+
controls to the top right corner of every diagram, revealed by the
|
|
181
|
+
stylesheet while the diagram is hovered, which maximize the diagram
|
|
182
|
+
temporarily in a single shared overlay covering the viewport (the
|
|
183
|
+
SVG cloned into it and fitted by the stylesheet), the second one
|
|
184
|
+
additionally taking the overlay into the browser fullscreen (a
|
|
185
|
+
refused request leaves the viewport overlay), where the close mark
|
|
186
|
+
at the top right corner, "Escape", a click beside the diagram, and
|
|
187
|
+
a jump through a node hyperlink close the overlay again (a browser
|
|
188
|
+
leaving the fullscreen on its own closes it, too). The script runs
|
|
189
|
+
at the end of the body, as the diagrams it decorates have to exist
|
|
190
|
+
already, and a live preview body swap replaces the overlay and the
|
|
191
|
+
body-bound listeners along with the body, while the document-bound
|
|
192
|
+
fullscreen listener retires itself */
|
|
193
|
+
export const maximizeScript = textframe `
|
|
194
|
+
(function () {
|
|
195
|
+
/* an icon control drawn like the tab icons */
|
|
196
|
+
const svgNS = "http://www.w3.org/2000/svg"
|
|
197
|
+
const mark = (name, title, paths) => {
|
|
198
|
+
const span = document.createElement("span")
|
|
199
|
+
span.className = name
|
|
200
|
+
span.title = title
|
|
201
|
+
const svg = document.createElementNS(svgNS, "svg")
|
|
202
|
+
svg.setAttribute("viewBox", "0 0 24 24")
|
|
203
|
+
svg.setAttribute("fill", "none")
|
|
204
|
+
svg.setAttribute("stroke", "currentColor")
|
|
205
|
+
svg.setAttribute("stroke-width", "2.5")
|
|
206
|
+
svg.setAttribute("stroke-linecap", "round")
|
|
207
|
+
svg.setAttribute("stroke-linejoin", "round")
|
|
208
|
+
for (const d of paths) {
|
|
209
|
+
const path = document.createElementNS(svgNS, "path")
|
|
210
|
+
path.setAttribute("d", d)
|
|
211
|
+
svg.appendChild(path)
|
|
212
|
+
}
|
|
213
|
+
span.appendChild(svg)
|
|
214
|
+
return span
|
|
215
|
+
}
|
|
216
|
+
|
|
217
|
+
/* the shared overlay: the close mark plus the content holding
|
|
218
|
+
the clone of the maximized diagram */
|
|
219
|
+
const overlay = document.createElement("div")
|
|
220
|
+
overlay.className = "maximize"
|
|
221
|
+
const close = mark("maximize-close", "close the maximized diagram",
|
|
222
|
+
[ "M18 6 6 18", "M6 6l12 12" ])
|
|
223
|
+
const content = document.createElement("div")
|
|
224
|
+
content.className = "maximize-content"
|
|
225
|
+
overlay.append(close, content)
|
|
226
|
+
document.body.appendChild(overlay)
|
|
227
|
+
|
|
228
|
+
/* open the overlay onto a diagram (its inline SVG, its code listing,
|
|
229
|
+
which keeps its line numbering style, or the image of the theme
|
|
230
|
+
variant currently shown), optionally in the browser fullscreen,
|
|
231
|
+
and close it again (leaving the fullscreen, too), with the
|
|
232
|
+
document scrolling locked while it is open */
|
|
233
|
+
const open = (diagram, fullscreen) => {
|
|
234
|
+
const shown = Array.from(diagram.querySelectorAll(":scope > svg, :scope > pre, img"))
|
|
235
|
+
.find((el) => el.getClientRects().length > 0)
|
|
236
|
+
if (shown === undefined)
|
|
237
|
+
return
|
|
238
|
+
const svg = shown.cloneNode(true)
|
|
239
|
+
if (svg.tagName !== "PRE")
|
|
240
|
+
svg.removeAttribute("style")
|
|
241
|
+
content.replaceChildren(svg)
|
|
242
|
+
overlay.classList.add("open")
|
|
243
|
+
document.documentElement.classList.add("maximized")
|
|
244
|
+
if (fullscreen)
|
|
245
|
+
overlay.requestFullscreen().catch(() => { /* a refused request leaves the viewport overlay */ })
|
|
246
|
+
}
|
|
247
|
+
const shut = () => {
|
|
248
|
+
overlay.classList.remove("open")
|
|
249
|
+
document.documentElement.classList.remove("maximized")
|
|
250
|
+
content.replaceChildren()
|
|
251
|
+
if (document.fullscreenElement === overlay)
|
|
252
|
+
document.exitFullscreen().catch(() => { /* a fullscreen left already needs no exit */ })
|
|
253
|
+
}
|
|
254
|
+
close.addEventListener("click", shut)
|
|
255
|
+
overlay.addEventListener("click", (event) => {
|
|
256
|
+
/* a node hyperlink navigates on its own, the overlay just
|
|
257
|
+
gets out of its way, while a click beside the diagram
|
|
258
|
+
closes the overlay alone */
|
|
259
|
+
if (event.target.closest("a") !== null || event.target.closest("svg, pre, img") === null)
|
|
260
|
+
shut()
|
|
261
|
+
})
|
|
262
|
+
document.body.addEventListener("keydown", (event) => {
|
|
263
|
+
if (event.key === "Escape" && overlay.classList.contains("open"))
|
|
264
|
+
shut()
|
|
265
|
+
})
|
|
266
|
+
const left = () => {
|
|
267
|
+
if (!overlay.isConnected)
|
|
268
|
+
document.removeEventListener("fullscreenchange", left)
|
|
269
|
+
else if (document.fullscreenElement === null && overlay.classList.contains("open"))
|
|
270
|
+
shut()
|
|
271
|
+
}
|
|
272
|
+
document.addEventListener("fullscreenchange", left)
|
|
273
|
+
|
|
274
|
+
/* attach the two controls to every diagram */
|
|
275
|
+
document.querySelectorAll("article div.diagram, nav.doc div.diagram").forEach((diagram) => {
|
|
276
|
+
const controls = document.createElement("span")
|
|
277
|
+
controls.className = "maximize-controls"
|
|
278
|
+
const view = mark("maximize-view", "maximize this diagram in the viewport",
|
|
279
|
+
[ "M15 3h6v6", "M21 3l-7 7", "M9 21H3v-6", "M3 21l7-7" ])
|
|
280
|
+
const full = mark("maximize-full", "maximize this diagram in fullscreen",
|
|
281
|
+
[ "M3 7V5a2 2 0 0 1 2-2h2", "M17 3h2a2 2 0 0 1 2 2v2",
|
|
282
|
+
"M21 17v2a2 2 0 0 1-2 2h-2", "M7 21H5a2 2 0 0 1-2-2v-2" ])
|
|
283
|
+
view.addEventListener("click", () => { open(diagram, false) })
|
|
284
|
+
full.addEventListener("click", () => { open(diagram, true) })
|
|
285
|
+
controls.append(view, full)
|
|
286
|
+
diagram.appendChild(controls)
|
|
287
|
+
})
|
|
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
|
+
export 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.fold-switch") === null
|
|
379
|
+
&& event.target.closest("div.realtime-status") === null)
|
|
380
|
+
toggle(false)
|
|
381
|
+
}
|
|
382
|
+
document.addEventListener("click", outside)
|
|
383
|
+
window.addEventListener("scroll", spy, { passive: true })
|
|
384
|
+
window.addEventListener("hashchange", anchor)
|
|
385
|
+
spy()
|
|
386
|
+
anchor()
|
|
387
|
+
})()
|
|
388
|
+
`;
|
|
389
|
+
/* the client-side script of the description popups: the info tab
|
|
390
|
+
toggles the popups (off by default, persisted across page loads),
|
|
391
|
+
and while they are on, a mouse resting 400ms on an element carrying
|
|
392
|
+
a "data-info" key (plus "data-info-prop" for a property) pops up the
|
|
393
|
+
schema description of its object kind (or property), while one
|
|
394
|
+
carrying a "data-info-spec" key pops up the corpus description of
|
|
395
|
+
the object instance (a diagram node box resolves through its
|
|
396
|
+
hyperlinked object anchor), titled with the object path (an instance
|
|
397
|
+
popup trailing the anchor id of the object) and fed from the
|
|
398
|
+
injected INFO/SPEC tables: as thousands of elements carry those
|
|
399
|
+
keys, they are just the table indices, and a title path is composed
|
|
400
|
+
from the SPEC table (the anchor id, parent index, kind, plain name,
|
|
401
|
+
and pre-rendered description HTML per object) instead of being
|
|
402
|
+
repeated on every element -- the path of an instance popup is the
|
|
403
|
+
one of its own object, while "data-info-path" names the object of a
|
|
404
|
+
schema popup, a trailing "^" keeping its last segment kind-only;
|
|
405
|
+
the popup is capped at 40% viewport width and attached above or
|
|
406
|
+
below, whichever side offers more space; the script runs at the
|
|
407
|
+
end of the body, so a live preview body swap replaces the popup
|
|
408
|
+
and the body-bound listeners along with it */
|
|
409
|
+
const infoPopupScript = textframe `
|
|
410
|
+
(function (INFO, SPEC) {
|
|
411
|
+
const popup = document.createElement("div")
|
|
412
|
+
popup.className = "info-popup"
|
|
413
|
+
document.body.appendChild(popup)
|
|
414
|
+
let enabled = false
|
|
415
|
+
try { enabled = localStorage.getItem("specbook-info") === "on" }
|
|
416
|
+
catch { /* an inaccessible storage just means no stored choice */ }
|
|
417
|
+
let current = null
|
|
418
|
+
let pending = null
|
|
419
|
+
let pendingId = 0
|
|
420
|
+
const cancel = () => {
|
|
421
|
+
clearTimeout(pendingId)
|
|
422
|
+
pending = null
|
|
423
|
+
}
|
|
424
|
+
const hide = () => {
|
|
425
|
+
popup.classList.remove("open")
|
|
426
|
+
current = null
|
|
427
|
+
}
|
|
428
|
+
const apply = () => {
|
|
429
|
+
document.body.classList.toggle("info-on", enabled)
|
|
430
|
+
if (!enabled) {
|
|
431
|
+
cancel()
|
|
432
|
+
hide()
|
|
433
|
+
}
|
|
434
|
+
}
|
|
435
|
+
apply()
|
|
436
|
+
document.querySelector("div.info-switch").addEventListener("click", () => {
|
|
437
|
+
enabled = !enabled
|
|
438
|
+
try { localStorage.setItem("specbook-info", enabled ? "on" : "off") }
|
|
439
|
+
catch { /* an inaccessible storage just loses the choice */ }
|
|
440
|
+
apply()
|
|
441
|
+
})
|
|
442
|
+
|
|
443
|
+
/* compose the title of a popup: the title path segments of the
|
|
444
|
+
object (the last one kind-only for a kind popup), the anchor
|
|
445
|
+
id trailing an instance popup, and the property name trailing
|
|
446
|
+
a property popup */
|
|
447
|
+
const titleOf = (source, spec, prop) => {
|
|
448
|
+
/* compose the title path segments of an object */
|
|
449
|
+
const pathOf = (n, named) => {
|
|
450
|
+
const [ , up, kind, title ] = SPEC[n]
|
|
451
|
+
const name = named ? title : ""
|
|
452
|
+
return [ ...(up >= 0 ? pathOf(up, true) : []),
|
|
453
|
+
name !== "" && kind !== "" ? kind + ": " + name : (name !== "" ? name : kind) ]
|
|
454
|
+
}
|
|
455
|
+
const title = document.createElement("div")
|
|
456
|
+
title.className = "info-title"
|
|
457
|
+
|
|
458
|
+
/* create a pointer, separating the title path segments */
|
|
459
|
+
const pointer = () => {
|
|
460
|
+
const span = document.createElement("span")
|
|
461
|
+
span.className = "info-pointer"
|
|
462
|
+
span.textContent = "▷"
|
|
463
|
+
return span
|
|
464
|
+
}
|
|
465
|
+
const ref = spec ?? source.getAttribute("data-info-path") ?? ""
|
|
466
|
+
const index = parseInt(ref, 10)
|
|
467
|
+
const segments = SPEC[index] !== undefined ? pathOf(index, !ref.endsWith("^")) : []
|
|
468
|
+
for (const [ i, segment ] of segments.entries()) {
|
|
469
|
+
if (i > 0)
|
|
470
|
+
title.appendChild(pointer())
|
|
471
|
+
const path = document.createElement("span")
|
|
472
|
+
path.className = "info-object"
|
|
473
|
+
|
|
474
|
+
/* a segment renders as in the document content (the kind
|
|
475
|
+
bold in the path color, the name semi-bold in the
|
|
476
|
+
description color), where a segment without a kind
|
|
477
|
+
prefix is the kind-only ending of a kind popup or the
|
|
478
|
+
name of a kind-less object */
|
|
479
|
+
const m = /^([^\\s:]+): (.*)$/.exec(segment)
|
|
480
|
+
const bare = i === segments.length - 1 && spec === null && prop === null
|
|
481
|
+
const name = document.createElement("span")
|
|
482
|
+
name.className = "info-name"
|
|
483
|
+
if (m !== null || bare) {
|
|
484
|
+
const kind = document.createElement("span")
|
|
485
|
+
kind.className = "info-kind"
|
|
486
|
+
kind.textContent = m !== null ? m[1] + ":" : segment
|
|
487
|
+
path.appendChild(kind)
|
|
488
|
+
if (m !== null) {
|
|
489
|
+
name.textContent = m[2]
|
|
490
|
+
path.append(" ", name)
|
|
491
|
+
}
|
|
492
|
+
}
|
|
493
|
+
else {
|
|
494
|
+
name.textContent = segment
|
|
495
|
+
path.appendChild(name)
|
|
496
|
+
}
|
|
497
|
+
title.appendChild(path)
|
|
498
|
+
}
|
|
499
|
+
|
|
500
|
+
/* the anchor id of an object instance (explicit or derived
|
|
501
|
+
from its name) trails the last segment */
|
|
502
|
+
const anchor = spec !== null ? SPEC[spec]?.[0] : undefined
|
|
503
|
+
if (anchor !== undefined) {
|
|
504
|
+
const id = document.createElement("span")
|
|
505
|
+
id.className = "info-id"
|
|
506
|
+
const name = document.createElement("span")
|
|
507
|
+
name.className = "info-id-name"
|
|
508
|
+
name.textContent = anchor
|
|
509
|
+
id.append(" (#", name, ")")
|
|
510
|
+
title.appendChild(id)
|
|
511
|
+
}
|
|
512
|
+
if (prop !== null) {
|
|
513
|
+
title.appendChild(pointer())
|
|
514
|
+
const name = document.createElement("span")
|
|
515
|
+
name.className = "info-property"
|
|
516
|
+
name.textContent = prop
|
|
517
|
+
title.appendChild(name)
|
|
518
|
+
}
|
|
519
|
+
return title
|
|
520
|
+
}
|
|
521
|
+
const show = (el) => {
|
|
522
|
+
/* a diagram node box carries no popup attributes itself, but
|
|
523
|
+
hyperlinks its object, whose heading name or table row name
|
|
524
|
+
carries the instance popup attributes */
|
|
525
|
+
let source = el
|
|
526
|
+
if (!el.hasAttribute("data-info") && !el.hasAttribute("data-info-spec")) {
|
|
527
|
+
const target = document.getElementById(
|
|
528
|
+
decodeURIComponent((el.getAttribute("href") ?? "").replace(/^#/, "")))
|
|
529
|
+
source = target === null ? null :
|
|
530
|
+
(target.hasAttribute("data-info-spec") ? target : target.querySelector("[data-info-spec]"))
|
|
531
|
+
if (source === null)
|
|
532
|
+
return
|
|
533
|
+
}
|
|
534
|
+
current = el
|
|
535
|
+
const key = source.getAttribute("data-info")
|
|
536
|
+
const spec = source.getAttribute("data-info-spec")
|
|
537
|
+
const prop = source.getAttribute("data-info-prop")
|
|
538
|
+
popup.classList.toggle("spec", spec !== null)
|
|
539
|
+
popup.replaceChildren(titleOf(source, spec, prop))
|
|
540
|
+
const entry = key !== null ? INFO[key] : undefined
|
|
541
|
+
const desc = prop !== null ? entry?.p?.[prop] :
|
|
542
|
+
(spec !== null ? SPEC[spec]?.[4] : entry?.d)
|
|
543
|
+
if (desc !== undefined && desc !== "") {
|
|
544
|
+
const text = document.createElement("div")
|
|
545
|
+
text.className = "info-desc"
|
|
546
|
+
text.innerHTML = desc
|
|
547
|
+
|
|
548
|
+
/* the origin label leads the description inline, so it is
|
|
549
|
+
hoisted into the leading paragraph (if there is one),
|
|
550
|
+
with its prefix word in a span of its own for the
|
|
551
|
+
rounded box styling */
|
|
552
|
+
const label = document.createElement("strong")
|
|
553
|
+
label.className = "info-label"
|
|
554
|
+
const origin = document.createElement("span")
|
|
555
|
+
origin.className = "info-origin"
|
|
556
|
+
origin.textContent = spec !== null ? "Specification" : "Schema"
|
|
557
|
+
label.append(origin, " ")
|
|
558
|
+
const lead = text.firstElementChild
|
|
559
|
+
if (lead !== null && lead.tagName === "P")
|
|
560
|
+
lead.insertBefore(label, lead.firstChild)
|
|
561
|
+
else
|
|
562
|
+
text.insertBefore(label, text.firstChild)
|
|
563
|
+
popup.appendChild(text)
|
|
564
|
+
}
|
|
565
|
+
|
|
566
|
+
/* measure the popup at the viewport origin first, as a
|
|
567
|
+
position near the right or bottom edge would clamp it */
|
|
568
|
+
popup.style.left = "0px"
|
|
569
|
+
popup.style.top = "0px"
|
|
570
|
+
popup.classList.add("open")
|
|
571
|
+
const rect = el.getBoundingClientRect()
|
|
572
|
+
const left = Math.max(8, Math.min(rect.left, window.innerWidth - popup.offsetWidth - 8))
|
|
573
|
+
const above = rect.top > window.innerHeight - rect.bottom
|
|
574
|
+
popup.style.left = left + "px"
|
|
575
|
+
popup.style.top = (above ? rect.top - popup.offsetHeight - 6 : rect.bottom + 6) + "px"
|
|
576
|
+
}
|
|
577
|
+
const targets = "[data-info], [data-info-spec], div.diagram a"
|
|
578
|
+
document.body.addEventListener("mouseover", (event) => {
|
|
579
|
+
if (!enabled)
|
|
580
|
+
return
|
|
581
|
+
const el = event.target.closest(targets)
|
|
582
|
+
if (el === null || el === current || el === pending)
|
|
583
|
+
return
|
|
584
|
+
cancel()
|
|
585
|
+
pending = el
|
|
586
|
+
pendingId = setTimeout(() => { pending = null; show(el) }, 400)
|
|
587
|
+
})
|
|
588
|
+
document.body.addEventListener("mouseout", (event) => {
|
|
589
|
+
const el = event.target.closest(targets)
|
|
590
|
+
if (el === null || (event.relatedTarget instanceof Node && el.contains(event.relatedTarget)))
|
|
591
|
+
return
|
|
592
|
+
if (el === pending)
|
|
593
|
+
cancel()
|
|
594
|
+
if (el === current)
|
|
595
|
+
hide()
|
|
596
|
+
})
|
|
597
|
+
})(@INFO@, @SPEC@)
|
|
598
|
+
`;
|
|
599
|
+
/* inject the description popup tables into their client-side script ("<"
|
|
600
|
+
escaped, so no embedded HTML can close the surrounding <script>
|
|
601
|
+
element, and the trailing table first, so a placeholder text
|
|
602
|
+
inside an injected table is never taken for the placeholder) */
|
|
603
|
+
export const infoScript = (info, spec) => infoPopupScript
|
|
604
|
+
.replace("@SPEC@", () => JSON.stringify(spec).replace(/</g, "\\u003c"))
|
|
605
|
+
.replace("@INFO@", () => JSON.stringify(info).replace(/</g, "\\u003c"));
|