@rse/specbook 1.2.7 → 1.2.9

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.
package/README.md CHANGED
@@ -6,6 +6,8 @@ SpecBook
6
6
 
7
7
  **Markdown-based Specification Format**
8
8
 
9
+ [specbook.tools](https://specbook.tools)
10
+
9
11
  [![github (author stars)](https://img.shields.io/github/stars/rse?logo=github&label=author%20stars&color=%233377aa)](https://github.com/rse)
10
12
  [![github (author followers)](https://img.shields.io/github/followers/rse?label=author%20followers&logo=github&color=%234477aa)](https://github.com/rse)
11
13
 
@@ -84,6 +86,7 @@ CLI with commands `specbook <xxx>`, and an MCP service with tools
84
86
  reference coverage tables (e.g. the share of the use cases, scenarios,
85
87
  requirements, and rules the test cases verify), a
86
88
  scroll progress meter (which also scrolls back to the top on click),
89
+ diagram maximization (into the viewport or the fullscreen),
87
90
  description popups (which show the schema and corpus description of
88
91
  an object kind, property, or object instance on hover), and a
89
92
  light/dark theme toggle. It is intended for the day-to-day online
@@ -3,6 +3,7 @@
3
3
  ** Copyright (c) 2026 Dr. Ralf S. Engelschall <rse@engelschall.com>
4
4
  ** Licensed under Apache 2.0 <https://spdx.org/licenses/Apache-2.0>
5
5
  */
6
+ import * as fs from "node:fs";
6
7
  /* Chokidar is aliased in "package.json", as Nunjucks declares the
7
8
  (unused) Chokidar 3 as an optional peer dependency and the current
8
9
  Chokidar under its plain name would raise a peer warning on install */
@@ -56,16 +57,38 @@ export const parseOutputSpec = (spec) => {
56
57
  /* the quiet period the observed files have to stay silent for before a
57
58
  burst of changes is coalesced into a single re-export */
58
59
  const watchDelay = 1000;
60
+ /* the interval of the safety-net scan, which catches the changes the
61
+ file-system watcher missed: a watcher can die unnoticed -- macOS
62
+ tears down its FSEvents streams across a sleep/wake cycle -- and
63
+ would then leave the export stale for the rest of the process life */
64
+ const scanDelay = 30 * 1000;
65
+ /* take the modification time snapshot of the observed files, where an
66
+ absent file (a still missing artifact) is remembered as absent, too,
67
+ so its creation and removal are noticed like a modification */
68
+ const snapshot = (files) => {
69
+ const times = new Map();
70
+ for (const file of files) {
71
+ try {
72
+ times.set(file, fs.statSync(file).mtimeMs);
73
+ }
74
+ catch {
75
+ times.set(file, 0);
76
+ }
77
+ }
78
+ return times;
79
+ };
59
80
  /* keep a specification export in sync with its sources, where "run"
60
81
  performs one export and returns the files to observe: the initial
61
82
  export is performed up-front and every observed change triggers a
62
83
  re-export once the sources fell silent again. The observed set is
63
84
  re-synchronized after every run, as an edit can add or drop an
64
85
  embedded asset. The returned promise settles once the initial export
65
- is done, while the active watcher keeps the process alive afterwards */
86
+ is done, while the active watcher and the safety-net scan keep the
87
+ process alive afterwards */
66
88
  export const watchSpecification = async (run, verbose) => {
67
89
  /* perform the regular export before entering the observe loop */
68
90
  let observed = await run();
91
+ let times = snapshot(observed);
69
92
  const watcher = watch(observed, { ignoreInitial: true });
70
93
  verbose(`observing ${literal(observed.length)} specification file(s) for changes`);
71
94
  /* report the failures of the observation itself, as an unhandled
@@ -80,6 +103,7 @@ export const watchSpecification = async (run, verbose) => {
80
103
  watcher.unwatch(observed.filter((file) => !files.includes(file)));
81
104
  watcher.add(files.filter((file) => !observed.includes(file)));
82
105
  observed = files;
106
+ times = snapshot(files);
83
107
  };
84
108
  /* restart the quiet period on every change, so a burst collapses
85
109
  into a single re-export, and chain the runs onto each other, so a
@@ -88,7 +112,7 @@ export const watchSpecification = async (run, verbose) => {
88
112
  and hence silently ending the observe loop) */
89
113
  let chain = Promise.resolve();
90
114
  let timer;
91
- watcher.on("all", () => {
115
+ const schedule = () => {
92
116
  if (timer !== undefined)
93
117
  clearTimeout(timer);
94
118
  timer = setTimeout(() => {
@@ -98,7 +122,24 @@ export const watchSpecification = async (run, verbose) => {
98
122
  (err instanceof Error ? err.message : String(err)), "none");
99
123
  });
100
124
  }, watchDelay);
101
- });
125
+ };
126
+ watcher.on("all", schedule);
127
+ /* compare the modification times regularly, so a change the
128
+ watcher never reported still reaches the export, and re-establish
129
+ the watchers on such a miss, as a watcher which lost a file never
130
+ regains it on its own and the following changes would be caught
131
+ by the scan only */
132
+ setInterval(() => {
133
+ for (const [file, time] of snapshot(observed))
134
+ if (times.get(file) !== time) {
135
+ verbose(`observing missed the change of "${literal(file)}" ` +
136
+ "-- re-establishing the file observation", "none");
137
+ watcher.unwatch(observed);
138
+ watcher.add(observed);
139
+ schedule();
140
+ break;
141
+ }
142
+ }, scanDelay);
102
143
  };
103
144
  /* render a specification into the requested format, where "realtime"
104
145
  injects the client-side script of the live preview into the HTML */
@@ -74,6 +74,23 @@
74
74
  units.push({ el, text, words, keep: false })
75
75
  })
76
76
  })
77
+
78
+ /* let a hyperlink whose target the filtering hid leave the
79
+ search mode (like the Escape key) before it jumps: the click
80
+ is intercepted, the document revealed again, and the click
81
+ re-issued, so the browser performs the regular jump */
82
+ document.querySelectorAll("a[href^='#']").forEach((link) => {
83
+ link.addEventListener("click", (event) => {
84
+ const target = document.getElementById(
85
+ decodeURIComponent((link.getAttribute("href") ?? "").slice(1)))
86
+ if (target === null || target.closest(".search-hide") === null)
87
+ return
88
+ event.preventDefault()
89
+ reset()
90
+ slide(false)
91
+ link.dispatchEvent(new MouseEvent("click", { bubbles: true, cancelable: true }))
92
+ })
93
+ })
77
94
  }
78
95
 
79
96
  /* the Levenshtein edit distance of two words */
@@ -574,6 +574,87 @@ span.fold-label svg {
574
574
  display: none;
575
575
  }
576
576
  }
577
+ /* the maximization of the diagrams (screen-only): every diagram
578
+ carries two controls at its top right corner (drawn like the fold
579
+ chevron of an over-long cell, in rounded boxes on the page
580
+ background), faded in while the diagram is hovered, which maximize
581
+ the diagram temporarily in the shared overlay covering the whole
582
+ viewport (or, taken into the browser fullscreen, the whole screen)
583
+ on the page background, where the cloned diagram scales to fit the
584
+ padded area (aspect ratio kept, centered) and a close mark sits at
585
+ the top right corner, while the document beneath stops scrolling */
586
+ span.maximize-controls {
587
+ position: absolute;
588
+ top: 0.35rem;
589
+ right: 0.35rem;
590
+ display: flex;
591
+ gap: 0.25rem;
592
+ line-height: 0;
593
+ opacity: 0;
594
+ transition: opacity 0.2s ease;
595
+ }
596
+ div.diagram:hover span.maximize-controls {
597
+ opacity: 1;
598
+ }
599
+ span.maximize-controls > span,
600
+ span.maximize-close {
601
+ display: inline-block;
602
+ padding: 0.2rem;
603
+ line-height: 0;
604
+ color: var(--theme-color-specbook-symbol);
605
+ background-color: var(--theme-color-specbook-background);
606
+ border: 1px solid var(--theme-color-specbook-border);
607
+ border-radius: 0.25rem;
608
+ cursor: pointer;
609
+ -webkit-user-select: none;
610
+ user-select: none;
611
+ }
612
+ span.maximize-controls > span:hover,
613
+ span.maximize-close:hover {
614
+ color: var(--theme-color-specbook-accent);
615
+ border-color: var(--theme-color-specbook-accent);
616
+ }
617
+ span.maximize-controls svg,
618
+ span.maximize-close svg {
619
+ width: 1rem;
620
+ height: 1rem;
621
+ }
622
+ div.maximize {
623
+ display: none;
624
+ position: fixed;
625
+ inset: 0;
626
+ z-index: 40;
627
+ box-sizing: border-box;
628
+ padding: 2rem;
629
+ background-color: var(--theme-color-specbook-background);
630
+ }
631
+ div.maximize.open {
632
+ display: block;
633
+ }
634
+ div.maximize span.maximize-close {
635
+ position: absolute;
636
+ top: 0.6rem;
637
+ right: 0.6rem;
638
+ }
639
+ div.maximize div.maximize-content {
640
+ width: 100%;
641
+ height: 100%;
642
+ }
643
+ div.maximize div.maximize-content svg {
644
+ display: block;
645
+ width: 100%;
646
+ height: 100%;
647
+ max-width: none;
648
+ }
649
+ html.maximized {
650
+ overflow: hidden;
651
+ }
652
+ @media print {
653
+ span.maximize-controls,
654
+ div.maximize {
655
+ display: none;
656
+ }
657
+ }
577
658
  /* the connection status tab of the live preview (screen-only): the
578
659
  bottommost tab riding right of the brand bar below the table of
579
660
  contents tab (sized like the search tab, fixed independent of the
@@ -1204,6 +1285,7 @@ span.theme-dark-only {
1204
1285
  }
1205
1286
  /* the diagrams, rationales, keywords, and code spans */
1206
1287
  div.diagram {
1288
+ position: relative;
1207
1289
  margin: 1rem 0;
1208
1290
  }
1209
1291
  div.diagram svg {
@@ -60,6 +60,7 @@ const templates = {
60
60
  {% if Document.search %}<script>{{ Document.search }}</script>{% endif %}
61
61
  <script>{{ Document.progress }}</script>
62
62
  <script>{{ Document.fold }}</script>
63
+ <script>{{ Document.maximize }}</script>
63
64
  {% if Document.tocpanel %}<script>{{ Document.tocscript }}</script>{% endif %}
64
65
  {% if Document.info %}<script>{{ Document.info }}</script>{% endif %}
65
66
  {% if Document.realtime %}<script class="realtime">{{ Document.realtime }}</script>{% endif %}
@@ -654,6 +655,110 @@ const foldScript = textframe `
654
655
 
655
656
  })()
656
657
  `;
658
+ /* the client-side script of the diagram maximization: it attaches two
659
+ controls to the top right corner of every diagram, revealed by the
660
+ stylesheet while the diagram is hovered, which maximize the diagram
661
+ temporarily in a single shared overlay covering the viewport (the
662
+ SVG cloned into it and fitted by the stylesheet), the second one
663
+ additionally taking the overlay into the browser fullscreen (a
664
+ refused request leaves the viewport overlay), where the close mark
665
+ at the top right corner, "Escape", a click beside the diagram, and
666
+ a jump through a node hyperlink close the overlay again (a browser
667
+ leaving the fullscreen on its own closes it, too). The script runs
668
+ at the end of the body, as the diagrams it decorates have to exist
669
+ already, and a live preview body swap replaces the overlay and the
670
+ body-bound listeners along with the body, while the document-bound
671
+ fullscreen listener retires itself */
672
+ const maximizeScript = textframe `
673
+ (function () {
674
+ /* an icon control drawn like the tab icons */
675
+ const svgNS = "http://www.w3.org/2000/svg"
676
+ const mark = (name, title, paths) => {
677
+ const span = document.createElement("span")
678
+ span.className = name
679
+ span.title = title
680
+ const svg = document.createElementNS(svgNS, "svg")
681
+ svg.setAttribute("viewBox", "0 0 24 24")
682
+ svg.setAttribute("fill", "none")
683
+ svg.setAttribute("stroke", "currentColor")
684
+ svg.setAttribute("stroke-width", "2.5")
685
+ svg.setAttribute("stroke-linecap", "round")
686
+ svg.setAttribute("stroke-linejoin", "round")
687
+ for (const d of paths) {
688
+ const path = document.createElementNS(svgNS, "path")
689
+ path.setAttribute("d", d)
690
+ svg.appendChild(path)
691
+ }
692
+ span.appendChild(svg)
693
+ return span
694
+ }
695
+
696
+ /* the shared overlay: the close mark plus the content holding
697
+ the clone of the maximized diagram */
698
+ const overlay = document.createElement("div")
699
+ overlay.className = "maximize"
700
+ const close = mark("maximize-close", "close the maximized diagram",
701
+ [ "M18 6 6 18", "M6 6l12 12" ])
702
+ const content = document.createElement("div")
703
+ content.className = "maximize-content"
704
+ overlay.append(close, content)
705
+ document.body.appendChild(overlay)
706
+
707
+ /* open the overlay onto a diagram, optionally in the browser
708
+ fullscreen, and close it again (leaving the fullscreen, too),
709
+ with the document scrolling locked while it is open */
710
+ const open = (diagram, fullscreen) => {
711
+ const svg = diagram.querySelector("svg").cloneNode(true)
712
+ svg.removeAttribute("style")
713
+ content.replaceChildren(svg)
714
+ overlay.classList.add("open")
715
+ document.documentElement.classList.add("maximized")
716
+ if (fullscreen)
717
+ overlay.requestFullscreen().catch(() => {})
718
+ }
719
+ const shut = () => {
720
+ overlay.classList.remove("open")
721
+ document.documentElement.classList.remove("maximized")
722
+ content.replaceChildren()
723
+ if (document.fullscreenElement === overlay)
724
+ document.exitFullscreen().catch(() => {})
725
+ }
726
+ close.addEventListener("click", shut)
727
+ overlay.addEventListener("click", (event) => {
728
+ /* a node hyperlink navigates on its own, the overlay just
729
+ gets out of its way, while a click beside the diagram
730
+ closes the overlay alone */
731
+ if (event.target.closest("a") !== null || event.target.closest("svg") === null)
732
+ shut()
733
+ })
734
+ document.body.addEventListener("keydown", (event) => {
735
+ if (event.key === "Escape" && overlay.classList.contains("open"))
736
+ shut()
737
+ })
738
+ const left = () => {
739
+ if (!overlay.isConnected)
740
+ document.removeEventListener("fullscreenchange", left)
741
+ else if (document.fullscreenElement === null && overlay.classList.contains("open"))
742
+ shut()
743
+ }
744
+ document.addEventListener("fullscreenchange", left)
745
+
746
+ /* attach the two controls to every diagram */
747
+ document.querySelectorAll("article div.diagram, nav.doc div.diagram").forEach((diagram) => {
748
+ const controls = document.createElement("span")
749
+ controls.className = "maximize-controls"
750
+ const view = mark("maximize-view", "maximize this diagram in the viewport",
751
+ [ "M15 3h6v6", "M21 3l-7 7", "M9 21H3v-6", "M3 21l7-7" ])
752
+ const full = mark("maximize-full", "maximize this diagram in fullscreen",
753
+ [ "M3 7V5a2 2 0 0 1 2-2h2", "M17 3h2a2 2 0 0 1 2 2v2",
754
+ "M21 17v2a2 2 0 0 1-2 2h-2", "M7 21H5a2 2 0 0 1-2-2v-2" ])
755
+ view.addEventListener("click", () => { open(diagram, false) })
756
+ full.addEventListener("click", () => { open(diagram, true) })
757
+ controls.append(view, full)
758
+ diagram.appendChild(controls)
759
+ })
760
+ })()
761
+ `;
657
762
  /* the client-side script of the table of contents side panel: it
658
763
  slides the panel out of and back into the viewport edge (closing it
659
764
  also on a jump, on "Escape", and on a click outside), remembers the
@@ -1661,6 +1766,7 @@ export const renderHtml = async (specification, config, tocPages, css, realtime
1661
1766
  search: safe(searchScript()),
1662
1767
  progress: safe(scrollProgressScript),
1663
1768
  fold: safe(foldScript),
1769
+ maximize: safe(maximizeScript),
1664
1770
  info: info !== null ? safe(infoScript(info, spec ?? {})) : "",
1665
1771
  realtime: realtime ? safe(realtimeScript) : "",
1666
1772
  toc: entries.length > 0 ? safe(render("Toc", { Toc: { entries } })) : "",
@@ -185,6 +185,7 @@ type SchemaGradiaConfig = Partial<{
185
185
  "grid-gap-horizontal": number
186
186
  "grid-gap-vertical": number
187
187
  "grid-node-width-equal": boolean
188
+ "grid-node-height-equal": boolean
188
189
  }>
189
190
  ```
190
191
 
@@ -1239,7 +1239,27 @@
1239
1239
  type: graph
1240
1240
  nodes: "[[DM.ENTITY:*]]"
1241
1241
  edges: "[[RELATION:*]]"
1242
+ nest:
1243
+ properties:
1244
+ - GROUP
1245
+ config:
1246
+ graph-columns-max: 2
1242
1247
  children:
1248
+ - kind: GROUP
1249
+ name: "[A-Z][a-zA-Z0-9]*"
1250
+ desc: >
1251
+ A named group of strongly related entities, carrying a Pascal-cased, 1-3 word long,
1252
+ per-artifact unique name, distinct from the names of its entities (e.g. `Statistics`
1253
+ for the snapshot entities, `Messages` for a message and its texts), and described by
1254
+ a 1-sentence description ("what") plus a 1-sentence rationale ("why") of the
1255
+ cohesion. A group is either an aggregate (a root entity with the part entities it
1256
+ owns) or a subject area (entities sharing one concern), affects the overview diagram
1257
+ only, where its entities are drawn next to each other inside a box, and carries no
1258
+ semantics of its own: the relations between its entities stay explicit. The groups
1259
+ are listed before the entities, and every entity belongs to at most one group.
1260
+ optional: true
1261
+ referenced:
1262
+ - "[[DM.ENTITY:*]]"
1243
1263
  - kind: ENTITY
1244
1264
  name: "[A-Z][a-zA-Z0-9]*"
1245
1265
  desc: >
@@ -1281,6 +1301,13 @@
1281
1301
  is anchored in the ubiquitous language and every term shows its data.
1282
1302
  value: list([[TERM:*]])
1283
1303
  optional: true
1304
+ - name: GROUP
1305
+ desc: >
1306
+ Reference to the DATA-MODEL group the entity belongs to, placing it next to
1307
+ the other entities of the group in the overview diagram; omitted for an
1308
+ entity standing on its own.
1309
+ value: "[[DM.GROUP:*]]"
1310
+ optional: true
1284
1311
  children:
1285
1312
  - kind: ATTRIBUTE
1286
1313
  name: "[a-z][a-zA-Z0-9]*"
@@ -2872,7 +2899,7 @@
2872
2899
  - PRODUCT
2873
2900
  config:
2874
2901
  size-node-width-max: 440
2875
- grid-columns-max: 3
2902
+ grid-columns-max: 2
2876
2903
  children:
2877
2904
  - kind: TIER
2878
2905
  desc: |
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@rse/specbook",
3
- "version": "1.2.7",
3
+ "version": "1.2.9",
4
4
  "publishConfig": { "access": "public" },
5
5
  "description": "Markdown-based Specification Format (API, CLI and MCP service)",
6
6
  "license": "Apache-2.0",
@@ -17,16 +17,16 @@
17
17
  },
18
18
  "files": [ "dst" ],
19
19
  "dependencies": {
20
- "marked": "18.0.11",
20
+ "marked": "18.0.12",
21
21
  "marked-smartypants": "1.1.12",
22
22
  "yaml": "2.9.0",
23
23
  "json5": "2.2.3",
24
24
  "pdf-lib": "1.17.1",
25
25
  "subset-font": "2.7.0",
26
26
  "@rse/mrcs": "0.9.2",
27
- "@rse/gradia": "1.2.1",
27
+ "@rse/gradia": "1.2.3",
28
28
  "@toon-format/toon": "4.1.1",
29
- "valibot": "1.4.2",
29
+ "valibot": "1.5.0",
30
30
  "nunjucks": "3.2.4",
31
31
  "@rse/nunjucks-addons": "2.2.0",
32
32
  "textframe": "1.2.1",
@@ -42,19 +42,19 @@
42
42
  "fastify": "5.12.3",
43
43
  "@fastify/websocket": "11.3.0",
44
44
  "@modelcontextprotocol/sdk": "1.30.0",
45
- "zod": "4.5.4"
45
+ "zod": "4.6.1"
46
46
  },
47
47
  "devDependencies": {
48
48
  "chokidar-cli": "3.0.0",
49
49
  "eslint": "10.9.0",
50
50
  "@eslint/js": "10.0.1",
51
- "typescript-eslint": "8.69.0",
51
+ "typescript-eslint": "8.70.0",
52
52
  "eslint-plugin-promise": "7.3.0",
53
53
  "neostandard": "0.14.0-next.1",
54
54
  "globals": "17.12.0",
55
55
  "markdownlint": "0.41.1",
56
56
  "markdownlint-cli2": "0.23.2",
57
- "@types/node": "26.4.1",
57
+ "@types/node": "26.5.0",
58
58
  "@types/subset-font": "2.3.0",
59
59
  "@types/nunjucks": "3.2.6",
60
60
  "@types/ws": "8.18.1",