@rse/specbook 1.2.7 → 1.2.8

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
 
@@ -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 */
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@rse/specbook",
3
- "version": "1.2.7",
3
+ "version": "1.2.8",
4
4
  "publishConfig": { "access": "public" },
5
5
  "description": "Markdown-based Specification Format (API, CLI and MCP service)",
6
6
  "license": "Apache-2.0",
@@ -54,7 +54,7 @@
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",