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 +86 -0
- package/README.md +61 -16
- package/bin/prolog-notebook.mjs +120 -48
- package/package.json +1 -1
- package/src/build-info.json +2 -2
- package/src/build.js +15 -0
- package/src/export.js +23 -0
- package/src/notebook.js +208 -13
- package/src/render.js +14 -7
- package/src/serve.js +61 -0
- package/src/upgrade.js +1 -1
- package/src/version.js +1 -1
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
|
-
>
|
|
6
|
-
>
|
|
7
|
-
>
|
|
5
|
+
> **v0.5 — usable, 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
|
-
|
|
43
|
+
A whole chapter, from nothing, in four commands:
|
|
44
|
+
|
|
45
|
+
````sh
|
|
44
46
|
npm i -g prolog-notebook
|
|
45
|
-
|
|
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
|
-
|
|
49
|
-
|
|
50
|
-
|
|
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
|
|
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
|
-
|
|
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
|
|
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**: `
|
|
272
|
-
in a browser, `build` writes a page you can host or send
|
|
273
|
-
|
|
274
|
-
-
|
|
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
|
-
-
|
|
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
|
package/bin/prolog-notebook.mjs
CHANGED
|
@@ -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
|
|
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>
|
|
44
|
-
prolog-notebook build <file.prolog.md>
|
|
45
|
-
prolog-notebook
|
|
46
|
-
prolog-notebook
|
|
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
|
-
|
|
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('
|
|
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
|
-
|
|
224
|
-
|
|
225
|
-
|
|
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: `
|
|
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
|
-
|
|
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(
|
|
423
|
+
process.stderr.write(`${options.port} was already answering — using ${server.port} instead.\n`);
|
|
342
424
|
}
|
|
343
|
-
process.stderr.write(
|
|
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
package/src/build-info.json
CHANGED
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
|
|
342
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
1298
|
-
|
|
1299
|
-
|
|
1300
|
-
|
|
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
|
-
|
|
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
|
|
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: `
|
|
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.
|
|
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';
|