@rse/specbook 1.2.2 → 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.
@@ -43,12 +43,14 @@ const templates = {
43
43
  <span class="search-clear" id="search-clear" title="clear search">&#x00D7;</span>
44
44
  </div>
45
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>
46
47
  {{ Document.tocpanel }}
47
48
  {{ Document.titlepage }}
48
49
  {{ Document.toc }}
49
50
  {{ Document.doc }}
50
51
  {{ Document.artifacts }}
51
52
  {% if Document.search %}<script>{{ Document.search }}</script>{% endif %}
53
+ <script>{{ Document.progress }}</script>
52
54
  {% if Document.tocpanel %}<script>{{ Document.tocscript }}</script>{% endif %}
53
55
  {% if Document.info %}<script>{{ Document.info }}</script>{% endif %}
54
56
  {% if Document.realtime %}<script class="realtime">{{ Document.realtime }}</script>{% endif %}
@@ -251,8 +253,8 @@ const placeholderStylesheet = textframe `
251
253
  div.placeholder { position: absolute; top: 50%; left: 0; right: 0;
252
254
  transform: translateY(-50%); text-align: center; opacity: 0.6 }
253
255
  `;
254
- /* the client-side script of the live preview: it connects back to the
255
- own page over WebSocket (the URL scheme "http"/"https" replaced with
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
256
258
  "ws"/"wss"), re-connects every second after a lost connection, and
257
259
  updates the page on a received "RELOAD" command as well as on every
258
260
  re-established connection (as the server was restarted meanwhile).
@@ -275,7 +277,11 @@ const realtimeScript = textframe `
275
277
  icon.className = "realtime-status " + state
276
278
  }
277
279
  let blink = 0
280
+ let seq = 0
278
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
279
285
  let response
280
286
  try {
281
287
  response = await fetch(window.location.pathname, { cache: "no-store" })
@@ -286,7 +292,10 @@ const realtimeScript = textframe `
286
292
  }
287
293
  if (!response.ok)
288
294
  return
289
- 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")
290
299
  const x = window.scrollX
291
300
  const y = window.scrollY
292
301
  document.title = doc.title
@@ -332,6 +341,47 @@ const realtimeScript = textframe `
332
341
  connect()
333
342
  })()
334
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
+ `;
335
385
  /* the client-side script of the table of contents side panel: it
336
386
  slides the panel out of and back into the viewport edge (closing it
337
387
  also on a jump, on "Escape", and on a click outside), remembers the
@@ -656,8 +706,19 @@ const env = new nunjucks.Environment(null, { autoescape: true });
656
706
  nunjucksAddons(env);
657
707
  /* mark pre-rendered HTML as safe for template interpolation */
658
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();
659
713
  /* render one of the built-in Nunjucks templates with a context */
660
- 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
+ };
661
722
  /* the active per-document reference expander, fully-qualified
662
723
  anchor paths, member-carrying property value constraints, object
663
724
  schema nodes, pre-rendered diagram SVGs, description popup keys
@@ -982,8 +1043,8 @@ const diagramOf = (object) => {
982
1043
  headers of a plain table */
983
1044
  const renderTable = (childs, maxColumns) => {
984
1045
  const { keys, desc } = tableShape(childs);
985
- const diagrams = childs.some((child) => diagramOf(child) !== "");
986
- 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)
987
1048
  return render("Table", { Table: {
988
1049
  head: childs[0].kind !== "" ? childs[0].kind : "Name",
989
1050
  info: infoKeyOf(childs[0]),
@@ -1242,47 +1303,48 @@ export const renderHtml = async (specification, config, tocPages, css, realtime
1242
1303
  /* the document language selects the smart typography quote style */
1243
1304
  const lang = documentLang(specification);
1244
1305
  quotes = quoteStyles[lang?.toLowerCase().split(/[-_]/)[0] ?? "en"] ?? quoteStyles.en;
1245
- /* 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) */
1246
1308
  const index = buildLinkIndex(specification);
1247
- anchors = anchorPaths(index);
1248
- members = config !== undefined ? collectMembers(config, new Map()) : null;
1249
- schemas = config !== undefined ? collectSchemas(specification, config) : null;
1250
- diagrams = rendered;
1251
- /* collect the schema descriptions for the description popups,
1252
- plus the object parents composing their title paths */
1253
- let info = null;
1254
- if (config !== undefined) {
1255
- infoKeys = new Map();
1256
- info = {};
1257
- collectInfo(config, "", infoKeys, info);
1258
- const parents = new Map();
1259
- for (const node of index)
1260
- if (node.parent !== undefined)
1261
- parents.set(node.object, node.parent.object);
1262
- infoParents = parents;
1263
- }
1264
- linker = makeLinker(index);
1265
- /* collect the corpus descriptions of the object instances for the
1266
- description popups (after the linker is in place, as the
1267
- pre-rendered descriptions expand their references, too) */
1268
- let spec = null;
1269
- if (info !== null) {
1270
- spec = {};
1271
- for (const artifact of specification.artifacts)
1272
- collectSpec(artifact.objects, spec);
1273
- }
1274
- /* the artifact timestamps aggregate into the earliest creation
1275
- and the latest modification timestamp of the document */
1276
- const created = new Date(Math.min(...specification.artifacts.map((artifact) => artifact.created.getTime())));
1277
- const modified = new Date(Math.max(...specification.artifacts.map((artifact) => artifact.modified.getTime())));
1278
- /* a "META: Title" object becomes the title page and leaves the regular
1279
- flow, while its diagram becomes the "Diagram of Contents" page */
1280
- const title = titlePageObject(specification);
1281
- const artifacts = specification.artifacts
1282
- .filter((artifact) => !artifact.objects.some(isTitleObject));
1283
- const meta = titleObject(specification);
1284
- const doc = meta !== undefined ? rendered?.get(meta) : undefined;
1285
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;
1286
1348
  const objects = artifacts.flatMap((artifact) => artifact.objects);
1287
1349
  const entries = tocEntries(objects, tocPages);
1288
1350
  return render("Document", { Document: {
@@ -1294,6 +1356,7 @@ export const renderHtml = async (specification, config, tocPages, css, realtime
1294
1356
  titlepage: title !== undefined ?
1295
1357
  safe(renderTitlePage(title, formatDate(created), formatDate(modified))) : "",
1296
1358
  search: safe(searchScript()),
1359
+ progress: safe(scrollProgressScript),
1297
1360
  info: info !== null ? safe(infoScript(info, spec ?? {})) : "",
1298
1361
  realtime: realtime ? safe(realtimeScript) : "",
1299
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
  }
@@ -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(),