prolog-notebook 0.6.0 → 0.6.1

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/CHANGELOG.md CHANGED
@@ -1,5 +1,28 @@
1
1
  # Changelog
2
2
 
3
+ ## [0.6.1] — 2026-08-30
4
+
5
+ ### Fixed
6
+
7
+ - **`view` served the notebook as it was when the server started.** It built the page once and
8
+ handed the map to the server, which held it for the life of the process — so an author who
9
+ edited their chapter and reloaded was shown the version the server had started with. A reload
10
+ is the gesture for *show me what I just did*, and answering it with the old page teaches an
11
+ author to doubt their own edit rather than the tool.
12
+
13
+ The request is now what reads the file, so there is no window in which the page and the file
14
+ can disagree, and nothing that can have missed a change. Compared by bytes rather than mtime:
15
+ an editor writing through a rename, a `git checkout` and two saves inside one millisecond are
16
+ all changes an mtime reports unreliably, and reparsing a chapter is milliseconds on a file the
17
+ author has open anyway. An unchanged file is read and not rebuilt.
18
+
19
+ A chapter that stops parsing keeps the last version that did, with the parser's own message
20
+ across the top of the page and once on stderr — not once per asset. Silently serving the
21
+ previous version would be the same bug wearing the fix's clothes, and blanking the page would
22
+ throw a chapter away over a half-typed fence. Fixing the file restores it with no restart.
23
+
24
+ `build` is unchanged: it takes the first answer and writes it.
25
+
3
26
  ## [0.6.0] — 2026-08-30
4
27
 
5
28
  Taking the answers back out — at the terminal, and on the page. Plus the first documentation
@@ -14,7 +14,7 @@ import { updateNotice } from '../src/update.js';
14
14
  import { confirm, describeInstall, globalRoot, install, relaunch, upgradePlan } from '../src/upgrade.js';
15
15
  import { clearedSource, exportSource } from '../src/export.js';
16
16
  import { runNotebook, DEFAULT_LIMIT } from '../src/run.js';
17
- import { buildFiles } from '../src/build.js';
17
+ import { livePages } from '../src/build.js';
18
18
  import { openInBrowser, serve } from '../src/serve.js';
19
19
 
20
20
  // The engine is imported WHERE IT IS USED, never at the top. src/node.js pulls in
@@ -391,10 +391,17 @@ async function page(command, args) {
391
391
  if (jump !== null) return jump;
392
392
 
393
393
  const file = files[0];
394
+ // ASKED AGAIN ON EVERY REQUEST, and built again only when the bytes have moved
395
+ // (869erpuhk). `build` takes the first answer and writes it; `view` keeps the
396
+ // producer, so a reload shows the chapter as it is now rather than as it was
397
+ // when the server started.
398
+ const pages = livePages(() => readFileSync(file, 'utf8'), {
399
+ filename: basename(file),
400
+ onError: (e) => process.stderr.write(`${file}: ${e.message}\n`),
401
+ });
394
402
  let built;
395
403
  try {
396
- const source = readFileSync(file, 'utf8');
397
- built = buildFiles(parse(source), source, { filename: basename(file) });
404
+ built = pages();
398
405
  } catch (e) {
399
406
  process.stderr.write(`${file}: ${e.message}\n`);
400
407
  return 1;
@@ -413,7 +420,7 @@ async function page(command, args) {
413
420
  return 0;
414
421
  }
415
422
 
416
- const server = await serve(built, { port: options.port });
423
+ const server = await serve(pages, { port: options.port });
417
424
  // THE URL IS THIS COMMAND'S OUTPUT. `view` writes no notebook and no data to
418
425
  // stdout, so there is nothing for it to corrupt — and a URL on stderr is a URL
419
426
  // a wrapper does not see, which is how somebody came to type localhost by hand
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "prolog-notebook",
3
- "version": "0.6.0",
3
+ "version": "0.6.1",
4
4
  "description": "Jupyter-style notebooks for Prolog. Runs in the browser, installs nothing.",
5
5
  "type": "module",
6
6
  "main": "./src/node.js",
@@ -1,4 +1,4 @@
1
1
  {
2
- "commit": "4e10d19",
3
- "built": "2026-08-30 18:33:44 UTC"
2
+ "commit": "3abbaef",
3
+ "built": "2026-08-30 19:27:20 UTC"
4
4
  }
package/src/build.js CHANGED
@@ -15,6 +15,7 @@
15
15
  // contain — generated text, or a path to copy — so that `build` can write it,
16
16
  // `view` can serve it, and a test can read it, without any of the three
17
17
  // disagreeing about what a page is.
18
+ import { parse } from './format.js';
18
19
  import { renderNotebook } from './render.js';
19
20
 
20
21
  /** The runtime a page needs. Copied side by side, so their relative imports hold. */
@@ -67,6 +68,82 @@ export function buildFiles(notebook, source, options = {}) {
67
68
  return files;
68
69
  }
69
70
 
71
+ /**
72
+ * The page, rebuilt whenever the notebook has changed underneath it (869erpuhk).
73
+ *
74
+ * `view` used to build once and serve that forever, so an author who edited their
75
+ * chapter and reloaded was shown the version the server had started with. A
76
+ * reload is the gesture for "show me what I just did"; answering it with the old
77
+ * page teaches an author to doubt their own edit.
78
+ *
79
+ * COMPARED BY BYTES, not by mtime. The point of a rebuild here is to be right
80
+ * rather than quick — an editor that writes through a rename, a `git checkout`, a
81
+ * clock that went backwards, two saves inside one millisecond, all of them are
82
+ * changes and none of them reliably move an mtime the way one would hope. Reading
83
+ * a chapter is microseconds and reparsing one is milliseconds, on a file the
84
+ * author has open anyway.
85
+ *
86
+ * A BROKEN FILE KEEPS THE LAST GOOD PAGE AND SAYS SO. Silently serving the
87
+ * previous version would be the same bug this exists to fix, so the page carries
88
+ * the parser's own message. Blanking it instead would throw away a chapter over a
89
+ * half-typed fence — an author saves mid-thought, and the useful thing on screen
90
+ * is the last version that worked, labelled.
91
+ *
92
+ * @param {() => string} read the notebook's bytes, now
93
+ * @param {{onError?: (e: Error) => void}} [options] and everything buildFiles takes
94
+ * @returns {() => Map<string, {text: string}|{copy: URL}>}
95
+ */
96
+ export function livePages(read, { onError = () => {}, ...options } = {}) {
97
+ let last = null;
98
+ let failing = null;
99
+ return () => {
100
+ let source = null;
101
+ try {
102
+ source = read();
103
+ if (last && last.source === source) return last.files;
104
+ const files = buildFiles(parse(source), source, options);
105
+ last = { source, files };
106
+ failing = null;
107
+ return files;
108
+ } catch (e) {
109
+ // Nothing good to fall back to: this is the first build, and the caller —
110
+ // the command — is the one that should report it and stop.
111
+ if (!last) throw e;
112
+ // Once per broken version, not once per request. A page fetches a dozen
113
+ // files, and a terminal repeating the same syntax error a dozen times is
114
+ // worse at communicating it than saying it once.
115
+ if (failing !== source) onError(e);
116
+ failing = source;
117
+ return withNotice(last.files, e.message);
118
+ }
119
+ };
120
+ }
121
+
122
+ /**
123
+ * The last good page, wearing the reason it is not the current one.
124
+ *
125
+ * A copy, so the good build is never mutated and recovering is simply serving it
126
+ * again.
127
+ */
128
+ function withNotice(files, message) {
129
+ const index = files.get('index.html');
130
+ if (index?.text === undefined) return files;
131
+ const copy = new Map(files);
132
+ copy.set('index.html', { text: index.text.replace('<body>', `<body>\n${notice(message)}`) });
133
+ return copy;
134
+ }
135
+
136
+ /**
137
+ * Inline styles, deliberately: this belongs to no chapter and must never depend
138
+ * on a stylesheet the broken file might itself have been changing.
139
+ */
140
+ function notice(message) {
141
+ return '<div role="alert" style="position:sticky;top:0;z-index:99;padding:.7rem 1rem;'
142
+ + 'background:#7a2618;color:#fff;font:500 .8rem/1.5 ui-monospace,Menlo,monospace">'
143
+ + '<strong>This notebook does not currently parse.</strong> Showing the last version that'
144
+ + ` did.<br>${escapeHtml(message)}</div>`;
145
+ }
146
+
70
147
  /**
71
148
  * The chapter's own title, from its first H1 (format §2).
72
149
  *
package/src/serve.js CHANGED
@@ -31,11 +31,24 @@ export function contentType(name) {
31
31
  /**
32
32
  * Serve a built page.
33
33
  *
34
- * @param {Map<string, {text: string}|{copy: URL}>} files what build produced
34
+ * THE REQUEST IS WHAT READS THE FILE, when a producer is given rather than a map
35
+ * (869erpuhk). The first version of this held the map it was handed for the life
36
+ * of the process, so `view` served the notebook as it had been at start-up and a
37
+ * reload — the universal gesture for "show me what I just did" — confirmed the
38
+ * old version. An author doubts their edit before they doubt the tool.
39
+ *
40
+ * Asked per request rather than pushed by a watcher, because that is what makes
41
+ * the guarantee unconditional: there is no window in which the page and the file
42
+ * disagree, and nothing to have missed a change. A watcher (869edp5c8) can only
43
+ * ever save the reader a keystroke on top of this.
44
+ *
45
+ * @param {Map<string, {text: string}|{copy: URL}>|(() => Map)} pages what build
46
+ * produced, or something that produces it — called once per request
35
47
  * @param {{port?: number, host?: string}} [options]
36
48
  * @returns {Promise<{url: string, port: number, close: () => Promise<void>}>}
37
49
  */
38
- export async function serve(files, { port = 8777, host = '127.0.0.1' } = {}) {
50
+ export async function serve(pages, { port = 8777, host = '127.0.0.1' } = {}) {
51
+ const files = typeof pages === 'function' ? pages : () => pages;
39
52
  // ASK WHETHER ANYBODY IS THERE, on both stacks, before binding to one of them.
40
53
  //
41
54
  // An IPv6 wildcard listener — `python3 -m http.server --bind ::` — does not
@@ -49,7 +62,7 @@ export async function serve(files, { port = 8777, host = '127.0.0.1' } = {}) {
49
62
  // Only GET, and only the names this process generated: the path never
50
63
  // reaches the filesystem, so there is nothing for a `..` to escape into.
51
64
  const name = decodeURIComponent(new URL(request.url, 'http://x').pathname).replace(/^\//, '');
52
- const entry = files.get(name === '' ? 'index.html' : name);
65
+ const entry = files().get(name === '' ? 'index.html' : name);
53
66
  if (request.method !== 'GET' || !entry) {
54
67
  response.writeHead(404, { 'content-type': 'text/plain' }).end('not found\n');
55
68
  return;
package/src/version.js CHANGED
@@ -10,7 +10,7 @@
10
10
  export const NAME = 'Prolog Notebook';
11
11
 
12
12
  /** Must equal package.json's `version` — test/run.test.mjs enforces it. */
13
- export const VERSION = '0.6.0';
13
+ export const VERSION = '0.6.1';
14
14
 
15
15
  /** The two facts a licence notice is actually made of. */
16
16
  export const YEAR = '2026';