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 +23 -0
- package/bin/prolog-notebook.mjs +11 -4
- package/package.json +1 -1
- package/src/build-info.json +2 -2
- package/src/build.js +77 -0
- package/src/serve.js +16 -3
- package/src/version.js +1 -1
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
|
package/bin/prolog-notebook.mjs
CHANGED
|
@@ -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 {
|
|
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
|
-
|
|
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(
|
|
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
package/src/build-info.json
CHANGED
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
|
-
*
|
|
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(
|
|
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.
|
|
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';
|