@rse/specbook 1.2.1 → 1.2.3

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.
@@ -4,6 +4,7 @@
4
4
  ** Licensed under Apache 2.0 <https://spdx.org/licenses/Apache-2.0>
5
5
  */
6
6
  import fs from "node:fs";
7
+ import { plainText } from "./specbook-link.js";
7
8
  /* escape a text for embedding into template HTML (text and attributes) */
8
9
  export const escapeHtml = (text) => text.replace(/&/g, "&amp;").replace(/</g, "&lt;").replace(/>/g, "&gt;")
9
10
  .replace(/"/g, "&quot;").replace(/'/g, "&#39;");
@@ -128,24 +129,16 @@ const typographyGlyphs = [
128
129
  0x00A0, 0x00AB, 0x00BB, 0x2013, 0x2014, 0x2018, 0x2019,
129
130
  0x201A, 0x201C, 0x201D, 0x201E, 0x2022, 0x2026, 0x2039, 0x203A
130
131
  ];
131
- /* the memoized subsetted stylesheets, keyed by the (shared) codepoint
132
- set of the charset, as the font subsetting is expensive and the
133
- formats of a single export, the watch and preview re-exports, and
134
- the MCP service request the very same subset over and over again */
132
+ /* the memoized subsetted stylesheets (as pending or settled promises,
133
+ so concurrent requests share a single subsetting), keyed by the
134
+ (shared) codepoint set of the charset, as the font subsetting is
135
+ expensive and the formats of a single export, the watch and preview
136
+ re-exports, and the MCP service request the very same subset over
137
+ and over again */
135
138
  const subsetCache = new Map();
136
- /* provide the stylesheet with its embedded fonts subsetted to the
137
- codepoints of a charset plus the always-used symbol and typography
138
- glyphs (no charset or a full Unicode charset keeps the fonts complete) */
139
- export const subsetStylesheet = async (charset) => {
140
- const codepoints = charset !== undefined ? charsetCodepoints(charset) : undefined;
141
- if (codepoints === undefined)
142
- return stylesheet();
143
- const cached = subsetCache.get(codepoints);
144
- if (cached !== undefined)
145
- return cached;
146
- const css = stylesheet();
139
+ /* subset the embedded fonts of a stylesheet to the glyphs of a text */
140
+ const subsetFonts = async (css, text) => {
147
141
  const { default: subsetFont } = await import("subset-font");
148
- const text = String.fromCodePoint(...codepoints, ...symbolGlyphs, ...typographyGlyphs);
149
142
  let result = "";
150
143
  let last = 0;
151
144
  for (const m of css.matchAll(/url\("data:font\/woff2;base64,([^"]+)"\)/g)) {
@@ -154,18 +147,36 @@ export const subsetStylesheet = async (charset) => {
154
147
  `url("data:font/woff2;base64,${subset.toString("base64")}")`;
155
148
  last = m.index + m[0].length;
156
149
  }
157
- const subsetted = result + css.slice(last);
158
- subsetCache.set(codepoints, subsetted);
150
+ return result + css.slice(last);
151
+ };
152
+ /* provide the stylesheet with its embedded fonts subsetted to the
153
+ codepoints of a charset plus the always-used symbol and typography
154
+ glyphs (no charset or a full Unicode charset keeps the fonts complete),
155
+ where a failed subsetting leaves the cache, so the next request retries */
156
+ export const subsetStylesheet = async (charset) => {
157
+ const codepoints = charset !== undefined ? charsetCodepoints(charset) : undefined;
158
+ if (codepoints === undefined)
159
+ return stylesheet();
160
+ let subsetted = subsetCache.get(codepoints);
161
+ if (subsetted === undefined) {
162
+ const text = String.fromCodePoint(...codepoints, ...symbolGlyphs, ...typographyGlyphs);
163
+ subsetted = subsetFonts(stylesheet(), text);
164
+ subsetted.catch(() => { subsetCache.delete(codepoints); });
165
+ subsetCache.set(codepoints, subsetted);
166
+ }
159
167
  return subsetted;
160
168
  };
161
169
  /* determine the document title and subtitle from the title object (an
162
- empty TITLE counts as absent, exactly like for the title page) */
170
+ empty TITLE counts as absent, exactly like for the title page), with
171
+ the inline code markup stripped, as the plain-text targets (the HTML
172
+ <title>, the PDF metadata and page header) render no Markdown */
163
173
  export const documentTitle = (specification) => {
164
174
  const title = titleProperty(specification, "TITLE")?.trim();
175
+ const subtitle = titleProperty(specification, "SUBTITLE")?.trim();
165
176
  return {
166
- title: title !== undefined && title !== "" ? title :
167
- titleObject(specification)?.name ?? "Specification",
168
- subtitle: titleProperty(specification, "SUBTITLE")?.trim()
177
+ title: plainText(title !== undefined && title !== "" ? title :
178
+ titleObject(specification)?.name ?? "Specification"),
179
+ subtitle: subtitle !== undefined ? plainText(subtitle) : undefined
169
180
  };
170
181
  };
171
182
  /* determine the document logo as a self-contained data: URL, taken from the
@@ -81,6 +81,8 @@ html.theme-light {
81
81
  --theme-color-specbook-popup-schema-desc: var(--theme-color-base-32);
82
82
  --theme-color-specbook-popup-spec-path: var(--theme-color-accent-12);
83
83
  --theme-color-specbook-popup-spec-desc: var(--theme-color-base-5);
84
+ --theme-color-specbook-progress-done: var(--theme-color-accent-12);
85
+ --theme-color-specbook-progress-todo: var(--theme-color-accent-31);
84
86
  --gradia-font-family: "Source Sans 3";
85
87
  --gradia-color-node-regular-name: var(--theme-color-accent-12);
86
88
  --gradia-color-node-regular-box: var(--theme-color-accent-30);
@@ -122,6 +124,8 @@ html.theme-light {
122
124
  --theme-color-specbook-popup-schema-desc: var(--theme-color-base-7);
123
125
  --theme-color-specbook-popup-spec-path: var(--theme-color-accent-24);
124
126
  --theme-color-specbook-popup-spec-desc: var(--theme-color-base-29);
127
+ --theme-color-specbook-progress-done: var(--theme-color-accent-24);
128
+ --theme-color-specbook-progress-todo: var(--theme-color-accent-4);
125
129
  --gradia-color-node-regular-name: var(--theme-color-accent-21);
126
130
  --gradia-color-node-regular-box: var(--theme-color-accent-3);
127
131
  --gradia-color-node-regular-border: var(--theme-color-accent-7);
@@ -437,6 +441,64 @@ div.realtime-status.reloaded svg.realtime-icon {
437
441
  display: none;
438
442
  }
439
443
  }
444
+ /* the scroll progress meter (screen-only): a ring at the bottom right
445
+ corner of the viewport (fixed independent of the content scrolling),
446
+ whose DONE part (the arc drawn clockwise from the top, together with
447
+ the upward arrow in its center, drawn like the tab icons) reflects
448
+ the scrolled fraction of the document over the TODO part (the full
449
+ circle in the lighter progress color beneath), which stays hidden
450
+ while the page is scrolled near the top (fading in once scrolled
451
+ beyond 400px) and scrolls the page back to the top on click, where
452
+ the DONE part and the arrow brighten on hover like the tabs */
453
+ div.scroll-progress {
454
+ position: fixed;
455
+ right: 1.5rem;
456
+ bottom: 1.5rem;
457
+ z-index: 30;
458
+ width: 3rem;
459
+ height: 3rem;
460
+ line-height: 0;
461
+ color: var(--theme-color-specbook-progress-done);
462
+ opacity: 0;
463
+ pointer-events: none;
464
+ transition: opacity 0.3s ease;
465
+ cursor: pointer;
466
+ -webkit-user-select: none;
467
+ user-select: none;
468
+ }
469
+ div.scroll-progress.shown {
470
+ opacity: 1;
471
+ pointer-events: auto;
472
+ }
473
+ div.scroll-progress:hover {
474
+ color: var(--theme-color-specbook-accent-soft);
475
+ }
476
+ div.scroll-progress svg.scroll-ring {
477
+ position: absolute;
478
+ inset: 0;
479
+ width: 100%;
480
+ height: 100%;
481
+ transform: rotate(-90deg);
482
+ }
483
+ div.scroll-progress svg.scroll-ring circle.scroll-todo {
484
+ stroke: var(--theme-color-specbook-progress-todo);
485
+ }
486
+ div.scroll-progress svg.scroll-ring circle.scroll-done {
487
+ stroke: currentColor;
488
+ }
489
+ div.scroll-progress svg.scroll-icon {
490
+ position: absolute;
491
+ top: 50%;
492
+ left: 50%;
493
+ width: 1.1rem;
494
+ height: 1.1rem;
495
+ transform: translate(-50%, -50%);
496
+ }
497
+ @media print {
498
+ div.scroll-progress {
499
+ display: none;
500
+ }
501
+ }
440
502
  /* the side panel of the table of contents (screen-only): a fixed
441
503
  full-height drawer at the left viewport edge, carrying a replica of
442
504
  the brand bar on its right edge, which exactly covers the brand bar
@@ -8,4 +8,5 @@ export type OutlineEntry = {
8
8
  };
9
9
  export declare const htmlOutline: (specification: Spec, config?: Schema) => OutlineEntry[];
10
10
  export declare const titlePageObject: (specification: Spec) => SpecObject | undefined;
11
+ export declare const renderPlaceholder: (message: string) => string;
11
12
  export declare const renderHtml: (specification: Spec, config?: Schema, tocPages?: Map<string, number>, css?: string, realtime?: boolean, verbose?: Verbose) => Promise<string>;
@@ -29,24 +29,7 @@ const templates = {
29
29
  {{ Document.css }}
30
30
  </style>
31
31
  <script>
32
- (function () {
33
- let style = null
34
- try { style = localStorage.getItem("specbook-theme") }
35
- catch { /* an inaccessible storage just means no stored choice */ }
36
- if (style === null) {
37
- const m = document.documentElement.className.match(/theme-(light|dark)/)
38
- style = m !== null ? m[1] : null
39
- }
40
- if (style === null)
41
- style = window.matchMedia("(prefers-color-scheme: dark)").matches ? "dark" : "light"
42
- document.documentElement.className = "theme-" + style
43
- })()
44
- function themeSwitch () {
45
- const style = document.documentElement.className === "theme-dark" ? "light" : "dark"
46
- document.documentElement.className = "theme-" + style
47
- try { localStorage.setItem("specbook-theme", style) }
48
- catch { /* an inaccessible storage just loses the choice */ }
49
- }
32
+ {{ Document.themescript }}
50
33
  </script>
51
34
  </head>
52
35
  <body>
@@ -60,18 +43,40 @@ const templates = {
60
43
  <span class="search-clear" id="search-clear" title="clear search">&#x00D7;</span>
61
44
  </div>
62
45
  </div>
46
+ <div class="scroll-progress" title="scroll to top"><svg class="scroll-ring" viewBox="0 0 44 44" fill="none" stroke-width="2.5"><circle class="scroll-todo" cx="22" cy="22" r="20"/><circle class="scroll-done" cx="22" cy="22" r="20" stroke-linecap="round" pathLength="1" stroke-dasharray="1" stroke-dashoffset="1"/></svg><svg class="scroll-icon" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5" stroke-linecap="round" stroke-linejoin="round"><path d="M12 19V5"/><path d="M5 12l7-7 7 7"/></svg></div>
63
47
  {{ Document.tocpanel }}
64
48
  {{ Document.titlepage }}
65
49
  {{ Document.toc }}
66
50
  {{ Document.doc }}
67
51
  {{ Document.artifacts }}
68
52
  {% if Document.search %}<script>{{ Document.search }}</script>{% endif %}
53
+ <script>{{ Document.progress }}</script>
69
54
  {% if Document.tocpanel %}<script>{{ Document.tocscript }}</script>{% endif %}
70
55
  {% if Document.info %}<script>{{ Document.info }}</script>{% endif %}
71
56
  {% if Document.realtime %}<script class="realtime">{{ Document.realtime }}</script>{% endif %}
72
57
  </body>
73
58
  </html>
74
59
  `,
60
+ /* <Placeholder/> */
61
+ "Placeholder": textframe `
62
+ <!DOCTYPE html>
63
+ <html>
64
+ <head>
65
+ <meta charset="utf-8"/>
66
+ <title>{{ Placeholder.title }}</title>
67
+ <style>
68
+ {{ Placeholder.css }}
69
+ </style>
70
+ <script>
71
+ {{ Placeholder.themescript }}
72
+ </script>
73
+ </head>
74
+ <body>
75
+ <div class="placeholder">{{ Placeholder.message }}</div>
76
+ <script class="realtime">{{ Placeholder.realtime }}</script>
77
+ </body>
78
+ </html>
79
+ `,
75
80
  /* <TitlePage/> */
76
81
  "TitlePage": textframe `
77
82
  <div class="titlepage" id="titlepage">
@@ -213,8 +218,43 @@ const templates = {
213
218
  </table>
214
219
  `
215
220
  };
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
221
+ /* the client-side script of the color theme: it applies the stored
222
+ choice (or, without one, the document default and finally the system
223
+ preference) before the first paint and toggles it on demand. The
224
+ live preview placeholder page carries it, too, as an in-place
225
+ document update never re-executes a head script */
226
+ const themeScript = textframe `
227
+ (function () {
228
+ let style = null
229
+ try { style = localStorage.getItem("specbook-theme") }
230
+ catch { /* an inaccessible storage just means no stored choice */ }
231
+ if (style === null) {
232
+ const m = document.documentElement.className.match(/theme-(light|dark)/)
233
+ style = m !== null ? m[1] : null
234
+ }
235
+ if (style === null)
236
+ style = window.matchMedia("(prefers-color-scheme: dark)").matches ? "dark" : "light"
237
+ document.documentElement.className = "theme-" + style
238
+ })()
239
+ function themeSwitch () {
240
+ const style = document.documentElement.className === "theme-dark" ? "light" : "dark"
241
+ document.documentElement.className = "theme-" + style
242
+ try { localStorage.setItem("specbook-theme", style) }
243
+ catch { /* an inaccessible storage just loses the choice */ }
244
+ }
245
+ `;
246
+ /* the minimal stylesheet of the live preview placeholder page: it also
247
+ provides the <style> element the in-place document update swaps with
248
+ the real stylesheet, so it carries the centered message only */
249
+ const placeholderStylesheet = textframe `
250
+ html.theme-light body { color: #333333; background-color: #ffffff }
251
+ html.theme-dark body { color: #cccccc; background-color: #1a1a1a }
252
+ body { font-family: sans-serif; margin: 0 }
253
+ div.placeholder { position: absolute; top: 50%; left: 0; right: 0;
254
+ transform: translateY(-50%); text-align: center; opacity: 0.6 }
255
+ `;
256
+ /* the client-side script of the live preview: it connects back to
257
+ its own page over WebSocket (the URL scheme "http"/"https" replaced with
218
258
  "ws"/"wss"), re-connects every second after a lost connection, and
219
259
  updates the page on a received "RELOAD" command as well as on every
220
260
  re-established connection (as the server was restarted meanwhile).
@@ -237,7 +277,11 @@ const realtimeScript = textframe `
237
277
  icon.className = "realtime-status " + state
238
278
  }
239
279
  let blink = 0
280
+ let seq = 0
240
281
  const update = async () => {
282
+ /* a superseded update (an overlapping newer one started
283
+ meanwhile) is dropped, as its stale page could arrive last */
284
+ const mine = ++seq
241
285
  let response
242
286
  try {
243
287
  response = await fetch(window.location.pathname, { cache: "no-store" })
@@ -248,7 +292,10 @@ const realtimeScript = textframe `
248
292
  }
249
293
  if (!response.ok)
250
294
  return
251
- const doc = new DOMParser().parseFromString(await response.text(), "text/html")
295
+ const html = await response.text()
296
+ if (mine !== seq)
297
+ return
298
+ const doc = new DOMParser().parseFromString(html, "text/html")
252
299
  const x = window.scrollX
253
300
  const y = window.scrollY
254
301
  document.title = doc.title
@@ -294,6 +341,47 @@ const realtimeScript = textframe `
294
341
  connect()
295
342
  })()
296
343
  `;
344
+ /* the client-side script of the scroll progress meter: it drives the
345
+ DONE arc of the ring (through the dash offset of its unit-length
346
+ circle) with the scrolled fraction of the document, shows the meter
347
+ once the page is scrolled beyond 400px, and scrolls the page back
348
+ to the top on a click (stripping the URL hash, so a later reload
349
+ stays at the top). The updates are throttled onto animation frames,
350
+ and the window-bound listeners retire themselves once a live preview
351
+ body swap replaced the meter (whose fresh script re-attaches) */
352
+ const scrollProgressScript = textframe `
353
+ (function () {
354
+ const meter = document.querySelector("div.scroll-progress")
355
+ const ring = meter.querySelector("circle.scroll-done")
356
+ let ticking = false
357
+ const update = () => {
358
+ ticking = false
359
+ if (!meter.isConnected) {
360
+ window.removeEventListener("scroll", schedule)
361
+ window.removeEventListener("resize", schedule)
362
+ return
363
+ }
364
+ const doc = document.documentElement
365
+ const max = doc.scrollHeight - doc.clientHeight
366
+ ring.setAttribute("stroke-dashoffset", String(1 - (max > 0 ? doc.scrollTop / max : 0)))
367
+ meter.classList.toggle("shown", doc.scrollTop > 400)
368
+ }
369
+ const schedule = () => {
370
+ if (!ticking) {
371
+ ticking = true
372
+ requestAnimationFrame(update)
373
+ }
374
+ }
375
+ meter.addEventListener("click", () => {
376
+ window.scrollTo({ top: 0, behavior: "smooth" })
377
+ if (window.location.hash)
378
+ history.replaceState(null, "", window.location.pathname + window.location.search)
379
+ })
380
+ window.addEventListener("scroll", schedule, { passive: true })
381
+ window.addEventListener("resize", schedule)
382
+ update()
383
+ })()
384
+ `;
297
385
  /* the client-side script of the table of contents side panel: it
298
386
  slides the panel out of and back into the viewport edge (closing it
299
387
  also on a jump, on "Escape", and on a click outside), remembers the
@@ -618,8 +706,19 @@ const env = new nunjucks.Environment(null, { autoescape: true });
618
706
  nunjucksAddons(env);
619
707
  /* mark pre-rendered HTML as safe for template interpolation */
620
708
  const safe = (html) => new nunjucks.runtime.SafeString(html);
709
+ /* the built-in Nunjucks templates compiled on first use, as
710
+ renderString() would re-compile a template on every single call
711
+ (i.e. for every rendered object, property table, and description) */
712
+ const compiled = new Map();
621
713
  /* render one of the built-in Nunjucks templates with a context */
622
- const render = (name, context) => env.renderString(templates[name], context);
714
+ const render = (name, context) => {
715
+ let template = compiled.get(name);
716
+ if (template === undefined) {
717
+ template = new nunjucks.Template(templates[name], env, undefined, true);
718
+ compiled.set(name, template);
719
+ }
720
+ return template.render(context);
721
+ };
623
722
  /* the active per-document reference expander, fully-qualified
624
723
  anchor paths, member-carrying property value constraints, object
625
724
  schema nodes, pre-rendered diagram SVGs, description popup keys
@@ -944,8 +1043,8 @@ const diagramOf = (object) => {
944
1043
  headers of a plain table */
945
1044
  const renderTable = (childs, maxColumns) => {
946
1045
  const { keys, desc } = tableShape(childs);
947
- const diagrams = childs.some((child) => diagramOf(child) !== "");
948
- if (!diagrams && 1 + keys.length + (desc ? 1 : 0) <= maxColumns)
1046
+ const diagrammed = childs.some((child) => diagramOf(child) !== "");
1047
+ if (!diagrammed && 1 + keys.length + (desc ? 1 : 0) <= maxColumns)
949
1048
  return render("Table", { Table: {
950
1049
  head: childs[0].kind !== "" ? childs[0].kind : "Name",
951
1050
  info: infoKeyOf(childs[0]),
@@ -1179,6 +1278,19 @@ const makeLinker = (index) => (text, compact) => expandReferences(text, (referen
1179
1278
  `<a href="#${escapeHtml(anchorOf(target))}" class="link-compact"${specAttr(target)}>${target.name}</a>` :
1180
1279
  `<a href="#${escapeHtml(anchorOf(target))}" class="link-full">${full}</a>`;
1181
1280
  });
1281
+ /* render the placeholder page of the live preview, served instead of
1282
+ the document before the first successful export: it carries the very
1283
+ same client-side script as the regular export, so the page recovers
1284
+ on its own -- as the usual in-place document update -- once the
1285
+ specification becomes exportable, plus a <style> element and the
1286
+ color theme script the update expects to find in the head */
1287
+ export const renderPlaceholder = (message) => render("Placeholder", { Placeholder: {
1288
+ title: "SpecBook",
1289
+ css: safe(placeholderStylesheet),
1290
+ themescript: safe(themeScript),
1291
+ realtime: safe(realtimeScript),
1292
+ message
1293
+ } });
1182
1294
  /* render the entire specification into a self-contained HTML document,
1183
1295
  with the build-time pre-assembled stylesheet embedded inline, the
1184
1296
  artifact timestamps aggregated into min(Created)/max(Modified),
@@ -1191,47 +1303,48 @@ export const renderHtml = async (specification, config, tocPages, css, realtime
1191
1303
  /* the document language selects the smart typography quote style */
1192
1304
  const lang = documentLang(specification);
1193
1305
  quotes = quoteStyles[lang?.toLowerCase().split(/[-_]/)[0] ?? "en"] ?? quoteStyles.en;
1194
- /* establish the per-document rendering state */
1306
+ /* establish the per-document rendering state (released again
1307
+ below, also on a failure of the collecting or rendering) */
1195
1308
  const index = buildLinkIndex(specification);
1196
- anchors = anchorPaths(index);
1197
- members = config !== undefined ? collectMembers(config, new Map()) : null;
1198
- schemas = config !== undefined ? collectSchemas(specification, config) : null;
1199
- diagrams = rendered;
1200
- /* collect the schema descriptions for the description popups,
1201
- plus the object parents composing their title paths */
1202
- let info = null;
1203
- if (config !== undefined) {
1204
- infoKeys = new Map();
1205
- info = {};
1206
- collectInfo(config, "", infoKeys, info);
1207
- const parents = new Map();
1208
- for (const node of index)
1209
- if (node.parent !== undefined)
1210
- parents.set(node.object, node.parent.object);
1211
- infoParents = parents;
1212
- }
1213
- linker = makeLinker(index);
1214
- /* collect the corpus descriptions of the object instances for the
1215
- description popups (after the linker is in place, as the
1216
- pre-rendered descriptions expand their references, too) */
1217
- let spec = null;
1218
- if (info !== null) {
1219
- spec = {};
1220
- for (const artifact of specification.artifacts)
1221
- collectSpec(artifact.objects, spec);
1222
- }
1223
- /* the artifact timestamps aggregate into the earliest creation
1224
- and the latest modification timestamp of the document */
1225
- const created = new Date(Math.min(...specification.artifacts.map((artifact) => artifact.created.getTime())));
1226
- const modified = new Date(Math.max(...specification.artifacts.map((artifact) => artifact.modified.getTime())));
1227
- /* a "META: Title" object becomes the title page and leaves the regular
1228
- flow, while its diagram becomes the "Diagram of Contents" page */
1229
- const title = titlePageObject(specification);
1230
- const artifacts = specification.artifacts
1231
- .filter((artifact) => !artifact.objects.some(isTitleObject));
1232
- const meta = titleObject(specification);
1233
- const doc = meta !== undefined ? rendered?.get(meta) : undefined;
1234
1309
  try {
1310
+ anchors = anchorPaths(index);
1311
+ members = config !== undefined ? collectMembers(config, new Map()) : null;
1312
+ schemas = config !== undefined ? collectSchemas(specification, config) : null;
1313
+ diagrams = rendered;
1314
+ /* collect the schema descriptions for the description popups,
1315
+ plus the object parents composing their title paths */
1316
+ let info = null;
1317
+ if (config !== undefined) {
1318
+ infoKeys = new Map();
1319
+ info = {};
1320
+ collectInfo(config, "", infoKeys, info);
1321
+ const parents = new Map();
1322
+ for (const node of index)
1323
+ if (node.parent !== undefined)
1324
+ parents.set(node.object, node.parent.object);
1325
+ infoParents = parents;
1326
+ }
1327
+ linker = makeLinker(index);
1328
+ /* collect the corpus descriptions of the object instances for the
1329
+ description popups (after the linker is in place, as the
1330
+ pre-rendered descriptions expand their references, too) */
1331
+ let spec = null;
1332
+ if (info !== null) {
1333
+ spec = {};
1334
+ for (const artifact of specification.artifacts)
1335
+ collectSpec(artifact.objects, spec);
1336
+ }
1337
+ /* the artifact timestamps aggregate into the earliest creation
1338
+ and the latest modification timestamp of the document */
1339
+ const created = new Date(Math.min(...specification.artifacts.map((artifact) => artifact.created.getTime())));
1340
+ const modified = new Date(Math.max(...specification.artifacts.map((artifact) => artifact.modified.getTime())));
1341
+ /* a "META: Title" object becomes the title page and leaves the regular
1342
+ flow, while its diagram becomes the "Diagram of Contents" page */
1343
+ const title = titlePageObject(specification);
1344
+ const artifacts = specification.artifacts
1345
+ .filter((artifact) => !artifact.objects.some(isTitleObject));
1346
+ const meta = titleObject(specification);
1347
+ const doc = meta !== undefined ? rendered?.get(meta) : undefined;
1235
1348
  const objects = artifacts.flatMap((artifact) => artifact.objects);
1236
1349
  const entries = tocEntries(objects, tocPages);
1237
1350
  return render("Document", { Document: {
@@ -1239,9 +1352,11 @@ export const renderHtml = async (specification, config, tocPages, css, realtime
1239
1352
  lang,
1240
1353
  theme: documentThemeStyle(specification)?.toLowerCase(),
1241
1354
  css: safe(css ?? stylesheet()),
1355
+ themescript: safe(themeScript),
1242
1356
  titlepage: title !== undefined ?
1243
1357
  safe(renderTitlePage(title, formatDate(created), formatDate(modified))) : "",
1244
1358
  search: safe(searchScript()),
1359
+ progress: safe(scrollProgressScript),
1245
1360
  info: info !== null ? safe(infoScript(info, spec ?? {})) : "",
1246
1361
  realtime: realtime ? safe(realtimeScript) : "",
1247
1362
  toc: entries.length > 0 ? safe(render("Toc", { Toc: { entries } })) : "",
@@ -33,13 +33,15 @@ const renderDescriptionMd = (description) => description.description + (descript
33
33
  const nameSuffixMd = (object) => (object.primary === true ? " (*)" : "") +
34
34
  (object.paren !== undefined ? ` (${object.paren})` : "") +
35
35
  (object.anchor !== undefined ? ` {{${object.anchor}}}` : "");
36
- /* whether any descendant of an object carries a description which no
37
- Concise Format item can carry -- a multi-line one (a fenced code
38
- block, an ordered list, a blockquote, or multiple paragraphs) or one
39
- carrying a ";", as only a ";"-free description stays a single segment
40
- on re-parsing instead of splitting off a spurious property */
41
- const complexBelowMd = (object) => object.childs.some((child) => (child.description !== undefined
42
- && /[\n;]/.test(renderDescriptionMd(child.description))) || complexBelowMd(child));
36
+ /* whether any descendant of an object carries a name or a description
37
+ which no Concise Format item can carry -- a name carrying a ";" (it
38
+ would split the head segment), or a multi-line description (a fenced
39
+ code block, an ordered list, a blockquote, or multiple paragraphs) or
40
+ one carrying a ";", as only a ";"-free description stays a single
41
+ segment on re-parsing instead of splitting off a spurious property */
42
+ const complexBelowMd = (object) => object.childs.some((child) => child.name.includes(";")
43
+ || (child.description !== undefined
44
+ && /[\n;]/.test(renderDescriptionMd(child.description))) || complexBelowMd(child));
43
45
  /* render an object in the Concise Format as a list item of the
44
46
  given nesting depth, with its childs as nested list items */
45
47
  const renderConciseMd = (object, depth = 0) => {
@@ -39,8 +39,10 @@ const addOutline = async (doc, entries) => {
39
39
  if (dests !== undefined) {
40
40
  for (const [name] of dests.entries()) {
41
41
  const dest = dests.lookup(name);
42
- const page = dest instanceof PDFArray ? dest.get(0) : undefined;
43
- if (dest instanceof PDFArray && page instanceof PDFRef && live.has(page.toString()))
42
+ if (!(dest instanceof PDFArray))
43
+ continue;
44
+ const page = dest.get(0);
45
+ if (page instanceof PDFRef && live.has(page.toString()))
44
46
  targets.set(name.decodeText(), dest);
45
47
  }
46
48
  }
@@ -168,6 +170,10 @@ export const requireBrowser = async (verbose) => {
168
170
  `"${literal("npx playwright install chromium")}" once to download the Playwright Chromium`);
169
171
  return options;
170
172
  };
173
+ /* the bound of the ToC page number fixpoint iteration, as the page
174
+ number column itself shifts the pagination and hence the iteration
175
+ can oscillate instead of ever reaching a stable rendering */
176
+ const tocPasses = 3;
171
177
  /* render a self-contained HTML document into a PDF via Playwright,
172
178
  re-rendering the HTML with the discovered ToC page numbers */
173
179
  export const htmlToPdf = async (renderHtmlPass, heading, outline, titlePage, verbose, css, theme, paper = paperSizeDefault) => {
@@ -198,19 +204,27 @@ export const htmlToPdf = async (renderHtmlPass, heading, outline, titlePage, ver
198
204
  };
199
205
  /* determine the ToC page numbers via a fixpoint iteration:
200
206
  paginate undecorated, extract the per-anchor pages, and
201
- re-render until the HTML is stable (bounded to three
207
+ re-render until the HTML is stable (bounded to "tocPasses"
202
208
  passes), as the ToC page number column itself can shift
203
209
  the pagination */
204
210
  verbose("determining ToC page numbers");
205
211
  let html = await renderHtmlPass();
206
212
  let plain = await renderPdf(html);
207
- for (let i = 0; i < 3; i++) {
213
+ let stable = false;
214
+ for (let i = 0; i < tocPasses && !stable; i++) {
208
215
  const next = await renderHtmlPass(await anchorPages(plain));
209
- if (next === html)
210
- break;
211
- html = next;
212
- plain = await renderPdf(html);
216
+ stable = next === html;
217
+ if (!stable) {
218
+ html = next;
219
+ plain = await renderPdf(html);
220
+ }
213
221
  }
222
+ /* report an unconverged iteration, as the ToC then carries the
223
+ page numbers of a pagination other than the final one and
224
+ would otherwise ship silently wrong page numbers */
225
+ if (!stable)
226
+ verbose(`the ToC page numbers did not stabilize within ${literal(tocPasses)} ` +
227
+ "passes -- they can be off by a page", "notice");
214
228
  /* render the final document, decorated with header/footer */
215
229
  const decorated = await renderPdf(html, {
216
230
  displayHeaderFooter: true,
@@ -16,6 +16,8 @@ const GradiaConfigSchemas = Object.fromEntries(Object.entries(Gradia.config).map
16
16
  typeof value === "number" ? v.pipe(v.number(), v.finite(), v.minValue(0)) : v.string()]));
17
17
  const SchemaDiagramConfig = v.partial(v.strictObject(GradiaConfigSchemas, (issue) => issue.expected === "never" ? "unknown Gradia rendering option" :
18
18
  "expected a map of Gradia rendering options"));
19
+ /* the Valibot schemas mirroring the types above, with the object
20
+ kind schema referencing itself lazily for the nested childs */
19
21
  const SchemaProperty = v.strictObject({
20
22
  name: v.string(),
21
23
  desc: v.optional(v.string()),
@@ -5,6 +5,8 @@
5
5
  */
6
6
  import * as v from "valibot";
7
7
  /* ==== Schema ==== */
8
+ /* the Valibot schemas mirroring the types above, with the object
9
+ schema referencing itself lazily for the nested childs */
8
10
  const SpecProperty = v.object({
9
11
  key: v.string(),
10
12
  value: v.string(),
@@ -416,8 +416,9 @@ type SchemaGradiaConfig = Partial<{
416
416
 
417
417
  - `SchemaProperty.present?: boolean | string`:
418
418
  whether some value (`true`) or a value matching a regexp or enum
419
- expression has to occur on at least one of the (existing) sibling
420
- objects of the kind, so that `unique` plus `present` demand exactly one,
419
+ expression has to occur on at least one sibling object of the kind,
420
+ so that `unique` plus `present` demand exactly one and `present` on
421
+ an optional kind demands at least one object of that kind,
421
422
  BECAUSE some markers, like an initial state, are not just single but indispensable
422
423
 
423
424
  - `SchemaProperty.local?: boolean`: