@rse/specbook 1.0.3 → 1.1.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 (40) hide show
  1. package/README.md +106 -19
  2. package/dst/specbook-api.d.ts +29 -9
  3. package/dst/specbook-api.js +108 -33
  4. package/dst/specbook-cli.js +84 -25
  5. package/dst/specbook-cmd-describe.d.ts +7 -1
  6. package/dst/specbook-cmd-describe.js +56 -11
  7. package/dst/specbook-cmd-export.d.ts +2 -1
  8. package/dst/specbook-cmd-export.js +46 -3
  9. package/dst/specbook-cmd-lint.d.ts +2 -1
  10. package/dst/specbook-cmd-lint.js +12 -8
  11. package/dst/specbook-cmd-preview.d.ts +12 -0
  12. package/dst/specbook-cmd-preview.js +57 -0
  13. package/dst/specbook-config.d.ts +1 -1
  14. package/dst/specbook-config.js +187 -37
  15. package/dst/specbook-diagnostic.d.ts +2 -0
  16. package/dst/specbook-diagnostic.js +4 -2
  17. package/dst/specbook-diagram.d.ts +1 -0
  18. package/dst/specbook-diagram.js +0 -0
  19. package/dst/specbook-export-ast.js +4 -2
  20. package/dst/specbook-export-common.js +5 -3
  21. package/dst/specbook-export-html-search.js +118 -30
  22. package/dst/specbook-export-html.css +1140 -233
  23. package/dst/specbook-export-html.d.ts +1 -1
  24. package/dst/specbook-export-html.js +764 -120
  25. package/dst/specbook-export-md.js +4 -2
  26. package/dst/specbook-format-schema.d.ts +33 -1
  27. package/dst/specbook-format-schema.js +34 -2
  28. package/dst/specbook-format.md +193 -25
  29. package/dst/specbook-format.yaml +2291 -299
  30. package/dst/specbook-link.d.ts +2 -1
  31. package/dst/specbook-link.js +43 -3
  32. package/dst/specbook-mcp.js +19 -10
  33. package/dst/specbook-parse-common.d.ts +4 -2
  34. package/dst/specbook-parse-common.js +6 -3
  35. package/dst/specbook-parse-semantic.js +290 -30
  36. package/dst/specbook-parse-syntax.js +3 -1
  37. package/dst/specbook-parse-value.d.ts +1 -0
  38. package/dst/specbook-parse-value.js +17 -3
  39. package/dst/specbook-parse.js +1 -1
  40. package/package.json +14 -6
@@ -50,17 +50,31 @@ const templates = {
50
50
  </script>
51
51
  </head>
52
52
  <body>
53
- <div class="theme-switch" onclick="themeSwitch()" title="switch color theme">&#x25D0;</div>
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&hellip;" autocomplete="off" spellcheck="false"/>
60
+ <span class="search-clear" id="search-clear" title="clear search">&#x00D7;</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&hellip;" autocomplete="off" spellcheck="false"/>
77
- <span class="search-clear" id="search-clear" title="clear search">&#x00D7;</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">&#x26AD;</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">&#x26AD;</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">&#x2318;</span>{% endif %}{% if Object.paren %} <span class="anchor-paren">({{ Object.paren }})</span>{% endif %} <a href="#{{ Object.id }}"><span class="anchor-symbol">&#x2693;&#xFE0E;</span></a></h{{ Object.level }}>
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">&#x2318;</span>{% endif %}{% if Object.paren %} <span class="anchor-paren">({{ Object.paren }})</span>{% endif %} <a href="#{{ Object.id }}"><span class="anchor-symbol">&#x2693;&#xFE0E;</span></a></h{{ Object.level }}>
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">&#x2318;</span>{% endif %}{% if row.paren %} <span class="anchor-paren">({{ row.paren }})</span>{% endif %} <a href="#{{ row.id }}"><span class="anchor-symbol">&#x2693;&#xFE0E;</span></a></td>
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">&#x2318;</span>{% endif %}{% if row.paren %} <span class="anchor-paren">({{ row.paren }})</span>{% endif %} <a href="#{{ row.id }}"><span class="anchor-symbol">&#x2693;&#xFE0E;</span></a></td>
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">&#x2318;</span>{% endif %}{% if row.paren %} <span class="anchor-paren">({{ row.paren }})</span>{% endif %} <a href="#{{ row.id }}"><span class="anchor-symbol">&#x2693;&#xFE0E;</span></a></td>
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">&#x2318;</span>{% endif %}{% if row.paren %} <span class="anchor-paren">({{ row.paren }})</span>{% endif %} <a href="#{{ row.id }}"><span class="anchor-symbol">&#x2693;&#xFE0E;</span></a></td>
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, enum/tags property value kinds, object schema nodes,
234
- and pre-rendered diagram SVGs (all set during HTML rendering) */
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
- const inline = (text) => safe(marked.parseInline(linker !== null ? linker(text) : text, { async: false }));
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 "enum(...)"/"tags(...)" constrained properties of the
313
- schema configuration, keyed by object kind and property name */
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.kind === "enum" || expr.kind === "tags")
322
- result.set(`${schema.kind}${property.name}`, expr.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 moving its image embeddings behind the value, rendered from the
335
- embedded image contents (as for a description), not from the markup */
336
- const inlineValue = (kind, { key, value, embedding }) => {
337
- const member = members?.get(`${kind}${key}`);
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 (member === undefined || text === "")
851
+ if (expr === undefined || text === "")
344
852
  return safe(`${inline(text)}${embeddings}`);
345
- const items = member === "tags" ?
346
- splitItems(text).filter((item) => item !== "") : [text];
347
- return safe(items.map((item) => `<span class="value-member">${inline(item)}</span>`).join(" ") + embeddings);
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
- const inlineProperties = (kind, properties) => properties.map((property) => ({ key: property.key,
351
- value: inlineValue(kind, property) }));
352
- /* resolve the format configuration of an object */
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 an object (at
355
- least two, as a single column cannot carry a name and a value) */
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, with
358
- "withUnusedProps" injecting the defined but still unused schema
359
- properties (in schema order) as empty key/value entries */
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?.format?.withUnusedProps !== true)
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, with
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 (in schema order) as additional columns */
892
+ properties as additional columns */
373
893
  const tableShape = (childs) => {
374
- let keys = [...new Set(childs.flatMap((child) => child.properties.map((property) => property.key)))];
894
+ const used = [...new Set(childs.flatMap((child) => child.properties.map((property) => property.key)))];
375
895
  const schema = schemas?.get(childs[0]);
376
- if (schema?.format?.withUnusedProps === true) {
377
- const names = (schema.props ?? []).map((prop) => prop.name);
378
- keys = [...names, ...keys.filter((key) => !names.includes(key))];
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 the childs of an object collapse into the concise
387
- (tabular) rendering: an explicit "format" type wins, "auto" collapses
388
- the deepest level only, and inside an already concise rendering
389
- context "auto" childs implicitly stay concise, too */
390
- const conciseChilds = (object, schemaMap, concise) => {
391
- const childs = flowChilds(object);
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 || childs.every((child) => child.childs.length === 0);
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 childs as nested sub-tables, explicitly complex
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
- const childs = flowChilds(child);
418
- if (childs.length > 0)
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 instead chunks the property and
427
- description cells of every object into an embedded per-object table */
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
- if (1 + keys.length + (desc ? 1 : 0) <= maxColumns)
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
- values: keys.map((key) => {
444
- const property = child.properties.find((property) => property.key === key);
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
- const property = child.properties.find((property) => property.key === key);
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
- const last = chunks[chunks.length - 1];
470
- last[last.length - 1].span = size - last.length + 1;
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
- const diagram = diagrams?.get(object);
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: diagram !== undefined ? safe(`<div class="diagram">${diagram}</div>`) : "",
1037
+ diagram: diagramOf(object),
493
1038
  properties: properties.length > 0 ?
494
- safe(render("Properties", { Properties: inlineProperties(object.kind, properties) })) : "",
1039
+ safe(render("Properties", { Properties: inlineProperties(object, properties) })) : "",
495
1040
  description: object.description !== undefined ?
496
1041
  safe(renderDescription(object.description)) : "",
497
- childs: conciseChilds(object, schemas, concise) ?
498
- safe(groupChilds(flowChilds(object)).map((group) => renderTable(group, maxColumnsOf(object))).join("")) :
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.kind, rest) })) : "",
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 childs collapsing into compact tables */
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: conciseChilds(object, schemaMap, false) ? [] : flowChilds(object).map(entry)
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), and
568
- optional per-anchor page numbers attached to the ToC entries */
569
- export const renderHtml = async (specification, config, tocPages, css) => {
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
- rendered.set(object, svg.replace(/(<svg[^>]*) width="([0-9.]+)" height="([0-9.]+)"/, (_, head, w, h) => `${head} width="${Number(w) * scale}" height="${Number(h) * scale}"`));
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), targeting
601
- the fully-qualified anchor paths of the objects */
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
- linker = (text) => expandReferences(text, (reference) => {
608
- const target = resolveUnique(index, reference).target;
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
- return `<a href="#${escapeHtml(anchorOf(target))}">` +
612
- `<span class="object-kind">${escapeHtml(target.kind)}:</span> <strong>${target.name}</strong>` +
613
- " <span class=\"link-symbol\">&#x26AD;</span></a>";
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\">&#x26AD;</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 flow */
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: title !== undefined ? safe(searchScript()) : "",
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
  };