prolog-notebook 0.5.0 → 0.6.0

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,91 @@
1
1
  # Changelog
2
2
 
3
+ ## [0.6.0] — 2026-08-30
4
+
5
+ Taking the answers back out — at the terminal, and on the page. Plus the first documentation
6
+ written for somebody who is not us.
7
+
8
+ ### Added
9
+
10
+ - **`prolog-notebook clear <file>`** empties every output block and leaves everything else
11
+ alone: prose, program cells, goals and attributes are the author's. A chapter with no answers
12
+ is a valid chapter — one that has not been executed yet — so `clear` then `execute` returns
13
+ the original bytes exactly. For a workbook edition, for a diff you can read, or for starting
14
+ again deliberately rather than trusting an overwrite.
15
+
16
+ - **The page can clear its own answers, and put them back.** A new row in the panel:
17
+
18
+ 4 outputs in this chapter [Clear all outputs]
19
+ 4 outputs cleared [Restore outputs]
20
+
21
+ It sits next to the hide row on purpose, because the two blank the same box for opposite
22
+ reasons. Hiding acts on the **screen** — the answers are still the chapter's and still go
23
+ into a download. Clearing acts on the **notebook**: a download taken after it carries no
24
+ output block at all, through the same erasure the CLI uses, so a chapter emptied on the page
25
+ and one emptied at the terminal are the same bytes.
26
+
27
+ That makes clearing a third way away from the published chapter, beside running and editing,
28
+ which is why nothing new was needed to get back. **One origin, one way back**: reset already
29
+ meant *as published* and now has one more thing it can undo — per cell, or all at once with
30
+ restore. An auto cell does not refill itself once cleared, and restore is the exact inverse
31
+ of clear, leaving an edited goal alone.
32
+
33
+ - **`docs/authoring.md` — the author's handbook.** The loop, a whole chapter from scratch,
34
+ what `hold` and `rerun` do to a reader, why you never hand-write an output block, staleness,
35
+ what a reader can do to your chapter, publishing, and nine things that otherwise cost an
36
+ afternoon. Every command line in it was run.
37
+
38
+ ### Changed
39
+
40
+ - **`run` is now `execute`.** It executes a chapter and writes its answers into the file, which
41
+ `run` did not say — and *run* was already the name of the button a reader presses on one
42
+ cell. `run` and `exec` still work; nothing has been taken away.
43
+
44
+ - **The README had not met the CLI.** Its banner still said the renderer was unwritten, Status
45
+ still counted 186 tests, and *Try it* pointed a fresh global install at a chapter that ships
46
+ in the repo and not in the package — so the first command in the front door failed for
47
+ everybody who had just installed it. It now writes a three-line chapter, executes it and
48
+ views it.
49
+
50
+ - `docs/modes.md` §3 gains *Hiding is not clearing*.
51
+
52
+ ## [0.5.1] — 2026-08-30
53
+
54
+ Everything here came from one field report: a chapter written with the tool rather than a test
55
+ written against it.
56
+
57
+ ### Fixed
58
+
59
+ - **`view` bound quietly behind a squatter on the same port.** A stale
60
+ `python3 -m http.server --bind ::` holds `*:8777` on IPv6; binding `127.0.0.1:8777` on IPv4
61
+ does not collide with it, so `EADDRINUSE` never fired and the auto-bump never ran. `localhost`
62
+ then resolves to `::1` first and the reader gets somebody else's directory listing where a
63
+ chapter should be. The port is now connect-tested on **both stacks** before binding, because
64
+ *can I bind* and *will the reader reach me* are different questions.
65
+
66
+ - **`view` printed its URL on stderr**, where a wrapper never saw it — which is how somebody
67
+ came to type `localhost` by hand. It is the command's output and is on stdout now; `view`
68
+ writes no notebook and no data there, so nothing can be corrupted by it.
69
+
70
+ - **`view` and `build` never looked for an update.** The offer went into the `run` path first
71
+ and stayed there. Every command that does real work now goes through one door, always before
72
+ the work — the only point at which the answer can change the outcome.
73
+
74
+ - **A prediction released the hold on blur, not on typing.** A reader who typed their prediction
75
+ and looked up saw nothing happen, and the link between *I wrote something* and *the answers
76
+ appeared* was broken by a pause with no cause. Debounced `input` at 1.2 s now, with `change`
77
+ kept so leaving the box is still immediate.
78
+
79
+ - **The console was not clean, and this audience opens the console.** A `favicon.ico` 404 on
80
+ every load — now a `data:` URI carrying the notebook's own `?-` prompt — and an issues-panel
81
+ warning for every form field on the page, all of which were anonymous. Every field has an id:
82
+ `src-<cell>`, `goal-<cell>`, `predict-<n>`.
83
+
84
+ - **A missing browser opener took the whole command down.** `spawn` reports that
85
+ asynchronously, so the `try/catch` around it caught nothing and an unhandled `'error'` event
86
+ killed `view` after the server had started. `start` on Windows is a shell builtin and was
87
+ never going to work spawned by name; it goes through `cmd` now.
88
+
3
89
  ## [0.5.0] — 2026-08-30
4
90
 
5
91
  **The CLI can show a notebook.** Until now the only way to see one running was to clone this
package/README.md CHANGED
@@ -2,9 +2,9 @@
2
2
 
3
3
  **Jupyter-style notebooks for Prolog. Runs in the browser, installs nothing.**
4
4
 
5
- > ⚠️ **v0.1.0early.** The execution core works and is tested. The renderer that turns a
6
- > notebook *file* into a page is not written yet; today you mark cells up in HTML by hand.
7
- > See [Status](#status).
5
+ > **v0.5usable, and moving.** A chapter is a markdown file; the CLI runs it, serves it and
6
+ > publishes it. Writing one is [the author's handbook](docs/authoring.md). See
7
+ > [Status](#status) for what is not built yet.
8
8
 
9
9
  ## The idea
10
10
 
@@ -40,19 +40,35 @@ So a query cell gives you the first solution, and then you step.
40
40
 
41
41
  ## Try it
42
42
 
43
- ```sh
43
+ A whole chapter, from nothing, in four commands:
44
+
45
+ ````sh
44
46
  npm i -g prolog-notebook
45
- prolog-notebook view notebooks/ch04-cut.prolog.md
47
+
48
+ cat > splitting.prolog.md <<'EOF'
49
+ # Splitting a list
50
+
51
+ `append/3` is usually introduced as the predicate that joins two lists. That is the
52
+ least interesting thing it does — run it backwards and it takes a list apart, every
53
+ way it can be split, one solution at a time.
54
+
55
+ ```prolog query
56
+ append(Front, Back, [hello, there, world])
46
57
  ```
58
+ EOF
59
+
60
+ prolog-notebook execute splitting.prolog.md # SWI fills the answers in
61
+ prolog-notebook view splitting.prolog.md # read it, cells live
62
+ ````
47
63
 
48
- That is a chapter the `once/1` placement puzzle, a real worked section rather than a widget
49
- demo. It opens in your browser with the cells live: press Run, then `; next`. Nothing is
50
- installed but the command, and the chapter is readable before the engine arrives.
64
+ `execute` runs the chapter and writes the solutions back into the markdown you never
65
+ hand-write an answer. `view` opens it in your browser: press Run, then `; next`, and watch the
66
+ four splits arrive one at a time. Nothing is installed but the command.
51
67
 
52
68
  To send it to somebody, or host it:
53
69
 
54
70
  ```sh
55
- prolog-notebook build ch04-cut.prolog.md --out site/
71
+ prolog-notebook build splitting.prolog.md --out site/
56
72
  ```
57
73
 
58
74
  A plain directory: prerendered HTML with the saved answers in it, the runtime beside it, and
@@ -98,7 +114,7 @@ author's *guess* at what SWI prints, published as though it ran. So write the fi
98
114
  output blocks and let the engine fill them in:
99
115
 
100
116
  ```sh
101
- npx prolog-notebook run notebooks/ch04-cut.prolog.md
117
+ prolog-notebook execute chapter.prolog.md
102
118
  ```
103
119
 
104
120
  It consults every program cell, runs every query below it, and writes the solution sequences
@@ -106,11 +122,24 @@ back into the file along with an `input-hash` for each — which is what makes t
106
122
  complete, and render as *current*, before the engine arrives. Running it again on an unchanged
107
123
  chapter changes nothing.
108
124
 
125
+ And back out again, for a workbook edition or a diff you can read:
126
+
127
+ ```sh
128
+ prolog-notebook clear chapter.prolog.md
129
+ chapter.prolog.md: 4 answers removed
130
+ ```
131
+
132
+ `clear` empties every output block and touches nothing else; `execute` fills them in again from
133
+ the engine. A chapter with no answers is a valid chapter — one that has not been executed yet.
134
+
109
135
  | flag | |
110
136
  |---|---|
111
137
  | `--limit <n>` | solutions to take from one query before stopping. Default 100. |
112
138
  | `--stdout` | print the result instead of writing the file |
113
139
  | `--quiet` | report only failures |
140
+ | `--out <dir>` | where `build` writes. Default `<file>-site` |
141
+ | `--port <n>` | what `view` listens on. Default 8777, and it takes another if that one is busy |
142
+ | `--no-open` | `view` prints the URL instead of opening a browser |
114
143
  | `--version` | the tool's version, **the SWI-Prolog version it will run your chapters with**, and the copyright |
115
144
  | `--check-update` | ask npm whether a newer one exists, and say so either way |
116
145
 
@@ -135,7 +164,7 @@ doing the work — and if you say yes it upgrades, then runs your command on the
135
164
  One command, no re-run:
136
165
 
137
166
  ```
138
- $ prolog-notebook run ch04.prolog.md
167
+ $ prolog-notebook execute ch04.prolog.md
139
168
  You have Prolog Notebook 0.4.0. The latest is 0.4.2.
140
169
  Update and continue on the new version? [Y/n] y
141
170
  Updating with npm i -g prolog-notebook@0.4.2
@@ -158,6 +187,20 @@ It has no defence against a non-terminating goal yet — the engine runs in this
158
187
  not a solution count. Fixing it properly means a worker thread, and it is the prerequisite for
159
188
  putting this in CI.
160
189
 
190
+ ## Write one
191
+
192
+ **[The author's handbook](docs/authoring.md)** — the loop, a chapter from scratch, what `hold`
193
+ and `rerun` do to a reader, why you never hand-write an answer, publishing, and the things that
194
+ will otherwise cost you an afternoon.
195
+
196
+ | | |
197
+ |---|---|
198
+ | [docs/authoring.md](docs/authoring.md) | writing and publishing a chapter |
199
+ | [docs/format.md](docs/format.md) | the `.prolog.md` format, normatively |
200
+ | [docs/modes.md](docs/modes.md) | Read, Explore, Own — what a reader may change, and what may never be confused with what |
201
+ | [docs/binding.md](docs/binding.md) | chapters into books; why a notebook never states its own position |
202
+ | [docs/platform-seams.md](docs/platform-seams.md) | what is environment-specific, and where |
203
+
161
204
  ## Use it
162
205
 
163
206
  Headless, in Node — this is how you test that every example in a document still works:
@@ -268,14 +311,16 @@ Working and tested:
268
311
  - program cells and query cells with `Run` / `; next` / `all` / `stop`, per-cell reset, and a
269
312
  page that says what the engine is holding
270
313
  - `hold` and `rerun="auto"` — the author decides what a reader may see and when it refreshes
271
- - **the CLI**: `run` executes a chapter headlessly and writes its answers back, `view` opens it
272
- in a browser, `build` writes a page you can host or send
273
- - download your own copy of a chapter, answers and all
274
- - 186 passing tests
314
+ - **the CLI**: `execute` runs a chapter headlessly and writes its answers back, `clear` takes
315
+ them out again, `view` opens it in a browser, `build` writes a page you can host or send,
316
+ and it updates itself
317
+ - **page controls**: hide the saved answers to work a chapter cold, clear them out and restore
318
+ them, and download your own copy — yours or the chapter as published
319
+ - 253 passing tests
275
320
 
276
321
  Not built yet:
277
322
 
278
- - `--check` — run a chapter in CI and fail the build when its answers have drifted. Needs a
323
+ - `check` — run a chapter in CI and fail the build when its answers have drifted. Needs a
279
324
  timeout first: a test suite that can hang forever is not a test suite.
280
325
  - custom elements (`<prolog-program>`, `<prolog-query>`) so notebooks drop into any static site
281
326
  - a VS Code notebook controller — VS Code supplies the UI, this supplies the kernel, still no Python
@@ -4,7 +4,6 @@
4
4
  // Code "run all" and a future --check get the same behaviour without going
5
5
  // through a shell (869ectt38, 869ectt3e).
6
6
  import { createRequire } from 'node:module';
7
- import { spawn } from 'node:child_process';
8
7
  import { copyFileSync, mkdirSync, readFileSync, writeFileSync } from 'node:fs';
9
8
  import { basename, dirname, join, resolve } from 'node:path';
10
9
  import { parse, NotebookError } from '../src/format.js';
@@ -13,10 +12,10 @@ import { buildLine, currentBuild } from '../src/build-info.js';
13
12
  import { banner, VERSION } from '../src/version.js';
14
13
  import { updateNotice } from '../src/update.js';
15
14
  import { confirm, describeInstall, globalRoot, install, relaunch, upgradePlan } from '../src/upgrade.js';
16
- import { exportSource } from '../src/export.js';
15
+ import { clearedSource, exportSource } from '../src/export.js';
17
16
  import { runNotebook, DEFAULT_LIMIT } from '../src/run.js';
18
17
  import { buildFiles } from '../src/build.js';
19
- import { serve } from '../src/serve.js';
18
+ import { openInBrowser, serve } from '../src/serve.js';
20
19
 
21
20
  // The engine is imported WHERE IT IS USED, never at the top. src/node.js pulls in
22
21
  // 5.9 MB of WebAssembly at module scope, so a static import here would mean that
@@ -24,7 +23,7 @@ import { serve } from '../src/serve.js';
24
23
  // commands most likely to be typed at a broken install are --help and --version.
25
24
  const engine = () => import('../src/node.js');
26
25
 
27
- // `prolog-notebook run --stdout file | head` closes the pipe while we are still
26
+ // `prolog-notebook execute --stdout file | head` closes the pipe while we are still
28
27
  // writing to it. That is the reader using the shell correctly, not an error, and
29
28
  // a command that answers it with an unhandled EPIPE and a stack trace is
30
29
  // complaining about being used properly.
@@ -40,10 +39,11 @@ const require = createRequire(import.meta.url);
40
39
 
41
40
  const USAGE = `prolog-notebook — Jupyter-style notebooks for Prolog
42
41
 
43
- prolog-notebook view <file.prolog.md> read it in a browser, cells and all
44
- prolog-notebook build <file.prolog.md> write a page you can host or send
45
- prolog-notebook run <file.prolog.md>... run every cell, write the answers back
46
- prolog-notebook upgrade fetch the latest version
42
+ prolog-notebook view <file.prolog.md> read it in a browser, cells and all
43
+ prolog-notebook build <file.prolog.md> write a page you can host or send
44
+ prolog-notebook execute <file.prolog.md>... run every query, write the answers in
45
+ prolog-notebook clear <file.prolog.md>... take the answers back out
46
+ prolog-notebook upgrade fetch the latest version
47
47
 
48
48
  Options
49
49
  --limit <n> solutions to take from one query before stopping (default ${DEFAULT_LIMIT})
@@ -141,6 +141,38 @@ function canAsk() {
141
141
  return Boolean(process.stdin.isTTY && process.stderr.isTTY);
142
142
  }
143
143
 
144
+ /**
145
+ * The offer, BEFORE the command does anything — which is the only place it can
146
+ * change the outcome. Afterwards the files are written, the server is up, and a
147
+ * newer version has nothing left to do.
148
+ *
149
+ * Every command that does real work goes through here: `run`, `view` and
150
+ * `build`. It went in the execute path first and stayed there, so `view` — the
151
+ * command somebody is most likely to leave running — was the one that never
152
+ * looked.
153
+ *
154
+ * It costs a network round trip once a day, not once a run: the rest of the day
155
+ * is a file read.
156
+ *
157
+ * @returns {Promise<number|null>} an exit code when the command has been handed
158
+ * to a newer version, null to carry on here.
159
+ */
160
+ async function upgradeFirst({ quiet = false, asked = false } = {}) {
161
+ if (!canAsk() || (quiet && !asked)) return null;
162
+ const ahead = await updateNotice({ version: VERSION, force: asked })
163
+ .catch(() => ({ message: null, newer: null }));
164
+ if (ahead.message) process.stderr.write(`${ahead.message}\n`);
165
+ if (!ahead.newer || !(await confirm('Update and continue on the new version?'))) return null;
166
+ if ((await upgrade(ahead.newer)) !== 0) {
167
+ process.stderr.write('Carrying on with the version you have.\n');
168
+ return null;
169
+ }
170
+ process.stderr.write('Continuing on the new version.\n');
171
+ // The path has not changed — npm replaced what is behind it — so this is the
172
+ // same command, running the bytes that have just arrived.
173
+ return relaunch(process.argv);
174
+ }
175
+
144
176
  async function offerUpgrade(newer) {
145
177
  if (!canAsk()) {
146
178
  // Nobody to ask, so say what to type instead. `prolog-notebook upgrade`
@@ -182,12 +214,17 @@ async function main(argv) {
182
214
 
183
215
  const command = args.shift();
184
216
  if (command === 'view' || command === 'build') return page(command, args);
217
+ if (command === 'clear') return clear(args);
185
218
  if (command === 'upgrade') {
186
219
  const { message, newer } = await updateNotice({ version: VERSION, force: true });
187
220
  if (message) process.stderr.write(`${message}\n`);
188
221
  return newer ? upgrade(newer) : 0;
189
222
  }
190
- if (command !== 'run') {
223
+ // `execute` and `exec` are aliases and stay undocumented: one name is the name.
224
+ // `run` because it is published in every release since 0.3.0 and breaking it
225
+ // silently would be rude; `exec` because seven characters is a lot to type in
226
+ // a loop (869erp0jd).
227
+ if (!['execute', 'exec', 'run'].includes(command)) {
191
228
  process.stderr.write(`unknown command "${command}"\n\n${USAGE}`);
192
229
  return 2;
193
230
  }
@@ -215,35 +252,14 @@ async function main(argv) {
215
252
  }
216
253
 
217
254
  if (!files.length) {
218
- process.stderr.write('run needs at least one file\n');
255
+ process.stderr.write('execute needs at least one file\n');
219
256
  return 2;
220
257
  }
221
258
  if (!options.quiet) process.stderr.write(`${RUNAWAY_WARNING}\n`);
222
259
 
223
- // BEFORE THE WORK, when there is somebody to ask — because the point of asking
224
- // is to run the NEW version, and that is only possible while there is still
225
- // something to run. Afterwards the files are written and the answer comes too
226
- // late to change them.
227
- //
228
- // It costs a network round trip once a day, not once a run: the rest of the day
229
- // is a file read. Measured at 25-120 ms against npm, against a run that spends
230
- // seconds in Prolog.
231
- if (canAsk() && !options.quiet) {
232
- const ahead = await updateNotice({ version: VERSION, force: asked })
233
- .catch(() => ({ message: null, newer: null }));
234
- if (ahead.message) process.stderr.write(`${ahead.message}\n`);
235
- if (ahead.newer && await confirm('Update and continue on the new version?')) {
236
- if ((await upgrade(ahead.newer)) === 0) {
237
- process.stderr.write('Continuing on the new version.\n');
238
- // The path has not changed — npm replaced what is behind it — so this is
239
- // the same command, running the bytes that have just arrived.
240
- return relaunch(process.argv);
241
- }
242
- process.stderr.write('Carrying on with the version you have.\n');
243
- }
244
- // Asked and answered: the check below has nothing left to say.
245
- checked = true;
246
- }
260
+ const jump = await upgradeFirst({ quiet: options.quiet, asked });
261
+ if (jump !== null) return jump;
262
+ checked = canAsk() && (!options.quiet || asked);
247
263
 
248
264
  // STARTED NOW, READ AT THE END. The registry is somebody else's machine on
249
265
  // somebody else's network, and none of that should stand between the reader
@@ -272,7 +288,7 @@ async function main(argv) {
272
288
  status = Math.max(status, await runFile(file, session, options));
273
289
  }
274
290
 
275
- // stderr, always: `run --stdout` is a notebook going down a pipe, and a version
291
+ // stderr, always: `execute --stdout` is a notebook going down a pipe, and a version
276
292
  // notice in the middle of it would corrupt the file it is printing.
277
293
  const { message, newer } = await update;
278
294
  if (message) process.stderr.write(`${message}\n`);
@@ -282,6 +298,62 @@ async function main(argv) {
282
298
  return status;
283
299
  }
284
300
 
301
+ /**
302
+ * Take the answers back out.
303
+ *
304
+ * The counterpart to `execute`, and it exists because the alternative was editing
305
+ * the file by hand: a workbook edition with the answers withheld, a diff without
306
+ * nineteen solution sequences in the way, or starting again deliberately rather
307
+ * than trusting an overwrite.
308
+ *
309
+ * No engine, no network and no update check: this is a text operation on a file
310
+ * the reader already has.
311
+ */
312
+ async function clear(args) {
313
+ const options = { stdout: false, quiet: false };
314
+ const files = [];
315
+ for (const arg of args) {
316
+ if (arg === '--stdout') options.stdout = true;
317
+ else if (arg === '--quiet') options.quiet = true;
318
+ else if (arg.startsWith('-')) {
319
+ process.stderr.write(`unknown option "${arg}"\n`);
320
+ return 2;
321
+ } else files.push(arg);
322
+ }
323
+ if (!files.length) {
324
+ process.stderr.write('clear needs at least one file\n');
325
+ return 2;
326
+ }
327
+
328
+ let status = 0;
329
+ for (const file of files) {
330
+ let source;
331
+ let emptied;
332
+ try {
333
+ source = readFileSync(file, 'utf8');
334
+ emptied = clearedSource(parse(source));
335
+ } catch (e) {
336
+ process.stderr.write(`${file}: ${e.message}\n`);
337
+ status = 1;
338
+ continue;
339
+ }
340
+ if (options.stdout) {
341
+ process.stdout.write(emptied.text);
342
+ continue;
343
+ }
344
+ if (emptied.text === source) {
345
+ if (!options.quiet) process.stderr.write(`${basename(file)}: nothing to remove\n`);
346
+ continue;
347
+ }
348
+ writeFileSync(file, emptied.text);
349
+ if (!options.quiet) {
350
+ const n = emptied.cleared;
351
+ process.stderr.write(`${basename(file)}: ${n} answer${n === 1 ? '' : 's'} removed\n`);
352
+ }
353
+ }
354
+ return status;
355
+ }
356
+
285
357
  /**
286
358
  * `build` and `view`, which are the same page put in two different places.
287
359
  *
@@ -312,6 +384,12 @@ async function page(command, args) {
312
384
  return 2;
313
385
  }
314
386
 
387
+ // The same offer the execute path makes, and for the same reason: a server about to
388
+ // start, or a directory about to be written, is work that a newer version
389
+ // should be doing.
390
+ const jump = await upgradeFirst();
391
+ if (jump !== null) return jump;
392
+
315
393
  const file = files[0];
316
394
  let built;
317
395
  try {
@@ -336,26 +414,20 @@ async function page(command, args) {
336
414
  }
337
415
 
338
416
  const server = await serve(built, { port: options.port });
339
- process.stderr.write(`${basename(file)} at ${server.url}\n`);
417
+ // THE URL IS THIS COMMAND'S OUTPUT. `view` writes no notebook and no data to
418
+ // stdout, so there is nothing for it to corrupt — and a URL on stderr is a URL
419
+ // a wrapper does not see, which is how somebody came to type localhost by hand
420
+ // and land on another server entirely (869ernmvh).
421
+ process.stdout.write(`${server.url}\n`);
340
422
  if (server.port !== options.port) {
341
- process.stderr.write(`(${options.port} was taken)\n`);
423
+ process.stderr.write(`${options.port} was already answering — using ${server.port} instead.\n`);
342
424
  }
343
- process.stderr.write('Ctrl-C to stop.\n');
425
+ process.stderr.write(`${basename(file)} is at ${server.url} — Ctrl-C to stop.\n`);
344
426
  if (options.open) openInBrowser(server.url);
345
427
  // Deliberately never resolves: the server is the command.
346
428
  return new Promise(() => {});
347
429
  }
348
430
 
349
- /** Hand the URL to whatever the desktop uses. Failure is not worth reporting. */
350
- function openInBrowser(url) {
351
- const opener = { darwin: 'open', win32: 'start' }[process.platform] ?? 'xdg-open';
352
- try {
353
- spawn(opener, [url], { stdio: 'ignore', detached: true }).unref();
354
- } catch {
355
- // No desktop, or no opener: the URL is on screen either way.
356
- }
357
- }
358
-
359
431
  async function runFile(file, session, options) {
360
432
  const name = basename(file);
361
433
  let notebook;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "prolog-notebook",
3
- "version": "0.5.0",
3
+ "version": "0.6.0",
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": "084182f",
3
- "built": "2026-08-30 16:32:07 UTC"
2
+ "commit": "4e10d19",
3
+ "built": "2026-08-30 18:33:44 UTC"
4
4
  }
package/src/build.js CHANGED
@@ -23,6 +23,20 @@ export const RUNTIME = [
23
23
  'clauses.js', 'export.js', 'format.js', 'version.js',
24
24
  ];
25
25
 
26
+ /**
27
+ * The notebook's own prompt, `?-`, as a tab icon.
28
+ *
29
+ * A data: URI rather than a file, because the alternative is a favicon.ico 404 on
30
+ * every single load and this audience opens the console (869ernmxe). An SVG so it
31
+ * scales to whatever size the tab wants.
32
+ */
33
+ const FAVICON = encodeURIComponent(
34
+ '<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 32 32">'
35
+ + '<rect width="32" height="32" rx="7" fill="#faf7f0"/>'
36
+ + '<text x="16" y="23" font-family="ui-monospace,Menlo,monospace" font-size="19"'
37
+ + ' font-weight="600" fill="#8a3b1e" text-anchor="middle">?-</text></svg>',
38
+ );
39
+
26
40
  /** The one engine file: the bundle carries its own data. */
27
41
  export const ENGINE = 'swipl-bundle.js';
28
42
 
@@ -80,6 +94,7 @@ function page(notebook) {
80
94
  <meta charset="utf-8">
81
95
  <meta name="viewport" content="width=device-width, initial-scale=1">
82
96
  <title>${escapeHtml(titleOf(notebook))}</title>
97
+ <link rel="icon" href="data:image/svg+xml,${FAVICON}">
83
98
  <link rel="stylesheet" href="notebook.css">
84
99
  </head>
85
100
  <body>
package/src/export.js CHANGED
@@ -94,6 +94,29 @@ export function exportSource(notebook, edits) {
94
94
  return serialise(withEdits(notebook, edits));
95
95
  }
96
96
 
97
+ /**
98
+ * The chapter with its answers taken back out.
99
+ *
100
+ * A query cell with no output block is valid — the result is a chapter that
101
+ * simply has not been executed yet, and `execute` will fill it in again. Nothing
102
+ * else moves: prose, program cells, goals and attributes are the author's, and
103
+ * this is not an excuse to reformat them.
104
+ *
105
+ * It goes through the SAME ERASURE PATH the reader's download already uses:
106
+ * `output: null` has always meant "there are no answers to write down here"
107
+ * (869ejgbxf), so nothing new decides what an emptied cell means.
108
+ *
109
+ * @param {{frontMatter: Map<string, string>, cells: object[]}} notebook
110
+ * @returns {{text: string, cleared: number}} the bytes, and how many were emptied
111
+ */
112
+ export function clearedSource(notebook) {
113
+ const edits = new Map();
114
+ for (const cell of notebook.cells) {
115
+ if (cell.kind === 'query' && cell.output) edits.set(cell.id, { output: null });
116
+ }
117
+ return { text: exportSource(notebook, edits), cleared: edits.size };
118
+ }
119
+
97
120
  /**
98
121
  * A filename for the reader's copy.
99
122
  *
package/src/notebook.js CHANGED
@@ -25,6 +25,14 @@ let serial = 0;
25
25
  let panels = 0;
26
26
 
27
27
  /** Absolute, never relative: "3 minutes ago" is wrong the moment it is written. */
28
+ /**
29
+ * How long a reader may stop typing before a prediction counts as written.
30
+ *
31
+ * Long enough that a first keystroke does not reveal the answers, short enough
32
+ * that somebody who has finished sees the consequence of finishing.
33
+ */
34
+ const PREDICTION_PAUSE = 1200;
35
+
28
36
  function clock(date = new Date()) {
29
37
  return date.toLocaleTimeString(undefined, { hour12: false });
30
38
  }
@@ -190,6 +198,14 @@ const ICONS = {
190
198
  download: '<path d="M12 3.6v10.6"/><path d="m7.6 10.2 4.4 4.4 4.4-4.4"/><path d="M4.6 19.4h14.8"/>',
191
199
  hide: '<path d="M2.5 12S6 6 12 6s9.5 6 9.5 6-3.5 6-9.5 6-9.5-6-9.5-6Z"/><circle cx="12" cy="12" r="2.7"/><path d="M4 4l16 16"/>',
192
200
  show: '<path d="M2.5 12S6 6 12 6s9.5 6 9.5 6-3.5 6-9.5 6-9.5-6-9.5-6Z"/><circle cx="12" cy="12" r="2.7"/>',
201
+ // An eraser rubbing something out, NOT a bin. What clear does is undone by the
202
+ // button beside it, and a lid-and-body icon promises a finality this control
203
+ // does not have.
204
+ erase: '<path d="m7 21-4.3-4.3a2.4 2.4 0 0 1 0-3.4l9.6-9.6a2.4 2.4 0 0 1 3.4 0l5.6 5.6a2.4 2.4 0 0 1 0 3.4L13 21"/><path d="M21.4 21H7"/><path d="m5 11 9 9"/>',
205
+ // The undo hook, deliberately not the engine's circular arrow two rows below:
206
+ // two glyphs that near-match in one small card is how a reader presses the
207
+ // wrong one.
208
+ restore: '<path d="M3.2 7.4v6.2h6.2"/><path d="M20.8 17.2a9 9 0 0 0-9-9 9 9 0 0 0-6.4 2.6L3.2 13.6"/>',
193
209
  };
194
210
 
195
211
  function icon(name) {
@@ -218,6 +234,13 @@ function mountPageBar(root, options, bus, programs, queries) {
218
234
  + `<span class="dot"></span><span class="count"></span><span class="chev">${icon('chevron')}</span></button>`
219
235
  + `<div class="panel" id="${panelId}">`
220
236
  + '<div class="unit answers"><span class="state answers-state"></span></div>'
237
+ // THE SAME ANSWERS, THE OTHER QUESTION. The row above is about the screen —
238
+ // put them out of sight, work the chapter cold, they are still the chapter's.
239
+ // This one is about the FILE: an output cleared here is gone from the page
240
+ // and from a download of it, which is why its verb is undone by restore and
241
+ // not by show. Two rows because they are two questions, adjacent because a
242
+ // reader comparing them is exactly what tells them apart.
243
+ + '<div class="unit outputs"><span class="state outputs-state"></span></div>'
221
244
  + '<div class="unit"><span class="state engine-state"></span>'
222
245
  + `<button data-act="restart"><span class="icon">${icon('power')}</span>`
223
246
  + '<span class="label">Start engine</span></button></div>'
@@ -338,8 +361,14 @@ function mountPageBar(root, options, bus, programs, queries) {
338
361
  let age = null;
339
362
 
340
363
  bus.on((event) => {
341
- // A cell's own hide control moved something this panel is reporting on.
342
- if (event.kind === 'answers') return refreshAnswers();
364
+ // A cell's own hide or reset control moved something this panel is reporting
365
+ // on both rows, since a cell restored on its own leaves one fewer cleared
366
+ // and one more showing.
367
+ if (event.kind === 'answers') {
368
+ refreshAnswers();
369
+ refreshOutputs();
370
+ return;
371
+ }
343
372
  if (event.kind === 'booting') {
344
373
  // Lit from wherever the engine was asked for — a Run halfway up the chapter
345
374
  // starts it just as this button does, and the light should not care which.
@@ -432,6 +461,73 @@ function mountPageBar(root, options, bus, programs, queries) {
432
461
  refreshAnswers();
433
462
  }
434
463
 
464
+ /**
465
+ * Take the answers out of the chapter, and put them back (869erp9ap).
466
+ *
467
+ * The page's half of `prolog-notebook clear`. An author emptying a chapter has
468
+ * the command; a reader who wants the file without the answers — to work
469
+ * through it later, to hand it to somebody, to keep a copy that gives nothing
470
+ * away — had to edit markdown by hand, which is the same "that's tedious" that
471
+ * put the command in the CLI.
472
+ *
473
+ * WHAT MAKES IT SAFE TO OFFER is that it is reversible in the page's existing
474
+ * vocabulary. Clear is a third way AWAY from the chapter, beside running and
475
+ * editing, and reset is still the one way back: per cell for one of them, and
476
+ * restore here for all at once.
477
+ */
478
+ const outputsUnit = bar.querySelector('.unit.outputs');
479
+ const outputsState = bar.querySelector('.outputs-state');
480
+ let refreshOutputs = () => {};
481
+ // Nothing published to clear, and nothing a restore could give back. The row
482
+ // goes, exactly as the one above it does — an unrun chapter is the CLI's
483
+ // business, and a control whose only possible effect is on the reader's own
484
+ // run is a control offering to undo the thing they just asked for.
485
+ if (!spoilers.length) {
486
+ outputsUnit.remove();
487
+ } else {
488
+ const wipe = document.createElement('button');
489
+ wipe.dataset.act = 'clear-all';
490
+ wipe.innerHTML = '<span class="icon"></span><span class="label"></span>';
491
+ outputsUnit.appendChild(wipe);
492
+
493
+ const plural = (n, word) => `${n} ${word}${n === 1 ? '' : 's'}`;
494
+
495
+ refreshOutputs = () => {
496
+ const gone = queries.filter((q) => q.isCleared());
497
+ const left = queries.filter((q) => q.hasOutput());
498
+ // Restore only once there is nothing left to clear, which is the rule the
499
+ // hide/show button already follows: in a half-cleared page the useful move
500
+ // is to finish, and one vocabulary is cheaper to learn than two.
501
+ const back = left.length === 0 && gone.length > 0;
502
+ // A cleared cell that never had saved answers has nothing of the chapter's
503
+ // to give back, so a page of only those leaves restore with no work.
504
+ const restorable = gone.filter((q) => q.hasSaved).length;
505
+ wipe.disabled = back ? restorable === 0 : left.length === 0;
506
+ // COUNTED FROM THE FILE while nothing is cleared, because that is the fact
507
+ // the reader does not have: how much of this chapter is answers. After
508
+ // that it counts what they did, which is the fact they want confirmed.
509
+ outputsState.textContent = gone.length === 0
510
+ ? `${plural(spoilers.length, 'output')} in this chapter`
511
+ : `${plural(gone.length, 'output')} cleared`;
512
+ label(wipe, back ? 'restore' : 'erase',
513
+ back ? 'Restore outputs' : 'Clear all outputs');
514
+ wipe.title = back
515
+ ? 'put the chapter’s own answers back into every cell you cleared'
516
+ : 'empty every output on this page, your own runs included — a download'
517
+ + ' taken then carries none of them, and restore puts the chapter’s back';
518
+ render();
519
+ };
520
+
521
+ wipe.addEventListener('click', () => {
522
+ const left = queries.filter((q) => q.hasOutput());
523
+ if (left.length) for (const q of left) q.clear();
524
+ else for (const q of queries) if (q.isCleared()) q.restore();
525
+ refreshOutputs();
526
+ refreshAnswers();
527
+ });
528
+ refreshOutputs();
529
+ }
530
+
435
531
  restart.title = 'download SWI-Prolog and have it ready, so your first Run is not the slow one';
436
532
 
437
533
  restart.addEventListener('click', async () => {
@@ -815,6 +911,14 @@ function mountQuery(cell, options, bus, { above = [], below = [], prediction = n
815
911
  // to the chapter.
816
912
  let mine = false;
817
913
  let hidden = false;
914
+ // Emptied on purpose, by the page's own control (869erp9ap). A THIRD state
915
+ // rather than a kind of hiding: hidden answers are still the chapter's and
916
+ // still go into a download, and cleared ones are gone from both. What the two
917
+ // have in common is only that the box looks empty.
918
+ //
919
+ // It is a way AWAY from the chapter, like a run and like an edit, so the way
920
+ // back is the one that has always existed — reset.
921
+ let cleared = false;
818
922
  // The author's own spoiler mark (format §5). It is a starting state rather than
819
923
  // a lock: the reader can always press show, because withholding the answer from
820
924
  // someone who has decided they want it is theatre, not teaching.
@@ -870,7 +974,11 @@ function mountQuery(cell, options, bus, { above = [], below = [], prediction = n
870
974
 
871
975
  const refresh = () => {
872
976
  if (resetBtn) {
873
- const changed = mine || input.value !== published.goal;
977
+ // Cleared counts, and it is the reason this cell can be brought back one at
978
+ // a time out of a page-wide clear. A reset button left grey over an emptied
979
+ // cell would say the answers are gone for good, which is the opposite of
980
+ // what this control is for.
981
+ const changed = mine || cleared || input.value !== published.goal;
874
982
  cell.dataset.edited = String(changed);
875
983
  resetBtn.disabled = !changed;
876
984
  resetBtn.title = changed
@@ -1080,9 +1188,13 @@ function mountQuery(cell, options, bus, { above = [], below = [], prediction = n
1080
1188
  // with the reader's own is fine; replacing them SILENTLY is not, so the run is
1081
1189
  // labelled and the way back is stated (docs/modes.md §3) — and the way back is
1082
1190
  // now a button on this cell rather than a page reload.
1083
- const hadSaved = !mine && out.querySelector('.line.from') !== null;
1191
+ // ASKED OF THE CHAPTER, not of the screen. A cleared cell has nothing on
1192
+ // screen to have come from the chapter, but the chapter's answers are still
1193
+ // one press of reset away — so the note that says so is still owed.
1194
+ const hadSaved = !mine && published.out !== '';
1084
1195
  out.innerHTML = '';
1085
1196
  mine = true;
1197
+ cleared = false;
1086
1198
  // This cell has just stopped showing the chapter's answers, which changes what
1087
1199
  // the page's control is counting. setHidden only speaks up when the hidden
1088
1200
  // flag itself moves, so a cell that was already visible would leave the count
@@ -1197,6 +1309,12 @@ function mountQuery(cell, options, bus, { above = [], below = [], prediction = n
1197
1309
  // that quizzes the reader and then answers itself is worse than one that
1198
1310
  // never asked.
1199
1311
  if (held) return;
1312
+ // AND A CLEARED CELL STAYS CLEARED. Auto exists so that answers do not go
1313
+ // stale under a reader who changed the program; a cell with no answers has
1314
+ // none that can. Refilling it would be the page overruling the reader who
1315
+ // emptied it, and doing so behind their back — they pressed Consult
1316
+ // somewhere else entirely.
1317
+ if (cleared) return;
1200
1318
  if (running || query) return;
1201
1319
  if (!outOfDate()) return;
1202
1320
  bus.queue(() => {
@@ -1269,18 +1387,59 @@ function mountQuery(cell, options, bus, { above = [], below = [], prediction = n
1269
1387
  * disagreed in fact — the same argument that makes a program cell's reset
1270
1388
  * un-consult.
1271
1389
  */
1272
- resetBtn?.addEventListener('click', () => {
1390
+ const restore = ({ goal = true } = {}) => {
1273
1391
  query?.close();
1274
- input.value = published.goal;
1392
+ if (goal) input.value = published.goal;
1275
1393
  out.innerHTML = published.out;
1276
1394
  mine = false;
1395
+ cleared = false;
1277
1396
  ran = null;
1278
1397
  engineChanged = false;
1279
1398
  decorateSaved();
1280
1399
  // Back in the set the page's control acts on, for the same reason.
1281
1400
  bus.emit({ kind: 'answers' });
1282
1401
  finish();
1283
- });
1402
+ };
1403
+
1404
+ resetBtn?.addEventListener('click', () => restore());
1405
+
1406
+ /**
1407
+ * Take the answers out — the chapter's included.
1408
+ *
1409
+ * The reader's half of `prolog-notebook clear`, and the same claim: there are
1410
+ * no answers here. Not a stronger kind of hiding — a download taken now
1411
+ * carries no output block for this cell, which is the whole difference and the
1412
+ * reason it is undone by reset rather than by show.
1413
+ *
1414
+ * The reader's own run goes with it, because "clear all output" that leaves
1415
+ * some output on the page has not done what it says. That is no worse than
1416
+ * what reset has always done to a run, and Run reproduces it.
1417
+ */
1418
+ const clear = () => {
1419
+ if (!mine && published.out === '') return false;
1420
+ // An open sequence in a cell showing nothing is a frame held in the reader's
1421
+ // name against a cell they have emptied — the same argument that makes reset
1422
+ // close one.
1423
+ query?.close();
1424
+ out.innerHTML = '';
1425
+ mine = false;
1426
+ cleared = true;
1427
+ ran = null;
1428
+ engineChanged = false;
1429
+ produced = [];
1430
+ failed = null;
1431
+ exhausted = false;
1432
+ count = 0;
1433
+ // Nothing to hide, and nothing to be held back from: the box is empty, and a
1434
+ // cell that still said "held until you run it" would be describing a wait
1435
+ // that no longer has anything to wait for.
1436
+ hidden = false;
1437
+ held = false;
1438
+ out.classList.remove('answers-hidden');
1439
+ bus.emit({ kind: 'answers' });
1440
+ finish();
1441
+ return true;
1442
+ };
1284
1443
 
1285
1444
  input.addEventListener('input', refresh);
1286
1445
 
@@ -1294,11 +1453,31 @@ function mountQuery(cell, options, bus, { above = [], below = [], prediction = n
1294
1453
 
1295
1454
  if (held) {
1296
1455
  hidden = true;
1297
- // `change` rather than `input`: it fires when they leave the box, so the
1298
- // answers do not appear under a reader who is still mid-sentence. An empty
1299
- // box is not a prediction, so it does not end the wait.
1300
- prediction?.addEventListener('change', () => {
1456
+ /**
1457
+ * AS THEY WRITE, not when they leave the box.
1458
+ *
1459
+ * This listened on `change` alone, which fires on blur — so a reader who
1460
+ * typed their prediction and looked up saw nothing happen, and the link
1461
+ * between "I wrote something" and "the answers appeared" was broken by a
1462
+ * pause with no cause (869ernmzh). The original reasoning was about the
1463
+ * FIRST KEYSTROKE and it over-corrected: they have committed as soon as they
1464
+ * have written something.
1465
+ *
1466
+ * Debounced, so one character does not reveal the chapter and a reader who is
1467
+ * still typing is not interrupted. `change` stays as well, so leaving the box
1468
+ * is immediate. An empty box is still not a prediction.
1469
+ */
1470
+ const release = () => {
1301
1471
  if (held && prediction.value.trim() !== '') setHidden(false);
1472
+ };
1473
+ let pause = null;
1474
+ prediction?.addEventListener('input', () => {
1475
+ clearTimeout(pause);
1476
+ pause = setTimeout(release, PREDICTION_PAUSE);
1477
+ });
1478
+ prediction?.addEventListener('change', () => {
1479
+ clearTimeout(pause);
1480
+ release();
1302
1481
  });
1303
1482
  }
1304
1483
 
@@ -1315,11 +1494,23 @@ function mountQuery(cell, options, bus, { above = [], below = [], prediction = n
1315
1494
  * control acts on what is on screen, and after a run the chapter's answers
1316
1495
  * are behind reset rather than in front of the reader.
1317
1496
  */
1318
- showsChapter: () => !mine && published.out !== '',
1497
+ showsChapter: () => !mine && !cleared && published.out !== '',
1319
1498
  setHidden,
1320
1499
  isHidden: () => hidden,
1321
- isEdited: () => mine || input.value !== published.goal,
1500
+ isEdited: () => mine || cleared || input.value !== published.goal,
1322
1501
  goal: () => input.value,
1502
+ /** Is there an output on screen at all — the chapter's or the reader's? */
1503
+ hasOutput: () => !cleared && (mine || published.out !== ''),
1504
+ isCleared: () => cleared,
1505
+ clear,
1506
+ /**
1507
+ * Put the chapter's answers back, and nothing else.
1508
+ *
1509
+ * The exact inverse of clear, which is what lets the page offer the pair as
1510
+ * one control: an edited goal is not an answer and was not what clear took
1511
+ * away, so restoring one must not quietly discard the other.
1512
+ */
1513
+ restore: () => restore({ goal: false }),
1323
1514
  /**
1324
1515
  * This cell's answers for an export, in the format's own spelling (§6).
1325
1516
  *
@@ -1330,6 +1521,10 @@ function mountQuery(cell, options, bus, { above = [], below = [], prediction = n
1330
1521
  * has actually produced.
1331
1522
  */
1332
1523
  output: () => {
1524
+ // `null` is the one that ERASES, and it is the same null the CLI's clear
1525
+ // writes (src/export.js): a chapter emptied on the page and one emptied at
1526
+ // the terminal produce the same bytes, because they take the same path.
1527
+ if (cleared) return null;
1333
1528
  if (!mine) return undefined;
1334
1529
  // Stopping and finishing look the same from outside — both leave no open
1335
1530
  // query — so `exhausted` is the only thing that distinguishes them.
package/src/render.js CHANGED
@@ -85,7 +85,7 @@ export function escapeHtml(text) {
85
85
  * @param {{variant: string, title: string, body: string}} cell
86
86
  * @returns {string}
87
87
  */
88
- export function renderContainer(cell) {
88
+ export function renderContainer(cell, ordinal = 1) {
89
89
  switch (cell.variant) {
90
90
  case 'margin':
91
91
  // The whole note lives in the head line — `> [!margin] text with no body` —
@@ -122,7 +122,7 @@ function joinHeadAndBody(cell) {
122
122
  * markup for a place to answer it. The reveal is a <details> so that it still
123
123
  * works, unclicked, on the GitHub page.
124
124
  */
125
- function renderPredict(cell) {
125
+ function renderPredict(cell, ordinal = 1) {
126
126
  const { before, summary, reveal } = splitReveal(cell.body);
127
127
  const parts = [];
128
128
  if (cell.title) parts.push(`<h3>${renderInline(cell.title)}</h3>`);
@@ -131,7 +131,11 @@ function renderPredict(cell) {
131
131
  // the format has no spelling for a per-prediction one, and inventing an
132
132
  // attribute for it would be a format change to save one line of prose — the
133
133
  // author's own question is directly above it and says what to write.
134
- parts.push('<textarea placeholder="your prediction…" spellcheck="false"></textarea>');
134
+ // An id, because a form field without one is a warning in every browser's
135
+ // issues panel and this audience opens the issues panel (869ernmxe). Minted
136
+ // from position, since a container carries no id in the model — and stable for
137
+ // a given chapter, which is what a saved prediction will need (869ectt5d).
138
+ parts.push(`<textarea id="predict-${ordinal}" placeholder="your prediction…" spellcheck="false"></textarea>`);
135
139
  if (reveal !== null) {
136
140
  parts.push(`<details>\n<summary>${escapeHtml(summary)}</summary>\n${renderProse(reveal)}\n</details>`);
137
141
  }
@@ -177,7 +181,7 @@ export function renderProgram(cell) {
177
181
  <div class="bar">program<span class="spacer"></span><span class="status"></span>
178
182
  <button data-act="reset" disabled>reset</button>
179
183
  <button class="primary" data-act="consult">Consult</button></div>
180
- <textarea spellcheck="false">${escapeHtml(cell.source)}</textarea>
184
+ <textarea id="src-${escapeHtml(cell.id)}" spellcheck="false">${escapeHtml(cell.source)}</textarea>
181
185
  </div>`;
182
186
  }
183
187
 
@@ -217,7 +221,8 @@ export function renderQuery(cell, options = {}) {
217
221
  <button data-act="next" disabled>; next</button>
218
222
  <button data-act="all" disabled>all</button>
219
223
  <button data-act="stop" disabled>stop</button></div>
220
- <div class="prompt"><span>?-</span><input value="${escapeHtml(cell.goal)}" spellcheck="false"></div>
224
+ <div class="prompt"><span>?-</span>`
225
+ + `<input id="goal-${escapeHtml(cell.id)}" value="${escapeHtml(cell.goal)}" spellcheck="false"></div>
221
226
  <div class="out">${renderSavedOutput(cell, { stale })}</div>
222
227
  </div>`;
223
228
  }
@@ -305,7 +310,7 @@ export function renderCell(cell, options = {}) {
305
310
  case 'markdown':
306
311
  return renderProse(cell.source);
307
312
  case 'container':
308
- return renderContainer(cell);
313
+ return renderContainer(cell, options.ordinal);
309
314
  case 'program':
310
315
  return renderProgram(cell);
311
316
  case 'query':
@@ -333,7 +338,9 @@ export function renderNotebook(notebook) {
333
338
  const parts = [];
334
339
  const kicker = renderKicker(notebook.frontMatter);
335
340
  if (kicker) parts.push(kicker);
341
+ let predictions = 0;
336
342
  for (const cell of notebook.cells) {
343
+ if (cell.kind === 'container' && cell.variant === 'predict') predictions += 1;
337
344
  // Staleness is decided here rather than in renderQuery, because it is a fact
338
345
  // about the cell's PLACE in the notebook — the program cells above it — and a
339
346
  // query cell on its own cannot know it. Computed before first paint: a 64-bit
@@ -348,7 +355,7 @@ export function renderNotebook(notebook) {
348
355
  const rerun = cell.kind === 'query'
349
356
  ? cell.rerun ?? notebook.frontMatter.get('rerun') ?? 'manual'
350
357
  : null;
351
- parts.push(renderCell(cell, { stale, rerun }));
358
+ parts.push(renderCell(cell, { stale, rerun, ordinal: predictions }));
352
359
  }
353
360
  return `${parts.join('\n\n')}\n`;
354
361
  }
package/src/serve.js CHANGED
@@ -8,8 +8,10 @@
8
8
  // A server of about forty lines rather than a dependency: it answers GET for a
9
9
  // fixed set of paths that this process generated, and 404s everything else. It
10
10
  // is not a static file server and must not become one.
11
+ import { spawn } from 'node:child_process';
11
12
  import { createReadStream } from 'node:fs';
12
13
  import { createServer } from 'node:http';
14
+ import { connect } from 'node:net';
13
15
 
14
16
  const TYPES = {
15
17
  '.html': 'text/html; charset=utf-8',
@@ -34,6 +36,15 @@ export function contentType(name) {
34
36
  * @returns {Promise<{url: string, port: number, close: () => Promise<void>}>}
35
37
  */
36
38
  export async function serve(files, { port = 8777, host = '127.0.0.1' } = {}) {
39
+ // ASK WHETHER ANYBODY IS THERE, on both stacks, before binding to one of them.
40
+ //
41
+ // An IPv6 wildcard listener — `python3 -m http.server --bind ::` — does not
42
+ // collide with an IPv4 loopback bind, so EADDRINUSE never fires and the bind
43
+ // succeeds. `localhost` then resolves to ::1 first, and the reader gets the
44
+ // other server's directory listing while this one sits unreachable on
45
+ // 127.0.0.1 with nothing anywhere saying why (869ernmvh). Found by somebody
46
+ // authoring their first chapter, which is exactly where it would be found.
47
+ if (port !== 0 && await occupied(port)) port = 0;
37
48
  const server = createServer((request, response) => {
38
49
  // Only GET, and only the names this process generated: the path never
39
50
  // reaches the filesystem, so there is nothing for a `..` to escape into.
@@ -78,3 +89,53 @@ function listen(server, port, host) {
78
89
  server.listen(port, host, () => resolve(server.address().port));
79
90
  });
80
91
  }
92
+
93
+ /**
94
+ * Hand the URL to whatever the desktop uses.
95
+ *
96
+ * TWO WAYS THIS GOES WRONG, both found by reading it rather than running it:
97
+ *
98
+ * - `start` on Windows is a SHELL BUILTIN, not a program, so spawning it by name
99
+ * fails every time. It has to be run through cmd, and the empty string is
100
+ * cmd's title argument — without it, a quoted URL becomes the window title and
101
+ * nothing opens.
102
+ * - spawn reports a missing program ASYNCHRONOUSLY. A try/catch around it catches
103
+ * nothing, and an 'error' event with no listener is an uncaught exception —
104
+ * which took the whole command down, AFTER the server had started, on any
105
+ * machine without an opener. A listener that does nothing is the fix: the URL
106
+ * is on screen either way, and a browser that will not open is not a reason to
107
+ * stop serving.
108
+ */
109
+ export function openInBrowser(url, { spawnImpl = spawn, platform = process.platform } = {}) {
110
+ const argv = platform === 'win32'
111
+ ? ['cmd', ['/c', 'start', '', url]]
112
+ : [platform === 'darwin' ? 'open' : 'xdg-open', [url]];
113
+ const child = spawnImpl(argv[0], argv[1], { stdio: 'ignore', detached: true });
114
+ child.on('error', () => {});
115
+ child.unref?.();
116
+ return child;
117
+ }
118
+
119
+ /**
120
+ * Is something already answering on this port, on either stack?
121
+ *
122
+ * A connect, not a bind: the question is "will the reader reach somebody else
123
+ * here", and a bind can succeed while the answer is yes.
124
+ */
125
+ export async function occupied(port, { hosts = ['127.0.0.1', '::1'], timeout = 300 } = {}) {
126
+ const answers = await Promise.all(hosts.map((host) => reachable(host, port, timeout)));
127
+ return answers.some(Boolean);
128
+ }
129
+
130
+ function reachable(host, port, timeout) {
131
+ return new Promise((resolve) => {
132
+ const socket = connect({ host, port, timeout });
133
+ const done = (answer) => {
134
+ socket.destroy();
135
+ resolve(answer);
136
+ };
137
+ socket.once('connect', () => done(true));
138
+ socket.once('error', () => done(false));
139
+ socket.once('timeout', () => done(false));
140
+ });
141
+ }
package/src/upgrade.js CHANGED
@@ -67,7 +67,7 @@ export async function globalRoot(exec = run) {
67
67
  /**
68
68
  * Ask a yes/no question, defaulting to yes.
69
69
  *
70
- * ON STDERR, always: `run --stdout` is a notebook going down a pipe, and a
70
+ * ON STDERR, always: `execute --stdout` is a notebook going down a pipe, and a
71
71
  * question in the middle of it would corrupt the file it is writing.
72
72
  *
73
73
  * @returns {Promise<boolean>}
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.5.0';
13
+ export const VERSION = '0.6.0';
14
14
 
15
15
  /** The two facts a licence notice is actually made of. */
16
16
  export const YEAR = '2026';