prolog-notebook 0.4.3 → 0.5.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +72 -0
- package/README.md +24 -4
- package/bin/prolog-notebook.mjs +119 -26
- package/package.json +1 -1
- package/src/build-info.json +2 -2
- package/src/build.js +155 -0
- package/src/notebook.js +57 -4
- package/src/page.js +2 -17
- package/src/render.js +14 -7
- package/src/serve.js +141 -0
- package/src/version.js +1 -1
package/CHANGELOG.md
CHANGED
|
@@ -1,5 +1,77 @@
|
|
|
1
1
|
# Changelog
|
|
2
2
|
|
|
3
|
+
## [0.5.1] — 2026-08-30
|
|
4
|
+
|
|
5
|
+
Everything here came from one field report: a chapter written with the tool rather than a test
|
|
6
|
+
written against it.
|
|
7
|
+
|
|
8
|
+
### Fixed
|
|
9
|
+
|
|
10
|
+
- **`view` bound quietly behind a squatter on the same port.** A stale
|
|
11
|
+
`python3 -m http.server --bind ::` holds `*:8777` on IPv6; binding `127.0.0.1:8777` on IPv4
|
|
12
|
+
does not collide with it, so `EADDRINUSE` never fired and the auto-bump never ran. `localhost`
|
|
13
|
+
then resolves to `::1` first and the reader gets somebody else's directory listing where a
|
|
14
|
+
chapter should be. The port is now connect-tested on **both stacks** before binding, because
|
|
15
|
+
*can I bind* and *will the reader reach me* are different questions.
|
|
16
|
+
|
|
17
|
+
- **`view` printed its URL on stderr**, where a wrapper never saw it — which is how somebody
|
|
18
|
+
came to type `localhost` by hand. It is the command's output and is on stdout now; `view`
|
|
19
|
+
writes no notebook and no data there, so nothing can be corrupted by it.
|
|
20
|
+
|
|
21
|
+
- **`view` and `build` never looked for an update.** The offer went into the `run` path first
|
|
22
|
+
and stayed there. Every command that does real work now goes through one door, always before
|
|
23
|
+
the work — the only point at which the answer can change the outcome.
|
|
24
|
+
|
|
25
|
+
- **A prediction released the hold on blur, not on typing.** A reader who typed their prediction
|
|
26
|
+
and looked up saw nothing happen, and the link between *I wrote something* and *the answers
|
|
27
|
+
appeared* was broken by a pause with no cause. Debounced `input` at 1.2 s now, with `change`
|
|
28
|
+
kept so leaving the box is still immediate.
|
|
29
|
+
|
|
30
|
+
- **The console was not clean, and this audience opens the console.** A `favicon.ico` 404 on
|
|
31
|
+
every load — now a `data:` URI carrying the notebook's own `?-` prompt — and an issues-panel
|
|
32
|
+
warning for every form field on the page, all of which were anonymous. Every field has an id:
|
|
33
|
+
`src-<cell>`, `goal-<cell>`, `predict-<n>`.
|
|
34
|
+
|
|
35
|
+
- **A missing browser opener took the whole command down.** `spawn` reports that
|
|
36
|
+
asynchronously, so the `try/catch` around it caught nothing and an unhandled `'error'` event
|
|
37
|
+
killed `view` after the server had started. `start` on Windows is a shell builtin and was
|
|
38
|
+
never going to work spawned by name; it goes through `cmd` now.
|
|
39
|
+
|
|
40
|
+
## [0.5.0] — 2026-08-30
|
|
41
|
+
|
|
42
|
+
**The CLI can show a notebook.** Until now the only way to see one running was to clone this
|
|
43
|
+
repository, start a dev server, symlink your file into `notebooks/` and pass `?src=`. A
|
|
44
|
+
notebook tool whose notebooks could only be read from its own source tree was not finished.
|
|
45
|
+
|
|
46
|
+
### Added
|
|
47
|
+
|
|
48
|
+
- **`prolog-notebook view <file>`** — serves the chapter and opens it, cells live. It serves
|
|
49
|
+
exactly what `build` writes, from memory, streaming the runtime and the engine out of the
|
|
50
|
+
installed package: no temp directory, nothing to clean up, and no chance of the page you look
|
|
51
|
+
at differing from the page you would publish. `--port` (8777 by default, and it says so when
|
|
52
|
+
it has to take another), `--no-open`.
|
|
53
|
+
|
|
54
|
+
- **`prolog-notebook build <file> --out <dir>`** — a plain static directory you can host, zip
|
|
55
|
+
or send:
|
|
56
|
+
|
|
57
|
+
index.html app.js notebook.css lib/*.js swipl/swipl-bundle.js
|
|
58
|
+
|
|
59
|
+
No bundler. The runtime is already plain ES modules with relative imports, so the build is a
|
|
60
|
+
prerender and a copy.
|
|
61
|
+
|
|
62
|
+
**What the page does not contain** is the half worth stating. No markdown library — the prose
|
|
63
|
+
is HTML by the time it is written, which is why `page.js` and `notebook.js` are separate
|
|
64
|
+
files, and a test now enforces that neither the parser nor the renderer reaches a reader. And
|
|
65
|
+
no engine on the path to *reading*: the 6.2 MB sits in the directory untouched until somebody
|
|
66
|
+
presses Run.
|
|
67
|
+
|
|
68
|
+
### Changed
|
|
69
|
+
|
|
70
|
+
- `editsOf()` moved from `page.js` to `notebook.js`. A built page needs it and must not import
|
|
71
|
+
the renderer — and so 137 kB of markdown parser — to get it.
|
|
72
|
+
- `viewer/` is now what it always should have been: the shell for working on the renderer
|
|
73
|
+
itself. Everyone else uses `view`.
|
|
74
|
+
|
|
3
75
|
## [0.4.3] — 2026-08-30
|
|
4
76
|
|
|
5
77
|
### Added
|
package/README.md
CHANGED
|
@@ -40,6 +40,27 @@ So a query cell gives you the first solution, and then you step.
|
|
|
40
40
|
|
|
41
41
|
## Try it
|
|
42
42
|
|
|
43
|
+
```sh
|
|
44
|
+
npm i -g prolog-notebook
|
|
45
|
+
prolog-notebook view notebooks/ch04-cut.prolog.md
|
|
46
|
+
```
|
|
47
|
+
|
|
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.
|
|
51
|
+
|
|
52
|
+
To send it to somebody, or host it:
|
|
53
|
+
|
|
54
|
+
```sh
|
|
55
|
+
prolog-notebook build ch04-cut.prolog.md --out site/
|
|
56
|
+
```
|
|
57
|
+
|
|
58
|
+
A plain directory: prerendered HTML with the saved answers in it, the runtime beside it, and
|
|
59
|
+
the 6.2 MB engine that is fetched only when a reader presses Run. No bundler, no build step of
|
|
60
|
+
your own, nothing to configure.
|
|
61
|
+
|
|
62
|
+
### Or from a checkout
|
|
63
|
+
|
|
43
64
|
```sh
|
|
44
65
|
git clone https://github.com/jarecsni/prolog-notebook
|
|
45
66
|
cd prolog-notebook
|
|
@@ -59,7 +80,7 @@ are all in it, and there is no HTML anywhere. Point the viewer at any other note
|
|
|
59
80
|
| | |
|
|
60
81
|
|---|---|
|
|
61
82
|
| `notebooks/` | chapters. The product. |
|
|
62
|
-
| `viewer/` |
|
|
83
|
+
| `viewer/` | the development shell, for working on the renderer itself. `view` and `build` are what everyone else uses. |
|
|
63
84
|
|
|
64
85
|
The chapter also reads on the repo page, [as a file](notebooks/ch04-cut.prolog.md), with no
|
|
65
86
|
build step and no site: prose as prose, Prolog syntax-highlighted, the saved answers in place,
|
|
@@ -247,8 +268,8 @@ Working and tested:
|
|
|
247
268
|
- program cells and query cells with `Run` / `; next` / `all` / `stop`, per-cell reset, and a
|
|
248
269
|
page that says what the engine is holding
|
|
249
270
|
- `hold` and `rerun="auto"` — the author decides what a reader may see and when it refreshes
|
|
250
|
-
- **the CLI
|
|
251
|
-
|
|
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
|
|
252
273
|
- download your own copy of a chapter, answers and all
|
|
253
274
|
- 186 passing tests
|
|
254
275
|
|
|
@@ -256,7 +277,6 @@ Not built yet:
|
|
|
256
277
|
|
|
257
278
|
- `--check` — run a chapter in CI and fail the build when its answers have drifted. Needs a
|
|
258
279
|
timeout first: a test suite that can hang forever is not a test suite.
|
|
259
|
-
- `build` — a static page a reader can open, without a dev server
|
|
260
280
|
- custom elements (`<prolog-program>`, `<prolog-query>`) so notebooks drop into any static site
|
|
261
281
|
- a VS Code notebook controller — VS Code supplies the UI, this supplies the kernel, still no Python
|
|
262
282
|
- persistence, so a reader's edits survive a reload
|
package/bin/prolog-notebook.mjs
CHANGED
|
@@ -4,8 +4,8 @@
|
|
|
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 { readFileSync, writeFileSync } from 'node:fs';
|
|
8
|
-
import { basename } from 'node:path';
|
|
7
|
+
import { copyFileSync, mkdirSync, readFileSync, writeFileSync } from 'node:fs';
|
|
8
|
+
import { basename, dirname, join, resolve } from 'node:path';
|
|
9
9
|
import { parse, NotebookError } from '../src/format.js';
|
|
10
10
|
import { prologVersion } from '../src/engine.js';
|
|
11
11
|
import { buildLine, currentBuild } from '../src/build-info.js';
|
|
@@ -14,6 +14,8 @@ import { updateNotice } from '../src/update.js';
|
|
|
14
14
|
import { confirm, describeInstall, globalRoot, install, relaunch, upgradePlan } from '../src/upgrade.js';
|
|
15
15
|
import { exportSource } from '../src/export.js';
|
|
16
16
|
import { runNotebook, DEFAULT_LIMIT } from '../src/run.js';
|
|
17
|
+
import { buildFiles } from '../src/build.js';
|
|
18
|
+
import { openInBrowser, serve } from '../src/serve.js';
|
|
17
19
|
|
|
18
20
|
// The engine is imported WHERE IT IS USED, never at the top. src/node.js pulls in
|
|
19
21
|
// 5.9 MB of WebAssembly at module scope, so a static import here would mean that
|
|
@@ -37,6 +39,8 @@ const require = createRequire(import.meta.url);
|
|
|
37
39
|
|
|
38
40
|
const USAGE = `prolog-notebook — Jupyter-style notebooks for Prolog
|
|
39
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
|
|
40
44
|
prolog-notebook run <file.prolog.md>... run every cell, write the answers back
|
|
41
45
|
prolog-notebook upgrade fetch the latest version
|
|
42
46
|
|
|
@@ -44,6 +48,9 @@ Options
|
|
|
44
48
|
--limit <n> solutions to take from one query before stopping (default ${DEFAULT_LIMIT})
|
|
45
49
|
--stdout print the result instead of writing the file
|
|
46
50
|
--quiet report only failures
|
|
51
|
+
--out <dir> where build writes (default: <file>-site)
|
|
52
|
+
--port <n> what view listens on (default 8777)
|
|
53
|
+
--no-open view prints the URL instead of opening a browser
|
|
47
54
|
--version version, engine and copyright
|
|
48
55
|
--check-update ask npm whether a newer one exists, and say so either way
|
|
49
56
|
-h, --help this
|
|
@@ -133,6 +140,38 @@ function canAsk() {
|
|
|
133
140
|
return Boolean(process.stdin.isTTY && process.stderr.isTTY);
|
|
134
141
|
}
|
|
135
142
|
|
|
143
|
+
/**
|
|
144
|
+
* The offer, BEFORE the command does anything — which is the only place it can
|
|
145
|
+
* change the outcome. Afterwards the files are written, the server is up, and a
|
|
146
|
+
* newer version has nothing left to do.
|
|
147
|
+
*
|
|
148
|
+
* 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
|
+
* command somebody is most likely to leave running — was the one that never
|
|
151
|
+
* looked.
|
|
152
|
+
*
|
|
153
|
+
* It costs a network round trip once a day, not once a run: the rest of the day
|
|
154
|
+
* is a file read.
|
|
155
|
+
*
|
|
156
|
+
* @returns {Promise<number|null>} an exit code when the command has been handed
|
|
157
|
+
* to a newer version, null to carry on here.
|
|
158
|
+
*/
|
|
159
|
+
async function upgradeFirst({ quiet = false, asked = false } = {}) {
|
|
160
|
+
if (!canAsk() || (quiet && !asked)) return null;
|
|
161
|
+
const ahead = await updateNotice({ version: VERSION, force: asked })
|
|
162
|
+
.catch(() => ({ message: null, newer: null }));
|
|
163
|
+
if (ahead.message) process.stderr.write(`${ahead.message}\n`);
|
|
164
|
+
if (!ahead.newer || !(await confirm('Update and continue on the new version?'))) return null;
|
|
165
|
+
if ((await upgrade(ahead.newer)) !== 0) {
|
|
166
|
+
process.stderr.write('Carrying on with the version you have.\n');
|
|
167
|
+
return null;
|
|
168
|
+
}
|
|
169
|
+
process.stderr.write('Continuing on the new version.\n');
|
|
170
|
+
// The path has not changed — npm replaced what is behind it — so this is the
|
|
171
|
+
// same command, running the bytes that have just arrived.
|
|
172
|
+
return relaunch(process.argv);
|
|
173
|
+
}
|
|
174
|
+
|
|
136
175
|
async function offerUpgrade(newer) {
|
|
137
176
|
if (!canAsk()) {
|
|
138
177
|
// Nobody to ask, so say what to type instead. `prolog-notebook upgrade`
|
|
@@ -173,6 +212,7 @@ async function main(argv) {
|
|
|
173
212
|
}
|
|
174
213
|
|
|
175
214
|
const command = args.shift();
|
|
215
|
+
if (command === 'view' || command === 'build') return page(command, args);
|
|
176
216
|
if (command === 'upgrade') {
|
|
177
217
|
const { message, newer } = await updateNotice({ version: VERSION, force: true });
|
|
178
218
|
if (message) process.stderr.write(`${message}\n`);
|
|
@@ -211,30 +251,9 @@ async function main(argv) {
|
|
|
211
251
|
}
|
|
212
252
|
if (!options.quiet) process.stderr.write(`${RUNAWAY_WARNING}\n`);
|
|
213
253
|
|
|
214
|
-
|
|
215
|
-
|
|
216
|
-
|
|
217
|
-
// late to change them.
|
|
218
|
-
//
|
|
219
|
-
// It costs a network round trip once a day, not once a run: the rest of the day
|
|
220
|
-
// is a file read. Measured at 25-120 ms against npm, against a run that spends
|
|
221
|
-
// seconds in Prolog.
|
|
222
|
-
if (canAsk() && !options.quiet) {
|
|
223
|
-
const ahead = await updateNotice({ version: VERSION, force: asked })
|
|
224
|
-
.catch(() => ({ message: null, newer: null }));
|
|
225
|
-
if (ahead.message) process.stderr.write(`${ahead.message}\n`);
|
|
226
|
-
if (ahead.newer && await confirm('Update and continue on the new version?')) {
|
|
227
|
-
if ((await upgrade(ahead.newer)) === 0) {
|
|
228
|
-
process.stderr.write('Continuing on the new version.\n');
|
|
229
|
-
// The path has not changed — npm replaced what is behind it — so this is
|
|
230
|
-
// the same command, running the bytes that have just arrived.
|
|
231
|
-
return relaunch(process.argv);
|
|
232
|
-
}
|
|
233
|
-
process.stderr.write('Carrying on with the version you have.\n');
|
|
234
|
-
}
|
|
235
|
-
// Asked and answered: the check below has nothing left to say.
|
|
236
|
-
checked = true;
|
|
237
|
-
}
|
|
254
|
+
const jump = await upgradeFirst({ quiet: options.quiet, asked });
|
|
255
|
+
if (jump !== null) return jump;
|
|
256
|
+
checked = canAsk() && (!options.quiet || asked);
|
|
238
257
|
|
|
239
258
|
// STARTED NOW, READ AT THE END. The registry is somebody else's machine on
|
|
240
259
|
// somebody else's network, and none of that should stand between the reader
|
|
@@ -273,6 +292,80 @@ async function main(argv) {
|
|
|
273
292
|
return status;
|
|
274
293
|
}
|
|
275
294
|
|
|
295
|
+
/**
|
|
296
|
+
* `build` and `view`, which are the same page put in two different places.
|
|
297
|
+
*
|
|
298
|
+
* Neither runs a cell: a chapter's answers are already in the file, which is the
|
|
299
|
+
* whole reason a built page is readable before any engine arrives. Use `run` to
|
|
300
|
+
* put them there.
|
|
301
|
+
*/
|
|
302
|
+
async function page(command, args) {
|
|
303
|
+
const options = { out: null, port: 8777, open: true };
|
|
304
|
+
const files = [];
|
|
305
|
+
while (args.length) {
|
|
306
|
+
const arg = args.shift();
|
|
307
|
+
if (arg === '--out') options.out = args.shift();
|
|
308
|
+
else if (arg === '--port') {
|
|
309
|
+
options.port = Number(args.shift());
|
|
310
|
+
if (!Number.isInteger(options.port) || options.port < 0 || options.port > 65535) {
|
|
311
|
+
process.stderr.write('--port takes a port number\n');
|
|
312
|
+
return 2;
|
|
313
|
+
}
|
|
314
|
+
} else if (arg === '--no-open') options.open = false;
|
|
315
|
+
else if (arg.startsWith('-')) {
|
|
316
|
+
process.stderr.write(`unknown option "${arg}"\n`);
|
|
317
|
+
return 2;
|
|
318
|
+
} else files.push(arg);
|
|
319
|
+
}
|
|
320
|
+
if (files.length !== 1) {
|
|
321
|
+
process.stderr.write(`${command} takes exactly one notebook\n`);
|
|
322
|
+
return 2;
|
|
323
|
+
}
|
|
324
|
+
|
|
325
|
+
// The same offer the run path makes, and for the same reason: a server about to
|
|
326
|
+
// start, or a directory about to be written, is work that a newer version
|
|
327
|
+
// should be doing.
|
|
328
|
+
const jump = await upgradeFirst();
|
|
329
|
+
if (jump !== null) return jump;
|
|
330
|
+
|
|
331
|
+
const file = files[0];
|
|
332
|
+
let built;
|
|
333
|
+
try {
|
|
334
|
+
const source = readFileSync(file, 'utf8');
|
|
335
|
+
built = buildFiles(parse(source), source, { filename: basename(file) });
|
|
336
|
+
} catch (e) {
|
|
337
|
+
process.stderr.write(`${file}: ${e.message}\n`);
|
|
338
|
+
return 1;
|
|
339
|
+
}
|
|
340
|
+
|
|
341
|
+
if (command === 'build') {
|
|
342
|
+
const out = options.out ?? `${file.replace(/\.prolog\.md$/, '')}-site`;
|
|
343
|
+
for (const [name, entry] of built) {
|
|
344
|
+
const target = join(out, name);
|
|
345
|
+
mkdirSync(dirname(target), { recursive: true });
|
|
346
|
+
if (entry.text !== undefined) writeFileSync(target, entry.text);
|
|
347
|
+
else copyFileSync(entry.copy, target);
|
|
348
|
+
}
|
|
349
|
+
process.stderr.write(`${out}: ${built.size} files\n`);
|
|
350
|
+
process.stderr.write(`Open ${join(out, 'index.html')} over HTTP, or host the directory.\n`);
|
|
351
|
+
return 0;
|
|
352
|
+
}
|
|
353
|
+
|
|
354
|
+
const server = await serve(built, { port: options.port });
|
|
355
|
+
// THE URL IS THIS COMMAND'S OUTPUT. `view` writes no notebook and no data to
|
|
356
|
+
// stdout, so there is nothing for it to corrupt — and a URL on stderr is a URL
|
|
357
|
+
// a wrapper does not see, which is how somebody came to type localhost by hand
|
|
358
|
+
// and land on another server entirely (869ernmvh).
|
|
359
|
+
process.stdout.write(`${server.url}\n`);
|
|
360
|
+
if (server.port !== options.port) {
|
|
361
|
+
process.stderr.write(`${options.port} was already answering — using ${server.port} instead.\n`);
|
|
362
|
+
}
|
|
363
|
+
process.stderr.write(`${basename(file)} is at ${server.url} — Ctrl-C to stop.\n`);
|
|
364
|
+
if (options.open) openInBrowser(server.url);
|
|
365
|
+
// Deliberately never resolves: the server is the command.
|
|
366
|
+
return new Promise(() => {});
|
|
367
|
+
}
|
|
368
|
+
|
|
276
369
|
async function runFile(file, session, options) {
|
|
277
370
|
const name = basename(file);
|
|
278
371
|
let notebook;
|
package/package.json
CHANGED
package/src/build-info.json
CHANGED
package/src/build.js
ADDED
|
@@ -0,0 +1,155 @@
|
|
|
1
|
+
// A chapter to a page that stands on its own (869ermwfv).
|
|
2
|
+
//
|
|
3
|
+
// The output is a plain static directory: open it, host it, zip it and send it.
|
|
4
|
+
// Nothing here is a bundler — the runtime is already plain ES modules with
|
|
5
|
+
// relative imports, so the "build" is a prerender plus a copy.
|
|
6
|
+
//
|
|
7
|
+
// WHAT THE PAGE DOES NOT CONTAIN is the interesting half. No markdown library:
|
|
8
|
+
// the prose is HTML by the time this writes it, which is exactly why page.js and
|
|
9
|
+
// notebook.js were split — a prerendered chapter needs the wiring, not the
|
|
10
|
+
// parser. And no engine on the critical path: 6.2 MB of WebAssembly sits in the
|
|
11
|
+
// directory and is fetched the first time somebody presses Run, so the chapter is
|
|
12
|
+
// readable with none of it, which is the property this whole project exists for.
|
|
13
|
+
//
|
|
14
|
+
// NOTHING IS WRITTEN HERE. This returns a MAP of what the directory should
|
|
15
|
+
// contain — generated text, or a path to copy — so that `build` can write it,
|
|
16
|
+
// `view` can serve it, and a test can read it, without any of the three
|
|
17
|
+
// disagreeing about what a page is.
|
|
18
|
+
import { renderNotebook } from './render.js';
|
|
19
|
+
|
|
20
|
+
/** The runtime a page needs. Copied side by side, so their relative imports hold. */
|
|
21
|
+
export const RUNTIME = [
|
|
22
|
+
'notebook.js', 'browser.js', 'session.js', 'engine.js', 'worker.js',
|
|
23
|
+
'clauses.js', 'export.js', 'format.js', 'version.js',
|
|
24
|
+
];
|
|
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
|
+
|
|
40
|
+
/** The one engine file: the bundle carries its own data. */
|
|
41
|
+
export const ENGINE = 'swipl-bundle.js';
|
|
42
|
+
|
|
43
|
+
/**
|
|
44
|
+
* The page, as a map of file name to what belongs there.
|
|
45
|
+
*
|
|
46
|
+
* @param {{frontMatter: Map<string,string>, cells: object[]}} notebook parsed
|
|
47
|
+
* @param {string} source the notebook's own bytes, for the download
|
|
48
|
+
* @param {{filename?: string, src?: URL, engine?: URL}} [options]
|
|
49
|
+
* `src` is the directory holding the runtime modules and `engine` the
|
|
50
|
+
* directory holding swipl-wasm's bundle — arguments rather than constants so a
|
|
51
|
+
* test can point them anywhere and an installed package can find its own.
|
|
52
|
+
* @returns {Map<string, {text: string}|{copy: URL}>}
|
|
53
|
+
*/
|
|
54
|
+
export function buildFiles(notebook, source, options = {}) {
|
|
55
|
+
const {
|
|
56
|
+
filename = 'notebook.prolog.md',
|
|
57
|
+
src = new URL('./', import.meta.url),
|
|
58
|
+
engine = new URL('../node_modules/swipl-wasm/dist/swipl/', import.meta.url),
|
|
59
|
+
} = options;
|
|
60
|
+
|
|
61
|
+
const files = new Map();
|
|
62
|
+
files.set('index.html', { text: page(notebook) });
|
|
63
|
+
files.set('app.js', { text: app(source, filename) });
|
|
64
|
+
files.set('notebook.css', { copy: new URL('notebook.css', src) });
|
|
65
|
+
for (const module of RUNTIME) files.set(`lib/${module}`, { copy: new URL(module, src) });
|
|
66
|
+
files.set(`swipl/${ENGINE}`, { copy: new URL(ENGINE, engine) });
|
|
67
|
+
return files;
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
/**
|
|
71
|
+
* The chapter's own title, from its first H1 (format §2).
|
|
72
|
+
*
|
|
73
|
+
* Written into the HTML at build time rather than set by script on load: it is
|
|
74
|
+
* the browser tab, the bookmark and the thing a link preview shows, and none of
|
|
75
|
+
* those wait for JavaScript.
|
|
76
|
+
*/
|
|
77
|
+
function titleOf(notebook) {
|
|
78
|
+
for (const cell of notebook.cells) {
|
|
79
|
+
if (cell.kind !== 'markdown') continue;
|
|
80
|
+
const heading = /^#\s+(.+)$/m.exec(cell.source);
|
|
81
|
+
if (heading) return heading[1].trim();
|
|
82
|
+
}
|
|
83
|
+
return 'A Prolog notebook';
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
function escapeHtml(text) {
|
|
87
|
+
return String(text).replace(/[&<>"]/g, (c) => ({ '&': '&', '<': '<', '>': '>', '"': '"' }[c]));
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
function page(notebook) {
|
|
91
|
+
return `<!doctype html>
|
|
92
|
+
<html lang="en">
|
|
93
|
+
<head>
|
|
94
|
+
<meta charset="utf-8">
|
|
95
|
+
<meta name="viewport" content="width=device-width, initial-scale=1">
|
|
96
|
+
<title>${escapeHtml(titleOf(notebook))}</title>
|
|
97
|
+
<link rel="icon" href="data:image/svg+xml,${FAVICON}">
|
|
98
|
+
<link rel="stylesheet" href="notebook.css">
|
|
99
|
+
</head>
|
|
100
|
+
<body>
|
|
101
|
+
<main>
|
|
102
|
+
${renderNotebook(notebook)}
|
|
103
|
+
<!--
|
|
104
|
+
THE CHAPTER ABOVE IS ALREADY READABLE. Everything below is the runtime that
|
|
105
|
+
makes it runnable, and none of it is needed to read a word.
|
|
106
|
+
-->
|
|
107
|
+
<div id="boot-warning">
|
|
108
|
+
<strong>This notebook is not running.</strong>
|
|
109
|
+
The page loaded but its JavaScript did not. The usual cause is opening
|
|
110
|
+
<code>index.html</code> straight from disk — browsers block ES modules over
|
|
111
|
+
<code>file://</code>. Serve it over HTTP instead, or run
|
|
112
|
+
<code>prolog-notebook view</code> on the notebook itself.
|
|
113
|
+
The chapter is readable either way; only the buttons need this.
|
|
114
|
+
</div>
|
|
115
|
+
</main>
|
|
116
|
+
<script type="module" src="app.js"></script>
|
|
117
|
+
</body>
|
|
118
|
+
</html>
|
|
119
|
+
`;
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
/**
|
|
123
|
+
* The wiring, and the source it hands back when a reader asks for a copy.
|
|
124
|
+
*
|
|
125
|
+
* The notebook's own bytes are embedded because "the chapter as published" has to
|
|
126
|
+
* mean the bytes the author wrote, not a re-serialisation of the model — a
|
|
127
|
+
* hand-written chapter would otherwise come back reformatted.
|
|
128
|
+
*/
|
|
129
|
+
function app(source, filename) {
|
|
130
|
+
return `// Generated by prolog-notebook build. The chapter is already in index.html;
|
|
131
|
+
// this only wires it up.
|
|
132
|
+
import { editsOf, mount, offerDownload } from './lib/notebook.js';
|
|
133
|
+
import { parse } from './lib/format.js';
|
|
134
|
+
import { exportSource } from './lib/export.js';
|
|
135
|
+
|
|
136
|
+
const SOURCE = ${JSON.stringify(source)};
|
|
137
|
+
const FILENAME = ${JSON.stringify(filename)};
|
|
138
|
+
|
|
139
|
+
const root = document.querySelector('main');
|
|
140
|
+
// The engine lives beside this file rather than in a node_modules the browser
|
|
141
|
+
// cannot see, so its location is passed rather than guessed.
|
|
142
|
+
const cells = mount(root, {
|
|
143
|
+
swiplUrl: new URL('./swipl/${ENGINE}', import.meta.url).href,
|
|
144
|
+
});
|
|
145
|
+
|
|
146
|
+
const notebook = parse(SOURCE);
|
|
147
|
+
offerDownload(root, {
|
|
148
|
+
produce: () => ({ filename: FILENAME, text: exportSource(notebook, editsOf(cells)) }),
|
|
149
|
+
published: () => ({ filename: FILENAME, text: SOURCE }),
|
|
150
|
+
isEdited: () => cells.programs.some((p) => p.isEdited())
|
|
151
|
+
|| cells.queries.some((q) => q.isEdited()),
|
|
152
|
+
on: cells.on,
|
|
153
|
+
});
|
|
154
|
+
`;
|
|
155
|
+
}
|
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
|
}
|
|
@@ -1294,11 +1302,31 @@ function mountQuery(cell, options, bus, { above = [], below = [], prediction = n
|
|
|
1294
1302
|
|
|
1295
1303
|
if (held) {
|
|
1296
1304
|
hidden = true;
|
|
1297
|
-
|
|
1298
|
-
|
|
1299
|
-
|
|
1300
|
-
|
|
1305
|
+
/**
|
|
1306
|
+
* AS THEY WRITE, not when they leave the box.
|
|
1307
|
+
*
|
|
1308
|
+
* This listened on `change` alone, which fires on blur — so a reader who
|
|
1309
|
+
* typed their prediction and looked up saw nothing happen, and the link
|
|
1310
|
+
* between "I wrote something" and "the answers appeared" was broken by a
|
|
1311
|
+
* pause with no cause (869ernmzh). The original reasoning was about the
|
|
1312
|
+
* FIRST KEYSTROKE and it over-corrected: they have committed as soon as they
|
|
1313
|
+
* have written something.
|
|
1314
|
+
*
|
|
1315
|
+
* Debounced, so one character does not reveal the chapter and a reader who is
|
|
1316
|
+
* still typing is not interrupted. `change` stays as well, so leaving the box
|
|
1317
|
+
* is immediate. An empty box is still not a prediction.
|
|
1318
|
+
*/
|
|
1319
|
+
const release = () => {
|
|
1301
1320
|
if (held && prediction.value.trim() !== '') setHidden(false);
|
|
1321
|
+
};
|
|
1322
|
+
let pause = null;
|
|
1323
|
+
prediction?.addEventListener('input', () => {
|
|
1324
|
+
clearTimeout(pause);
|
|
1325
|
+
pause = setTimeout(release, PREDICTION_PAUSE);
|
|
1326
|
+
});
|
|
1327
|
+
prediction?.addEventListener('change', () => {
|
|
1328
|
+
clearTimeout(pause);
|
|
1329
|
+
release();
|
|
1302
1330
|
});
|
|
1303
1331
|
}
|
|
1304
1332
|
|
|
@@ -1338,6 +1366,31 @@ function mountQuery(cell, options, bus, { above = [], below = [], prediction = n
|
|
|
1338
1366
|
};
|
|
1339
1367
|
}
|
|
1340
1368
|
|
|
1369
|
+
/**
|
|
1370
|
+
* What the cells now say, keyed by id, for src/export.js to fold into the model.
|
|
1371
|
+
*
|
|
1372
|
+
* HERE RATHER THAN IN page.js, because a built page has its HTML already and must
|
|
1373
|
+
* not import the renderer to get this — page.js pulls in markdown-it, and 137 KB
|
|
1374
|
+
* of markdown parser to answer "what does this textarea say" is the opposite of
|
|
1375
|
+
* why these two files are separate.
|
|
1376
|
+
*
|
|
1377
|
+
* @param {{programs: object[], queries: object[]}} cells what mount() returned
|
|
1378
|
+
* @returns {Map<string, object>}
|
|
1379
|
+
*/
|
|
1380
|
+
export function editsOf(cells) {
|
|
1381
|
+
const map = new Map();
|
|
1382
|
+
for (const program of cells.programs) {
|
|
1383
|
+
map.set(program.name, { source: program.text() });
|
|
1384
|
+
}
|
|
1385
|
+
for (const query of cells.queries) {
|
|
1386
|
+
const output = query.output();
|
|
1387
|
+
map.set(query.id, output === undefined
|
|
1388
|
+
? { goal: query.goal() }
|
|
1389
|
+
: { goal: query.goal(), output });
|
|
1390
|
+
}
|
|
1391
|
+
return map;
|
|
1392
|
+
}
|
|
1393
|
+
|
|
1341
1394
|
function autosizeNow(ta) {
|
|
1342
1395
|
ta.style.height = 'auto';
|
|
1343
1396
|
ta.style.height = `${ta.scrollHeight}px`;
|
package/src/page.js
CHANGED
|
@@ -6,7 +6,7 @@
|
|
|
6
6
|
// a prerendered chapter from downloading 137 KB of markdown-it to render nothing.
|
|
7
7
|
import { parse } from './format.js';
|
|
8
8
|
import { renderNotebook } from './render.js';
|
|
9
|
-
import { mount, offerDownload } from './notebook.js';
|
|
9
|
+
import { editsOf, mount, offerDownload } from './notebook.js';
|
|
10
10
|
import { exportSource, filenameFor } from './export.js';
|
|
11
11
|
|
|
12
12
|
/**
|
|
@@ -33,7 +33,7 @@ export function renderInto(text, root, options = {}) {
|
|
|
33
33
|
// but a markdown cell has been rendered to HTML and cannot be read back out of
|
|
34
34
|
// it — a chapter exported from the DOM alone would lose its writing.
|
|
35
35
|
offerDownload(root, {
|
|
36
|
-
produce: () => ({ filename, text: exportSource(notebook,
|
|
36
|
+
produce: () => ({ filename, text: exportSource(notebook, editsOf(cells)) }),
|
|
37
37
|
// THE BYTES THE PAGE WAS GIVEN, not the model written out again. A
|
|
38
38
|
// re-serialisation would be canonical form, which is not necessarily the
|
|
39
39
|
// author's file: a hand-written chapter with no ids, or attributes in
|
|
@@ -49,21 +49,6 @@ export function renderInto(text, root, options = {}) {
|
|
|
49
49
|
return notebook;
|
|
50
50
|
}
|
|
51
51
|
|
|
52
|
-
/** What the cells now say, keyed by id, for src/export.js to fold into the model. */
|
|
53
|
-
function edits(cells) {
|
|
54
|
-
const map = new Map();
|
|
55
|
-
for (const program of cells.programs) {
|
|
56
|
-
map.set(program.name, { source: program.text() });
|
|
57
|
-
}
|
|
58
|
-
for (const query of cells.queries) {
|
|
59
|
-
const output = query.output();
|
|
60
|
-
map.set(query.id, output === undefined
|
|
61
|
-
? { goal: query.goal() }
|
|
62
|
-
: { goal: query.goal(), output });
|
|
63
|
-
}
|
|
64
|
-
return map;
|
|
65
|
-
}
|
|
66
|
-
|
|
67
52
|
/**
|
|
68
53
|
* Fetch a `.prolog.md` and render it.
|
|
69
54
|
*
|
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
ADDED
|
@@ -0,0 +1,141 @@
|
|
|
1
|
+
// Showing the chapter you are writing (869ermwjv).
|
|
2
|
+
//
|
|
3
|
+
// It serves exactly what `build` writes — the same map — so the page somebody
|
|
4
|
+
// looks at and the page they would publish cannot drift apart. Generated files
|
|
5
|
+
// come from memory and copied ones are streamed from wherever they live, so
|
|
6
|
+
// nothing is written to disk and there is nothing to clean up.
|
|
7
|
+
//
|
|
8
|
+
// A server of about forty lines rather than a dependency: it answers GET for a
|
|
9
|
+
// fixed set of paths that this process generated, and 404s everything else. It
|
|
10
|
+
// is not a static file server and must not become one.
|
|
11
|
+
import { spawn } from 'node:child_process';
|
|
12
|
+
import { createReadStream } from 'node:fs';
|
|
13
|
+
import { createServer } from 'node:http';
|
|
14
|
+
import { connect } from 'node:net';
|
|
15
|
+
|
|
16
|
+
const TYPES = {
|
|
17
|
+
'.html': 'text/html; charset=utf-8',
|
|
18
|
+
'.js': 'text/javascript; charset=utf-8',
|
|
19
|
+
'.css': 'text/css; charset=utf-8',
|
|
20
|
+
'.json': 'application/json; charset=utf-8',
|
|
21
|
+
'.wasm': 'application/wasm',
|
|
22
|
+
'.data': 'application/octet-stream',
|
|
23
|
+
'.md': 'text/markdown; charset=utf-8',
|
|
24
|
+
};
|
|
25
|
+
|
|
26
|
+
export function contentType(name) {
|
|
27
|
+
const dot = name.lastIndexOf('.');
|
|
28
|
+
return TYPES[name.slice(dot)] ?? 'application/octet-stream';
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
/**
|
|
32
|
+
* Serve a built page.
|
|
33
|
+
*
|
|
34
|
+
* @param {Map<string, {text: string}|{copy: URL}>} files what build produced
|
|
35
|
+
* @param {{port?: number, host?: string}} [options]
|
|
36
|
+
* @returns {Promise<{url: string, port: number, close: () => Promise<void>}>}
|
|
37
|
+
*/
|
|
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;
|
|
48
|
+
const server = createServer((request, response) => {
|
|
49
|
+
// Only GET, and only the names this process generated: the path never
|
|
50
|
+
// reaches the filesystem, so there is nothing for a `..` to escape into.
|
|
51
|
+
const name = decodeURIComponent(new URL(request.url, 'http://x').pathname).replace(/^\//, '');
|
|
52
|
+
const entry = files.get(name === '' ? 'index.html' : name);
|
|
53
|
+
if (request.method !== 'GET' || !entry) {
|
|
54
|
+
response.writeHead(404, { 'content-type': 'text/plain' }).end('not found\n');
|
|
55
|
+
return;
|
|
56
|
+
}
|
|
57
|
+
response.writeHead(200, {
|
|
58
|
+
'content-type': contentType(name || 'index.html'),
|
|
59
|
+
// A page being written is a page that changes under the reader.
|
|
60
|
+
'cache-control': 'no-store',
|
|
61
|
+
});
|
|
62
|
+
if (entry.text !== undefined) {
|
|
63
|
+
response.end(entry.text);
|
|
64
|
+
return;
|
|
65
|
+
}
|
|
66
|
+
createReadStream(entry.copy).on('error', () => response.end()).pipe(response);
|
|
67
|
+
});
|
|
68
|
+
|
|
69
|
+
const listening = await listen(server, port, host);
|
|
70
|
+
return {
|
|
71
|
+
url: `http://${host}:${listening}/`,
|
|
72
|
+
port: listening,
|
|
73
|
+
close: () => new Promise((resolve) => server.close(resolve)),
|
|
74
|
+
};
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
/**
|
|
78
|
+
* Take the port asked for, or any port at all.
|
|
79
|
+
*
|
|
80
|
+
* A tool that dies because something else is on 8777 is a tool that makes the
|
|
81
|
+
* reader find out what. It says which port it took instead.
|
|
82
|
+
*/
|
|
83
|
+
function listen(server, port, host) {
|
|
84
|
+
return new Promise((resolve, reject) => {
|
|
85
|
+
server.once('error', (error) => {
|
|
86
|
+
if (error.code !== 'EADDRINUSE') return reject(error);
|
|
87
|
+
server.listen(0, host, () => resolve(server.address().port));
|
|
88
|
+
});
|
|
89
|
+
server.listen(port, host, () => resolve(server.address().port));
|
|
90
|
+
});
|
|
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/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.5.1';
|
|
14
14
|
|
|
15
15
|
/** The two facts a licence notice is actually made of. */
|
|
16
16
|
export const YEAR = '2026';
|