@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.
Files changed (45) hide show
  1. package/README.md +5 -2
  2. package/dst/specbook-api.js +7 -5
  3. package/dst/specbook-cli.js +0 -0
  4. package/dst/specbook-cmd-export.js +2 -2
  5. package/dst/specbook-coverage.js +1 -1
  6. package/dst/specbook-diagram.d.ts +1 -1
  7. package/dst/specbook-diagram.js +9 -3
  8. package/dst/specbook-export-ast.d.ts +1 -1
  9. package/dst/specbook-export-ast.js +21 -4
  10. package/dst/specbook-export-code.d.ts +14 -0
  11. package/dst/specbook-export-code.js +155 -0
  12. package/dst/specbook-export-common.d.ts +4 -2
  13. package/dst/specbook-export-common.js +110 -6
  14. package/dst/specbook-export-diagram.d.ts +15 -0
  15. package/dst/specbook-export-diagram.js +211 -0
  16. package/dst/specbook-export-html-fold.d.ts +20 -0
  17. package/dst/specbook-export-html-fold.js +458 -0
  18. package/dst/specbook-export-html-render.d.ts +51 -0
  19. package/dst/specbook-export-html-render.js +749 -0
  20. package/dst/specbook-export-html-search.js +6 -5
  21. package/dst/specbook-export-html-template.d.ts +21 -0
  22. package/dst/specbook-export-html-template.js +282 -0
  23. package/dst/specbook-export-html-widget.d.ts +12 -0
  24. package/dst/specbook-export-html-widget.js +605 -0
  25. package/dst/specbook-export-html.css +325 -50
  26. package/dst/specbook-export-html.js +62 -1931
  27. package/dst/specbook-export-image.d.ts +2 -0
  28. package/dst/specbook-export-image.js +161 -9
  29. package/dst/specbook-export-md.js +60 -12
  30. package/dst/specbook-format-schema.d.ts +1 -1
  31. package/dst/specbook-format-schema.js +1 -1
  32. package/dst/specbook-format-spec.d.ts +1 -0
  33. package/dst/specbook-format-spec.js +1 -0
  34. package/dst/specbook-format.md +169 -17
  35. package/dst/specbook-format.yaml +224 -23
  36. package/dst/specbook-ignore.js +2 -0
  37. package/dst/specbook-link.js +21 -5
  38. package/dst/specbook-parse-common.d.ts +24 -0
  39. package/dst/specbook-parse-common.js +137 -10
  40. package/dst/specbook-parse-semantic.js +2 -0
  41. package/dst/specbook-parse-syntax.js +81 -20
  42. package/dst/specbook-parse.js +1 -1
  43. package/dst/specbook-theme.d.ts +1 -0
  44. package/dst/specbook-theme.js +2 -0
  45. 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"));