prolog-notebook 0.5.1 → 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,54 @@
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
+
3
52
  ## [0.5.1] — 2026-08-30
4
53
 
5
54
  Everything here came from one field report: a chapter written with the tool rather than a test
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
@@ -12,7 +12,7 @@ import { buildLine, currentBuild } from '../src/build-info.js';
12
12
  import { banner, VERSION } from '../src/version.js';
13
13
  import { updateNotice } from '../src/update.js';
14
14
  import { confirm, describeInstall, globalRoot, install, relaunch, upgradePlan } from '../src/upgrade.js';
15
- import { exportSource } from '../src/export.js';
15
+ import { clearedSource, exportSource } from '../src/export.js';
16
16
  import { runNotebook, DEFAULT_LIMIT } from '../src/run.js';
17
17
  import { buildFiles } from '../src/build.js';
18
18
  import { openInBrowser, serve } from '../src/serve.js';
@@ -23,7 +23,7 @@ import { openInBrowser, serve } from '../src/serve.js';
23
23
  // commands most likely to be typed at a broken install are --help and --version.
24
24
  const engine = () => import('../src/node.js');
25
25
 
26
- // `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
27
27
  // writing to it. That is the reader using the shell correctly, not an error, and
28
28
  // a command that answers it with an unhandled EPIPE and a stack trace is
29
29
  // complaining about being used properly.
@@ -39,10 +39,11 @@ const require = createRequire(import.meta.url);
39
39
 
40
40
  const USAGE = `prolog-notebook — Jupyter-style notebooks for Prolog
41
41
 
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 run <file.prolog.md>... run every cell, write the answers back
45
- 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
46
47
 
47
48
  Options
48
49
  --limit <n> solutions to take from one query before stopping (default ${DEFAULT_LIMIT})
@@ -146,7 +147,7 @@ function canAsk() {
146
147
  * newer version has nothing left to do.
147
148
  *
148
149
  * Every command that does real work goes through here: `run`, `view` and
149
- * `build`. It went in the run path first and stayed there, so `view` — the
150
+ * `build`. It went in the execute path first and stayed there, so `view` — the
150
151
  * command somebody is most likely to leave running — was the one that never
151
152
  * looked.
152
153
  *
@@ -213,12 +214,17 @@ async function main(argv) {
213
214
 
214
215
  const command = args.shift();
215
216
  if (command === 'view' || command === 'build') return page(command, args);
217
+ if (command === 'clear') return clear(args);
216
218
  if (command === 'upgrade') {
217
219
  const { message, newer } = await updateNotice({ version: VERSION, force: true });
218
220
  if (message) process.stderr.write(`${message}\n`);
219
221
  return newer ? upgrade(newer) : 0;
220
222
  }
221
- 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)) {
222
228
  process.stderr.write(`unknown command "${command}"\n\n${USAGE}`);
223
229
  return 2;
224
230
  }
@@ -246,7 +252,7 @@ async function main(argv) {
246
252
  }
247
253
 
248
254
  if (!files.length) {
249
- process.stderr.write('run needs at least one file\n');
255
+ process.stderr.write('execute needs at least one file\n');
250
256
  return 2;
251
257
  }
252
258
  if (!options.quiet) process.stderr.write(`${RUNAWAY_WARNING}\n`);
@@ -282,7 +288,7 @@ async function main(argv) {
282
288
  status = Math.max(status, await runFile(file, session, options));
283
289
  }
284
290
 
285
- // 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
286
292
  // notice in the middle of it would corrupt the file it is printing.
287
293
  const { message, newer } = await update;
288
294
  if (message) process.stderr.write(`${message}\n`);
@@ -292,6 +298,62 @@ async function main(argv) {
292
298
  return status;
293
299
  }
294
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
+
295
357
  /**
296
358
  * `build` and `view`, which are the same page put in two different places.
297
359
  *
@@ -322,7 +384,7 @@ async function page(command, args) {
322
384
  return 2;
323
385
  }
324
386
 
325
- // The same offer the run path makes, and for the same reason: a server about to
387
+ // The same offer the execute path makes, and for the same reason: a server about to
326
388
  // start, or a directory about to be written, is work that a newer version
327
389
  // should be doing.
328
390
  const jump = await upgradeFirst();
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "prolog-notebook",
3
- "version": "0.5.1",
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": "3ed80f2",
3
- "built": "2026-08-30 17:12:10 UTC"
2
+ "commit": "4e10d19",
3
+ "built": "2026-08-30 18:33:44 UTC"
4
4
  }
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
@@ -198,6 +198,14 @@ const ICONS = {
198
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"/>',
199
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"/>',
200
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"/>',
201
209
  };
202
210
 
203
211
  function icon(name) {
@@ -226,6 +234,13 @@ function mountPageBar(root, options, bus, programs, queries) {
226
234
  + `<span class="dot"></span><span class="count"></span><span class="chev">${icon('chevron')}</span></button>`
227
235
  + `<div class="panel" id="${panelId}">`
228
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>'
229
244
  + '<div class="unit"><span class="state engine-state"></span>'
230
245
  + `<button data-act="restart"><span class="icon">${icon('power')}</span>`
231
246
  + '<span class="label">Start engine</span></button></div>'
@@ -346,8 +361,14 @@ function mountPageBar(root, options, bus, programs, queries) {
346
361
  let age = null;
347
362
 
348
363
  bus.on((event) => {
349
- // A cell's own hide control moved something this panel is reporting on.
350
- 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
+ }
351
372
  if (event.kind === 'booting') {
352
373
  // Lit from wherever the engine was asked for — a Run halfway up the chapter
353
374
  // starts it just as this button does, and the light should not care which.
@@ -440,6 +461,73 @@ function mountPageBar(root, options, bus, programs, queries) {
440
461
  refreshAnswers();
441
462
  }
442
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
+
443
531
  restart.title = 'download SWI-Prolog and have it ready, so your first Run is not the slow one';
444
532
 
445
533
  restart.addEventListener('click', async () => {
@@ -823,6 +911,14 @@ function mountQuery(cell, options, bus, { above = [], below = [], prediction = n
823
911
  // to the chapter.
824
912
  let mine = false;
825
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;
826
922
  // The author's own spoiler mark (format §5). It is a starting state rather than
827
923
  // a lock: the reader can always press show, because withholding the answer from
828
924
  // someone who has decided they want it is theatre, not teaching.
@@ -878,7 +974,11 @@ function mountQuery(cell, options, bus, { above = [], below = [], prediction = n
878
974
 
879
975
  const refresh = () => {
880
976
  if (resetBtn) {
881
- 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;
882
982
  cell.dataset.edited = String(changed);
883
983
  resetBtn.disabled = !changed;
884
984
  resetBtn.title = changed
@@ -1088,9 +1188,13 @@ function mountQuery(cell, options, bus, { above = [], below = [], prediction = n
1088
1188
  // with the reader's own is fine; replacing them SILENTLY is not, so the run is
1089
1189
  // labelled and the way back is stated (docs/modes.md §3) — and the way back is
1090
1190
  // now a button on this cell rather than a page reload.
1091
- 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 !== '';
1092
1195
  out.innerHTML = '';
1093
1196
  mine = true;
1197
+ cleared = false;
1094
1198
  // This cell has just stopped showing the chapter's answers, which changes what
1095
1199
  // the page's control is counting. setHidden only speaks up when the hidden
1096
1200
  // flag itself moves, so a cell that was already visible would leave the count
@@ -1205,6 +1309,12 @@ function mountQuery(cell, options, bus, { above = [], below = [], prediction = n
1205
1309
  // that quizzes the reader and then answers itself is worse than one that
1206
1310
  // never asked.
1207
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;
1208
1318
  if (running || query) return;
1209
1319
  if (!outOfDate()) return;
1210
1320
  bus.queue(() => {
@@ -1277,18 +1387,59 @@ function mountQuery(cell, options, bus, { above = [], below = [], prediction = n
1277
1387
  * disagreed in fact — the same argument that makes a program cell's reset
1278
1388
  * un-consult.
1279
1389
  */
1280
- resetBtn?.addEventListener('click', () => {
1390
+ const restore = ({ goal = true } = {}) => {
1281
1391
  query?.close();
1282
- input.value = published.goal;
1392
+ if (goal) input.value = published.goal;
1283
1393
  out.innerHTML = published.out;
1284
1394
  mine = false;
1395
+ cleared = false;
1285
1396
  ran = null;
1286
1397
  engineChanged = false;
1287
1398
  decorateSaved();
1288
1399
  // Back in the set the page's control acts on, for the same reason.
1289
1400
  bus.emit({ kind: 'answers' });
1290
1401
  finish();
1291
- });
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
+ };
1292
1443
 
1293
1444
  input.addEventListener('input', refresh);
1294
1445
 
@@ -1343,11 +1494,23 @@ function mountQuery(cell, options, bus, { above = [], below = [], prediction = n
1343
1494
  * control acts on what is on screen, and after a run the chapter's answers
1344
1495
  * are behind reset rather than in front of the reader.
1345
1496
  */
1346
- showsChapter: () => !mine && published.out !== '',
1497
+ showsChapter: () => !mine && !cleared && published.out !== '',
1347
1498
  setHidden,
1348
1499
  isHidden: () => hidden,
1349
- isEdited: () => mine || input.value !== published.goal,
1500
+ isEdited: () => mine || cleared || input.value !== published.goal,
1350
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 }),
1351
1514
  /**
1352
1515
  * This cell's answers for an export, in the format's own spelling (§6).
1353
1516
  *
@@ -1358,6 +1521,10 @@ function mountQuery(cell, options, bus, { above = [], below = [], prediction = n
1358
1521
  * has actually produced.
1359
1522
  */
1360
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;
1361
1528
  if (!mine) return undefined;
1362
1529
  // Stopping and finishing look the same from outside — both leave no open
1363
1530
  // query — so `exhausted` is the only thing that distinguishes them.
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.1';
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';