@rse/specbook 1.2.2 → 1.2.4

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.
@@ -109,7 +109,7 @@ const deriveReferenceEdges = (diagram, nodes, nodeSet, index, anchors, parents,
109
109
  const walk = (object) => {
110
110
  objects.push(object);
111
111
  if (diagram.deep === true)
112
- for (const child of object.childs)
112
+ for (const child of object.children)
113
113
  if (!nodeSet.has(child))
114
114
  walk(child);
115
115
  };
@@ -213,9 +213,12 @@ const deriveEdges = (diagram, type, nodes, nodeSet, edgeObjects, index, anchors,
213
213
  if (diagram.onlyConnected === true)
214
214
  errors.push({ reason: "\"grid\" diagram cannot carry an \"onlyConnected\" configuration" });
215
215
  }
216
- /* deduplicate the edges (a containment or object edge can coincide
217
- with a reference edge, and several edge objects can describe
218
- the very same edge) */
216
+ return edges;
217
+ };
218
+ /* deduplicate the edges (a containment, object, or center edge can
219
+ coincide with a reference edge, and several edge objects can
220
+ describe the very same edge) */
221
+ const dedupEdges = (edges, anchors) => {
219
222
  const seenEdges = new Set();
220
223
  return edges.filter((edge) => {
221
224
  const key = `${anchors.get(edge.source) ?? edge.source.id}` +
@@ -307,7 +310,7 @@ const deriveCenter = (object, diagram, nodes, nodeSet, index, anchors, parenProp
307
310
  /* inject the synthetic center node into the node set, with a
308
311
  collision-free id ("@" never occurs in an anchor path) */
309
312
  const center = { kind: centerCfg.kind ?? "",
310
- id: `${anchors.get(object) ?? object.id}-@center`, name: label, properties: [], childs: [] };
313
+ id: `${anchors.get(object) ?? object.id}-@center`, name: label, properties: [], children: [] };
311
314
  nodes.unshift(center);
312
315
  nodeSet.add(center);
313
316
  return { center, centerUrl };
@@ -360,7 +363,7 @@ const deriveDiagram = (object, diagram, index, anchors, parents, parenProps) =>
360
363
  nodes = new Array();
361
364
  const walk = (o) => {
362
365
  nodes.push(o);
363
- o.childs.forEach(walk);
366
+ o.children.forEach(walk);
364
367
  };
365
368
  walk(object);
366
369
  }
@@ -368,7 +371,7 @@ const deriveDiagram = (object, diagram, index, anchors, parents, parenProps) =>
368
371
  const edgeSet = new Set(edgeObjects);
369
372
  nodes = nodes.filter((node) => !edgeSet.has(node));
370
373
  const nodeSet = new Set(nodes);
371
- /* derive the (deduplicated) edges of the diagram */
374
+ /* derive the edges of the diagram */
372
375
  let edges = deriveEdges(diagram, type, nodes, nodeSet, edgeObjects, index, anchors, parents, parenProps, errors);
373
376
  /* determine the center of a "hub" diagram (the only shape
374
377
  carrying a center at all) */
@@ -384,9 +387,12 @@ const deriveDiagram = (object, diagram, index, anchors, parents, parenProps) =>
384
387
  return { errors };
385
388
  const { center, centerUrl } = derived;
386
389
  /* synthesize the center edges of a "hub" diagram, as the nodes
387
- carry no "[[...]]" reference to a synthetic center */
390
+ carry no "[[...]]" reference to a synthetic center, and only
391
+ then deduplicate all edges, as a center edge of a real center
392
+ can coincide with a reference edge of a node onto it */
388
393
  if (type === "hub" && diagram.centerEdges !== undefined)
389
394
  edges.push(...deriveCenterEdges(diagram.centerEdges, nodes, center, index, parenProps, errors));
395
+ edges = dedupEdges(edges, anchors);
390
396
  /* a "hub" diagram is the hub-projection onto its center object:
391
397
  only the edges incident to the center and only the nodes
392
398
  connected through them remain, with self-loops dropped, as
@@ -513,11 +519,11 @@ export const validateDiagrams = (ctx, specification, config) => {
513
519
  object itself and report it just once, as a wildcard
514
520
  "edges" pattern lets every diagram walk the very same
515
521
  edge objects again */
516
- const meta = ctx.objectMeta.get(error.object ?? object) ?? { file: "", line: 1 };
522
+ const meta = ctx.metaOf(error.object ?? object);
517
523
  const message = error.object !== undefined ?
518
524
  `invalid diagram edge on ${error.object.kind} "${error.object.name}": ${error.reason}` :
519
525
  `invalid diagram on ${object.kind} "${object.name}": ${error.reason}`;
520
- const key = `${meta.file}${meta.line}${message}`;
526
+ const key = `${meta.file}\u0000${meta.line}\u0000${message}`;
521
527
  if (seen.has(key))
522
528
  continue;
523
529
  seen.add(key);
@@ -23,7 +23,7 @@ export const renderAst = (specification, format, config) => {
23
23
  const result = diagrams.get(object);
24
24
  if (result?.spec !== undefined && !isTitleObject(object))
25
25
  node.diagram = result.spec;
26
- object.childs.forEach((child, i) => walk(child, node.childs[i]));
26
+ object.children.forEach((child, i) => walk(child, node.children[i]));
27
27
  };
28
28
  specification.artifacts.forEach((artifact, i) => artifact.objects.forEach((object, j) => walk(object, plain.artifacts[i].objects[j])));
29
29
  }
@@ -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;");
@@ -25,7 +26,7 @@ export const titleObject = (specification) => {
25
26
  for (const object of objects) {
26
27
  if (isTitleObject(object))
27
28
  return object;
28
- const found = search(object.childs);
29
+ const found = search(object.children);
29
30
  if (found !== undefined)
30
31
  return found;
31
32
  }
@@ -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
@@ -4,7 +4,7 @@ import type { Verbose } from "./specbook-verbose.js";
4
4
  export type OutlineEntry = {
5
5
  title: string;
6
6
  anchor: string;
7
- childs: OutlineEntry[];
7
+ children: OutlineEntry[];
8
8
  };
9
9
  export declare const htmlOutline: (specification: Spec, config?: Schema) => OutlineEntry[];
10
10
  export declare const titlePageObject: (specification: Spec) => SpecObject | undefined;