prolog-notebook 0.4.2 → 0.5.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 +65 -0
- package/README.md +51 -4
- package/bin/prolog-notebook.mjs +189 -10
- package/package.json +1 -1
- package/src/build-info.json +2 -2
- package/src/build.js +140 -0
- package/src/notebook.js +25 -0
- package/src/page.js +2 -17
- package/src/serve.js +80 -0
- package/src/update.js +22 -7
- package/src/upgrade.js +128 -0
- package/src/version.js +1 -1
package/CHANGELOG.md
CHANGED
|
@@ -1,5 +1,70 @@
|
|
|
1
1
|
# Changelog
|
|
2
2
|
|
|
3
|
+
## [0.5.0] — 2026-08-30
|
|
4
|
+
|
|
5
|
+
**The CLI can show a notebook.** Until now the only way to see one running was to clone this
|
|
6
|
+
repository, start a dev server, symlink your file into `notebooks/` and pass `?src=`. A
|
|
7
|
+
notebook tool whose notebooks could only be read from its own source tree was not finished.
|
|
8
|
+
|
|
9
|
+
### Added
|
|
10
|
+
|
|
11
|
+
- **`prolog-notebook view <file>`** — serves the chapter and opens it, cells live. It serves
|
|
12
|
+
exactly what `build` writes, from memory, streaming the runtime and the engine out of the
|
|
13
|
+
installed package: no temp directory, nothing to clean up, and no chance of the page you look
|
|
14
|
+
at differing from the page you would publish. `--port` (8777 by default, and it says so when
|
|
15
|
+
it has to take another), `--no-open`.
|
|
16
|
+
|
|
17
|
+
- **`prolog-notebook build <file> --out <dir>`** — a plain static directory you can host, zip
|
|
18
|
+
or send:
|
|
19
|
+
|
|
20
|
+
index.html app.js notebook.css lib/*.js swipl/swipl-bundle.js
|
|
21
|
+
|
|
22
|
+
No bundler. The runtime is already plain ES modules with relative imports, so the build is a
|
|
23
|
+
prerender and a copy.
|
|
24
|
+
|
|
25
|
+
**What the page does not contain** is the half worth stating. No markdown library — the prose
|
|
26
|
+
is HTML by the time it is written, which is why `page.js` and `notebook.js` are separate
|
|
27
|
+
files, and a test now enforces that neither the parser nor the renderer reaches a reader. And
|
|
28
|
+
no engine on the path to *reading*: the 6.2 MB sits in the directory untouched until somebody
|
|
29
|
+
presses Run.
|
|
30
|
+
|
|
31
|
+
### Changed
|
|
32
|
+
|
|
33
|
+
- `editsOf()` moved from `page.js` to `notebook.js`. A built page needs it and must not import
|
|
34
|
+
the renderer — and so 137 kB of markdown parser — to get it.
|
|
35
|
+
- `viewer/` is now what it always should have been: the shell for working on the renderer
|
|
36
|
+
itself. Everyone else uses `view`.
|
|
37
|
+
|
|
38
|
+
## [0.4.3] — 2026-08-30
|
|
39
|
+
|
|
40
|
+
### Added
|
|
41
|
+
|
|
42
|
+
- **It offers to update itself, and then carries on** — one command, no re-run:
|
|
43
|
+
|
|
44
|
+
$ prolog-notebook run ch04.prolog.md
|
|
45
|
+
You have Prolog Notebook 0.4.2. The latest is 0.4.3.
|
|
46
|
+
Update and continue on the new version? [Y/n] y
|
|
47
|
+
Updating with npm i -g prolog-notebook@0.4.3
|
|
48
|
+
You now have Prolog Notebook 0.4.3.
|
|
49
|
+
Continuing on the new version.
|
|
50
|
+
✓ p-family
|
|
51
|
+
✓ q-is-son — 6 solutions
|
|
52
|
+
|
|
53
|
+
The offer comes **before** the work, because that is the only point at which the answer can
|
|
54
|
+
change anything. It costs a network round trip once a day rather than once a run, since the
|
|
55
|
+
answer is cached. Continuing is a child process on the same path — npm replaces the contents
|
|
56
|
+
of the package directory, so the command the reader typed now holds the new version — with
|
|
57
|
+
stdio inherited, the exit code proxied, and an environment marker that stops the new process
|
|
58
|
+
checking again.
|
|
59
|
+
|
|
60
|
+
- **`prolog-notebook upgrade`**, for when you already know. It replaces this copy only where
|
|
61
|
+
it can prove how it was installed: a global `npm i -g` it will do, a dependency of somebody's
|
|
62
|
+
project it will not touch, and a source checkout is git's business. It installs the exact
|
|
63
|
+
version you were told about rather than `@latest`, which can move in between.
|
|
64
|
+
|
|
65
|
+
Both only where there is somebody to ask — stdin and stderr must be terminals. In a pipe, a
|
|
66
|
+
script or CI the notice comes after the work, with the command to type.
|
|
67
|
+
|
|
3
68
|
## [0.4.2] — 2026-08-30
|
|
4
69
|
|
|
5
70
|
### Fixed
|
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,
|
|
@@ -93,6 +114,10 @@ chapter changes nothing.
|
|
|
93
114
|
| `--version` | the tool's version, **the SWI-Prolog version it will run your chapters with**, and the copyright |
|
|
94
115
|
| `--check-update` | ask npm whether a newer one exists, and say so either way |
|
|
95
116
|
|
|
117
|
+
```sh
|
|
118
|
+
prolog-notebook upgrade # fetch the latest
|
|
119
|
+
```
|
|
120
|
+
|
|
96
121
|
Two things it will not do. A query stopped at the limit is written **without** a terminator,
|
|
97
122
|
which is the format's way of saying the search was never exhausted — `false.` there would be a
|
|
98
123
|
forgery. And if a program cell fails to load, nothing is written at all: every answer below it
|
|
@@ -105,6 +130,29 @@ reach is reported once a day rather than on every command — silence there woul
|
|
|
105
130
|
indistinguishable from *you are up to date*. Ask outright with `--check-update` and it answers
|
|
106
131
|
either way.
|
|
107
132
|
|
|
133
|
+
When it finds something newer **and you are at a terminal**, it offers to fetch it *before*
|
|
134
|
+
doing the work — and if you say yes it upgrades, then runs your command on the new version.
|
|
135
|
+
One command, no re-run:
|
|
136
|
+
|
|
137
|
+
```
|
|
138
|
+
$ prolog-notebook run ch04.prolog.md
|
|
139
|
+
You have Prolog Notebook 0.4.0. The latest is 0.4.2.
|
|
140
|
+
Update and continue on the new version? [Y/n] y
|
|
141
|
+
Updating with npm i -g prolog-notebook@0.4.2
|
|
142
|
+
You now have Prolog Notebook 0.4.2.
|
|
143
|
+
Continuing on the new version.
|
|
144
|
+
✓ p-family
|
|
145
|
+
✓ q-is-son — 6 solutions
|
|
146
|
+
```
|
|
147
|
+
|
|
148
|
+
That costs a network round trip once a day rather than once a run, because the answer is
|
|
149
|
+
cached. Down a pipe or in a script there is nobody to ask, so it prints `Update with:
|
|
150
|
+
prolog-notebook upgrade` after the work instead — a question nobody can answer is a hang.
|
|
151
|
+
|
|
152
|
+
`upgrade` replaces this copy only when it can prove how it was installed. A global `npm i -g`
|
|
153
|
+
it will do; a dependency of somebody's project it will not touch, and a source checkout is
|
|
154
|
+
git's business. Guessing wrong there breaks a project while trying to help.
|
|
155
|
+
|
|
108
156
|
It has no defence against a non-terminating goal yet — the engine runs in this process, so
|
|
109
157
|
`loop :- loop.` hangs the command. Say the word `--limit` all you like; a runaway *consult* is
|
|
110
158
|
not a solution count. Fixing it properly means a worker thread, and it is the prerequisite for
|
|
@@ -220,8 +268,8 @@ Working and tested:
|
|
|
220
268
|
- program cells and query cells with `Run` / `; next` / `all` / `stop`, per-cell reset, and a
|
|
221
269
|
page that says what the engine is holding
|
|
222
270
|
- `hold` and `rerun="auto"` — the author decides what a reader may see and when it refreshes
|
|
223
|
-
- **the CLI
|
|
224
|
-
|
|
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
|
|
225
273
|
- download your own copy of a chapter, answers and all
|
|
226
274
|
- 186 passing tests
|
|
227
275
|
|
|
@@ -229,7 +277,6 @@ Not built yet:
|
|
|
229
277
|
|
|
230
278
|
- `--check` — run a chapter in CI and fail the build when its answers have drifted. Needs a
|
|
231
279
|
timeout first: a test suite that can hang forever is not a test suite.
|
|
232
|
-
- `build` — a static page a reader can open, without a dev server
|
|
233
280
|
- custom elements (`<prolog-program>`, `<prolog-query>`) so notebooks drop into any static site
|
|
234
281
|
- a VS Code notebook controller — VS Code supplies the UI, this supplies the kernel, still no Python
|
|
235
282
|
- persistence, so a reader's edits survive a reload
|
package/bin/prolog-notebook.mjs
CHANGED
|
@@ -4,15 +4,19 @@
|
|
|
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 {
|
|
8
|
-
import {
|
|
7
|
+
import { spawn } from 'node:child_process';
|
|
8
|
+
import { copyFileSync, mkdirSync, readFileSync, writeFileSync } from 'node:fs';
|
|
9
|
+
import { basename, dirname, join, resolve } from 'node:path';
|
|
9
10
|
import { parse, NotebookError } from '../src/format.js';
|
|
10
11
|
import { prologVersion } from '../src/engine.js';
|
|
11
12
|
import { buildLine, currentBuild } from '../src/build-info.js';
|
|
12
13
|
import { banner, VERSION } from '../src/version.js';
|
|
13
14
|
import { updateNotice } from '../src/update.js';
|
|
15
|
+
import { confirm, describeInstall, globalRoot, install, relaunch, upgradePlan } from '../src/upgrade.js';
|
|
14
16
|
import { exportSource } from '../src/export.js';
|
|
15
17
|
import { runNotebook, DEFAULT_LIMIT } from '../src/run.js';
|
|
18
|
+
import { buildFiles } from '../src/build.js';
|
|
19
|
+
import { serve } from '../src/serve.js';
|
|
16
20
|
|
|
17
21
|
// The engine is imported WHERE IT IS USED, never at the top. src/node.js pulls in
|
|
18
22
|
// 5.9 MB of WebAssembly at module scope, so a static import here would mean that
|
|
@@ -36,12 +40,18 @@ const require = createRequire(import.meta.url);
|
|
|
36
40
|
|
|
37
41
|
const USAGE = `prolog-notebook — Jupyter-style notebooks for Prolog
|
|
38
42
|
|
|
43
|
+
prolog-notebook view <file.prolog.md> read it in a browser, cells and all
|
|
44
|
+
prolog-notebook build <file.prolog.md> write a page you can host or send
|
|
39
45
|
prolog-notebook run <file.prolog.md>... run every cell, write the answers back
|
|
46
|
+
prolog-notebook upgrade fetch the latest version
|
|
40
47
|
|
|
41
48
|
Options
|
|
42
49
|
--limit <n> solutions to take from one query before stopping (default ${DEFAULT_LIMIT})
|
|
43
50
|
--stdout print the result instead of writing the file
|
|
44
51
|
--quiet report only failures
|
|
52
|
+
--out <dir> where build writes (default: <file>-site)
|
|
53
|
+
--port <n> what view listens on (default 8777)
|
|
54
|
+
--no-open view prints the URL instead of opening a browser
|
|
45
55
|
--version version, engine and copyright
|
|
46
56
|
--check-update ask npm whether a newer one exists, and say so either way
|
|
47
57
|
-h, --help this
|
|
@@ -91,6 +101,63 @@ async function version() {
|
|
|
91
101
|
return `${lines.join('\n')}\n\n`;
|
|
92
102
|
}
|
|
93
103
|
|
|
104
|
+
/**
|
|
105
|
+
* Fetch the latest, if this copy is one we know how to replace.
|
|
106
|
+
*
|
|
107
|
+
* @param {string} version what to install
|
|
108
|
+
* @returns {Promise<number>} an exit code
|
|
109
|
+
*/
|
|
110
|
+
async function upgrade(version) {
|
|
111
|
+
const packageRoot = new URL('..', import.meta.url).pathname;
|
|
112
|
+
const kind = describeInstall({ packageRoot, globalRoot: await globalRoot() });
|
|
113
|
+
const plan = upgradePlan(kind, version);
|
|
114
|
+
if (plan.say) {
|
|
115
|
+
process.stderr.write(`${plan.say}\n`);
|
|
116
|
+
return 1;
|
|
117
|
+
}
|
|
118
|
+
process.stderr.write(`Updating with ${NPM_LINE} ${plan.argv.join(' ')}\n`);
|
|
119
|
+
if (!(await install(plan.argv))) {
|
|
120
|
+
process.stderr.write('npm could not complete the update.\n');
|
|
121
|
+
return 1;
|
|
122
|
+
}
|
|
123
|
+
process.stderr.write(`You now have Prolog Notebook ${version}.\n`);
|
|
124
|
+
return 0;
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
/**
|
|
128
|
+
* Offer it, but only where a question is a question.
|
|
129
|
+
*
|
|
130
|
+
* A pipe, a script and CI are all places where waiting for an answer is a hang,
|
|
131
|
+
* so the notice is simply printed there. Both streams are checked because the
|
|
132
|
+
* question goes to stderr and the answer comes from stdin.
|
|
133
|
+
*/
|
|
134
|
+
/**
|
|
135
|
+
* Is there somebody at the other end?
|
|
136
|
+
*
|
|
137
|
+
* Both streams, because the question goes to stderr and the answer comes back on
|
|
138
|
+
* stdin. A pipe, a script and CI are all places where a question is a hang.
|
|
139
|
+
*/
|
|
140
|
+
function canAsk() {
|
|
141
|
+
return Boolean(process.stdin.isTTY && process.stderr.isTTY);
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
async function offerUpgrade(newer) {
|
|
145
|
+
if (!canAsk()) {
|
|
146
|
+
// Nobody to ask, so say what to type instead. `prolog-notebook upgrade`
|
|
147
|
+
// rather than the npm line: it knows how this copy was installed, and the
|
|
148
|
+
// npm line is wrong for a project dependency.
|
|
149
|
+
process.stderr.write('Update with: prolog-notebook upgrade\n');
|
|
150
|
+
return null;
|
|
151
|
+
}
|
|
152
|
+
if (!(await confirm('Update now?'))) {
|
|
153
|
+
process.stderr.write(' (run `prolog-notebook upgrade` whenever you like)\n');
|
|
154
|
+
return null;
|
|
155
|
+
}
|
|
156
|
+
return upgrade(newer);
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
const NPM_LINE = process.platform === 'win32' ? 'npm.cmd' : 'npm';
|
|
160
|
+
|
|
94
161
|
async function main(argv) {
|
|
95
162
|
const args = argv.slice(2);
|
|
96
163
|
if (!args.length || args.includes('-h') || args.includes('--help')) {
|
|
@@ -108,18 +175,26 @@ async function main(argv) {
|
|
|
108
175
|
// forces the check that would otherwise wait for the day to turn over.
|
|
109
176
|
const asked = args.includes('--check-update');
|
|
110
177
|
if (asked && args.filter((a) => !a.startsWith('-')).length === 0) {
|
|
111
|
-
const
|
|
112
|
-
process.stderr.write(`${
|
|
113
|
-
return 0;
|
|
178
|
+
const { message, newer } = await updateNotice({ version: VERSION, force: true });
|
|
179
|
+
if (message) process.stderr.write(`${message}\n`);
|
|
180
|
+
return newer ? (await offerUpgrade(newer)) ?? 0 : 0;
|
|
114
181
|
}
|
|
115
182
|
|
|
116
183
|
const command = args.shift();
|
|
184
|
+
if (command === 'view' || command === 'build') return page(command, args);
|
|
185
|
+
if (command === 'upgrade') {
|
|
186
|
+
const { message, newer } = await updateNotice({ version: VERSION, force: true });
|
|
187
|
+
if (message) process.stderr.write(`${message}\n`);
|
|
188
|
+
return newer ? upgrade(newer) : 0;
|
|
189
|
+
}
|
|
117
190
|
if (command !== 'run') {
|
|
118
191
|
process.stderr.write(`unknown command "${command}"\n\n${USAGE}`);
|
|
119
192
|
return 2;
|
|
120
193
|
}
|
|
121
194
|
|
|
122
195
|
const options = { limit: DEFAULT_LIMIT, stdout: false, quiet: false };
|
|
196
|
+
// Whether the offer has already been made, before the work started.
|
|
197
|
+
let checked = false;
|
|
123
198
|
const files = [];
|
|
124
199
|
while (args.length) {
|
|
125
200
|
const arg = args.shift();
|
|
@@ -145,6 +220,31 @@ async function main(argv) {
|
|
|
145
220
|
}
|
|
146
221
|
if (!options.quiet) process.stderr.write(`${RUNAWAY_WARNING}\n`);
|
|
147
222
|
|
|
223
|
+
// BEFORE THE WORK, when there is somebody to ask — because the point of asking
|
|
224
|
+
// is to run the NEW version, and that is only possible while there is still
|
|
225
|
+
// something to run. Afterwards the files are written and the answer comes too
|
|
226
|
+
// late to change them.
|
|
227
|
+
//
|
|
228
|
+
// It costs a network round trip once a day, not once a run: the rest of the day
|
|
229
|
+
// is a file read. Measured at 25-120 ms against npm, against a run that spends
|
|
230
|
+
// seconds in Prolog.
|
|
231
|
+
if (canAsk() && !options.quiet) {
|
|
232
|
+
const ahead = await updateNotice({ version: VERSION, force: asked })
|
|
233
|
+
.catch(() => ({ message: null, newer: null }));
|
|
234
|
+
if (ahead.message) process.stderr.write(`${ahead.message}\n`);
|
|
235
|
+
if (ahead.newer && await confirm('Update and continue on the new version?')) {
|
|
236
|
+
if ((await upgrade(ahead.newer)) === 0) {
|
|
237
|
+
process.stderr.write('Continuing on the new version.\n');
|
|
238
|
+
// The path has not changed — npm replaced what is behind it — so this is
|
|
239
|
+
// the same command, running the bytes that have just arrived.
|
|
240
|
+
return relaunch(process.argv);
|
|
241
|
+
}
|
|
242
|
+
process.stderr.write('Carrying on with the version you have.\n');
|
|
243
|
+
}
|
|
244
|
+
// Asked and answered: the check below has nothing left to say.
|
|
245
|
+
checked = true;
|
|
246
|
+
}
|
|
247
|
+
|
|
148
248
|
// STARTED NOW, READ AT THE END. The registry is somebody else's machine on
|
|
149
249
|
// somebody else's network, and none of that should stand between the reader
|
|
150
250
|
// and their answers — so the question is asked while the work happens and the
|
|
@@ -153,9 +253,11 @@ async function main(argv) {
|
|
|
153
253
|
// Not asked at all under --quiet, unless it was asked for outright: --quiet
|
|
154
254
|
// means "report only failures", and news about a newer version is not one. Not
|
|
155
255
|
// starting the request is better than starting it and discarding the answer.
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
|
|
256
|
+
// The other half: nobody to ask, so the question is asked ALONGSIDE the work
|
|
257
|
+
// and reported at the end. A terminal has had its offer already.
|
|
258
|
+
const update = checked || (options.quiet && !asked)
|
|
259
|
+
? Promise.resolve({ message: null, newer: null })
|
|
260
|
+
: updateNotice({ version: VERSION, force: asked }).catch(() => ({ message: null, newer: null }));
|
|
159
261
|
|
|
160
262
|
// One engine for the whole invocation, restarted between files. A notebook is
|
|
161
263
|
// a world of its own — one cell is one virtual file, and two chapters may
|
|
@@ -172,11 +274,88 @@ async function main(argv) {
|
|
|
172
274
|
|
|
173
275
|
// stderr, always: `run --stdout` is a notebook going down a pipe, and a version
|
|
174
276
|
// notice in the middle of it would corrupt the file it is printing.
|
|
175
|
-
const
|
|
176
|
-
if (
|
|
277
|
+
const { message, newer } = await update;
|
|
278
|
+
if (message) process.stderr.write(`${message}\n`);
|
|
279
|
+
// Offered AFTER the work, and never re-running it: the files are written, and
|
|
280
|
+
// a command that repeated itself on a newer version would write them twice.
|
|
281
|
+
if (newer) await offerUpgrade(newer);
|
|
177
282
|
return status;
|
|
178
283
|
}
|
|
179
284
|
|
|
285
|
+
/**
|
|
286
|
+
* `build` and `view`, which are the same page put in two different places.
|
|
287
|
+
*
|
|
288
|
+
* Neither runs a cell: a chapter's answers are already in the file, which is the
|
|
289
|
+
* whole reason a built page is readable before any engine arrives. Use `run` to
|
|
290
|
+
* put them there.
|
|
291
|
+
*/
|
|
292
|
+
async function page(command, args) {
|
|
293
|
+
const options = { out: null, port: 8777, open: true };
|
|
294
|
+
const files = [];
|
|
295
|
+
while (args.length) {
|
|
296
|
+
const arg = args.shift();
|
|
297
|
+
if (arg === '--out') options.out = args.shift();
|
|
298
|
+
else if (arg === '--port') {
|
|
299
|
+
options.port = Number(args.shift());
|
|
300
|
+
if (!Number.isInteger(options.port) || options.port < 0 || options.port > 65535) {
|
|
301
|
+
process.stderr.write('--port takes a port number\n');
|
|
302
|
+
return 2;
|
|
303
|
+
}
|
|
304
|
+
} else if (arg === '--no-open') options.open = false;
|
|
305
|
+
else if (arg.startsWith('-')) {
|
|
306
|
+
process.stderr.write(`unknown option "${arg}"\n`);
|
|
307
|
+
return 2;
|
|
308
|
+
} else files.push(arg);
|
|
309
|
+
}
|
|
310
|
+
if (files.length !== 1) {
|
|
311
|
+
process.stderr.write(`${command} takes exactly one notebook\n`);
|
|
312
|
+
return 2;
|
|
313
|
+
}
|
|
314
|
+
|
|
315
|
+
const file = files[0];
|
|
316
|
+
let built;
|
|
317
|
+
try {
|
|
318
|
+
const source = readFileSync(file, 'utf8');
|
|
319
|
+
built = buildFiles(parse(source), source, { filename: basename(file) });
|
|
320
|
+
} catch (e) {
|
|
321
|
+
process.stderr.write(`${file}: ${e.message}\n`);
|
|
322
|
+
return 1;
|
|
323
|
+
}
|
|
324
|
+
|
|
325
|
+
if (command === 'build') {
|
|
326
|
+
const out = options.out ?? `${file.replace(/\.prolog\.md$/, '')}-site`;
|
|
327
|
+
for (const [name, entry] of built) {
|
|
328
|
+
const target = join(out, name);
|
|
329
|
+
mkdirSync(dirname(target), { recursive: true });
|
|
330
|
+
if (entry.text !== undefined) writeFileSync(target, entry.text);
|
|
331
|
+
else copyFileSync(entry.copy, target);
|
|
332
|
+
}
|
|
333
|
+
process.stderr.write(`${out}: ${built.size} files\n`);
|
|
334
|
+
process.stderr.write(`Open ${join(out, 'index.html')} over HTTP, or host the directory.\n`);
|
|
335
|
+
return 0;
|
|
336
|
+
}
|
|
337
|
+
|
|
338
|
+
const server = await serve(built, { port: options.port });
|
|
339
|
+
process.stderr.write(`${basename(file)} at ${server.url}\n`);
|
|
340
|
+
if (server.port !== options.port) {
|
|
341
|
+
process.stderr.write(`(${options.port} was taken)\n`);
|
|
342
|
+
}
|
|
343
|
+
process.stderr.write('Ctrl-C to stop.\n');
|
|
344
|
+
if (options.open) openInBrowser(server.url);
|
|
345
|
+
// Deliberately never resolves: the server is the command.
|
|
346
|
+
return new Promise(() => {});
|
|
347
|
+
}
|
|
348
|
+
|
|
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
|
+
|
|
180
359
|
async function runFile(file, session, options) {
|
|
181
360
|
const name = basename(file);
|
|
182
361
|
let notebook;
|
package/package.json
CHANGED
package/src/build-info.json
CHANGED
package/src/build.js
ADDED
|
@@ -0,0 +1,140 @@
|
|
|
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
|
+
/** The one engine file: the bundle carries its own data. */
|
|
27
|
+
export const ENGINE = 'swipl-bundle.js';
|
|
28
|
+
|
|
29
|
+
/**
|
|
30
|
+
* The page, as a map of file name to what belongs there.
|
|
31
|
+
*
|
|
32
|
+
* @param {{frontMatter: Map<string,string>, cells: object[]}} notebook parsed
|
|
33
|
+
* @param {string} source the notebook's own bytes, for the download
|
|
34
|
+
* @param {{filename?: string, src?: URL, engine?: URL}} [options]
|
|
35
|
+
* `src` is the directory holding the runtime modules and `engine` the
|
|
36
|
+
* directory holding swipl-wasm's bundle — arguments rather than constants so a
|
|
37
|
+
* test can point them anywhere and an installed package can find its own.
|
|
38
|
+
* @returns {Map<string, {text: string}|{copy: URL}>}
|
|
39
|
+
*/
|
|
40
|
+
export function buildFiles(notebook, source, options = {}) {
|
|
41
|
+
const {
|
|
42
|
+
filename = 'notebook.prolog.md',
|
|
43
|
+
src = new URL('./', import.meta.url),
|
|
44
|
+
engine = new URL('../node_modules/swipl-wasm/dist/swipl/', import.meta.url),
|
|
45
|
+
} = options;
|
|
46
|
+
|
|
47
|
+
const files = new Map();
|
|
48
|
+
files.set('index.html', { text: page(notebook) });
|
|
49
|
+
files.set('app.js', { text: app(source, filename) });
|
|
50
|
+
files.set('notebook.css', { copy: new URL('notebook.css', src) });
|
|
51
|
+
for (const module of RUNTIME) files.set(`lib/${module}`, { copy: new URL(module, src) });
|
|
52
|
+
files.set(`swipl/${ENGINE}`, { copy: new URL(ENGINE, engine) });
|
|
53
|
+
return files;
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
/**
|
|
57
|
+
* The chapter's own title, from its first H1 (format §2).
|
|
58
|
+
*
|
|
59
|
+
* Written into the HTML at build time rather than set by script on load: it is
|
|
60
|
+
* the browser tab, the bookmark and the thing a link preview shows, and none of
|
|
61
|
+
* those wait for JavaScript.
|
|
62
|
+
*/
|
|
63
|
+
function titleOf(notebook) {
|
|
64
|
+
for (const cell of notebook.cells) {
|
|
65
|
+
if (cell.kind !== 'markdown') continue;
|
|
66
|
+
const heading = /^#\s+(.+)$/m.exec(cell.source);
|
|
67
|
+
if (heading) return heading[1].trim();
|
|
68
|
+
}
|
|
69
|
+
return 'A Prolog notebook';
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
function escapeHtml(text) {
|
|
73
|
+
return String(text).replace(/[&<>"]/g, (c) => ({ '&': '&', '<': '<', '>': '>', '"': '"' }[c]));
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
function page(notebook) {
|
|
77
|
+
return `<!doctype html>
|
|
78
|
+
<html lang="en">
|
|
79
|
+
<head>
|
|
80
|
+
<meta charset="utf-8">
|
|
81
|
+
<meta name="viewport" content="width=device-width, initial-scale=1">
|
|
82
|
+
<title>${escapeHtml(titleOf(notebook))}</title>
|
|
83
|
+
<link rel="stylesheet" href="notebook.css">
|
|
84
|
+
</head>
|
|
85
|
+
<body>
|
|
86
|
+
<main>
|
|
87
|
+
${renderNotebook(notebook)}
|
|
88
|
+
<!--
|
|
89
|
+
THE CHAPTER ABOVE IS ALREADY READABLE. Everything below is the runtime that
|
|
90
|
+
makes it runnable, and none of it is needed to read a word.
|
|
91
|
+
-->
|
|
92
|
+
<div id="boot-warning">
|
|
93
|
+
<strong>This notebook is not running.</strong>
|
|
94
|
+
The page loaded but its JavaScript did not. The usual cause is opening
|
|
95
|
+
<code>index.html</code> straight from disk — browsers block ES modules over
|
|
96
|
+
<code>file://</code>. Serve it over HTTP instead, or run
|
|
97
|
+
<code>prolog-notebook view</code> on the notebook itself.
|
|
98
|
+
The chapter is readable either way; only the buttons need this.
|
|
99
|
+
</div>
|
|
100
|
+
</main>
|
|
101
|
+
<script type="module" src="app.js"></script>
|
|
102
|
+
</body>
|
|
103
|
+
</html>
|
|
104
|
+
`;
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
/**
|
|
108
|
+
* The wiring, and the source it hands back when a reader asks for a copy.
|
|
109
|
+
*
|
|
110
|
+
* The notebook's own bytes are embedded because "the chapter as published" has to
|
|
111
|
+
* mean the bytes the author wrote, not a re-serialisation of the model — a
|
|
112
|
+
* hand-written chapter would otherwise come back reformatted.
|
|
113
|
+
*/
|
|
114
|
+
function app(source, filename) {
|
|
115
|
+
return `// Generated by prolog-notebook build. The chapter is already in index.html;
|
|
116
|
+
// this only wires it up.
|
|
117
|
+
import { editsOf, mount, offerDownload } from './lib/notebook.js';
|
|
118
|
+
import { parse } from './lib/format.js';
|
|
119
|
+
import { exportSource } from './lib/export.js';
|
|
120
|
+
|
|
121
|
+
const SOURCE = ${JSON.stringify(source)};
|
|
122
|
+
const FILENAME = ${JSON.stringify(filename)};
|
|
123
|
+
|
|
124
|
+
const root = document.querySelector('main');
|
|
125
|
+
// The engine lives beside this file rather than in a node_modules the browser
|
|
126
|
+
// cannot see, so its location is passed rather than guessed.
|
|
127
|
+
const cells = mount(root, {
|
|
128
|
+
swiplUrl: new URL('./swipl/${ENGINE}', import.meta.url).href,
|
|
129
|
+
});
|
|
130
|
+
|
|
131
|
+
const notebook = parse(SOURCE);
|
|
132
|
+
offerDownload(root, {
|
|
133
|
+
produce: () => ({ filename: FILENAME, text: exportSource(notebook, editsOf(cells)) }),
|
|
134
|
+
published: () => ({ filename: FILENAME, text: SOURCE }),
|
|
135
|
+
isEdited: () => cells.programs.some((p) => p.isEdited())
|
|
136
|
+
|| cells.queries.some((q) => q.isEdited()),
|
|
137
|
+
on: cells.on,
|
|
138
|
+
});
|
|
139
|
+
`;
|
|
140
|
+
}
|
package/src/notebook.js
CHANGED
|
@@ -1338,6 +1338,31 @@ function mountQuery(cell, options, bus, { above = [], below = [], prediction = n
|
|
|
1338
1338
|
};
|
|
1339
1339
|
}
|
|
1340
1340
|
|
|
1341
|
+
/**
|
|
1342
|
+
* What the cells now say, keyed by id, for src/export.js to fold into the model.
|
|
1343
|
+
*
|
|
1344
|
+
* HERE RATHER THAN IN page.js, because a built page has its HTML already and must
|
|
1345
|
+
* not import the renderer to get this — page.js pulls in markdown-it, and 137 KB
|
|
1346
|
+
* of markdown parser to answer "what does this textarea say" is the opposite of
|
|
1347
|
+
* why these two files are separate.
|
|
1348
|
+
*
|
|
1349
|
+
* @param {{programs: object[], queries: object[]}} cells what mount() returned
|
|
1350
|
+
* @returns {Map<string, object>}
|
|
1351
|
+
*/
|
|
1352
|
+
export function editsOf(cells) {
|
|
1353
|
+
const map = new Map();
|
|
1354
|
+
for (const program of cells.programs) {
|
|
1355
|
+
map.set(program.name, { source: program.text() });
|
|
1356
|
+
}
|
|
1357
|
+
for (const query of cells.queries) {
|
|
1358
|
+
const output = query.output();
|
|
1359
|
+
map.set(query.id, output === undefined
|
|
1360
|
+
? { goal: query.goal() }
|
|
1361
|
+
: { goal: query.goal(), output });
|
|
1362
|
+
}
|
|
1363
|
+
return map;
|
|
1364
|
+
}
|
|
1365
|
+
|
|
1341
1366
|
function autosizeNow(ta) {
|
|
1342
1367
|
ta.style.height = 'auto';
|
|
1343
1368
|
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/serve.js
ADDED
|
@@ -0,0 +1,80 @@
|
|
|
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 { createReadStream } from 'node:fs';
|
|
12
|
+
import { createServer } from 'node:http';
|
|
13
|
+
|
|
14
|
+
const TYPES = {
|
|
15
|
+
'.html': 'text/html; charset=utf-8',
|
|
16
|
+
'.js': 'text/javascript; charset=utf-8',
|
|
17
|
+
'.css': 'text/css; charset=utf-8',
|
|
18
|
+
'.json': 'application/json; charset=utf-8',
|
|
19
|
+
'.wasm': 'application/wasm',
|
|
20
|
+
'.data': 'application/octet-stream',
|
|
21
|
+
'.md': 'text/markdown; charset=utf-8',
|
|
22
|
+
};
|
|
23
|
+
|
|
24
|
+
export function contentType(name) {
|
|
25
|
+
const dot = name.lastIndexOf('.');
|
|
26
|
+
return TYPES[name.slice(dot)] ?? 'application/octet-stream';
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
/**
|
|
30
|
+
* Serve a built page.
|
|
31
|
+
*
|
|
32
|
+
* @param {Map<string, {text: string}|{copy: URL}>} files what build produced
|
|
33
|
+
* @param {{port?: number, host?: string}} [options]
|
|
34
|
+
* @returns {Promise<{url: string, port: number, close: () => Promise<void>}>}
|
|
35
|
+
*/
|
|
36
|
+
export async function serve(files, { port = 8777, host = '127.0.0.1' } = {}) {
|
|
37
|
+
const server = createServer((request, response) => {
|
|
38
|
+
// Only GET, and only the names this process generated: the path never
|
|
39
|
+
// reaches the filesystem, so there is nothing for a `..` to escape into.
|
|
40
|
+
const name = decodeURIComponent(new URL(request.url, 'http://x').pathname).replace(/^\//, '');
|
|
41
|
+
const entry = files.get(name === '' ? 'index.html' : name);
|
|
42
|
+
if (request.method !== 'GET' || !entry) {
|
|
43
|
+
response.writeHead(404, { 'content-type': 'text/plain' }).end('not found\n');
|
|
44
|
+
return;
|
|
45
|
+
}
|
|
46
|
+
response.writeHead(200, {
|
|
47
|
+
'content-type': contentType(name || 'index.html'),
|
|
48
|
+
// A page being written is a page that changes under the reader.
|
|
49
|
+
'cache-control': 'no-store',
|
|
50
|
+
});
|
|
51
|
+
if (entry.text !== undefined) {
|
|
52
|
+
response.end(entry.text);
|
|
53
|
+
return;
|
|
54
|
+
}
|
|
55
|
+
createReadStream(entry.copy).on('error', () => response.end()).pipe(response);
|
|
56
|
+
});
|
|
57
|
+
|
|
58
|
+
const listening = await listen(server, port, host);
|
|
59
|
+
return {
|
|
60
|
+
url: `http://${host}:${listening}/`,
|
|
61
|
+
port: listening,
|
|
62
|
+
close: () => new Promise((resolve) => server.close(resolve)),
|
|
63
|
+
};
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
/**
|
|
67
|
+
* Take the port asked for, or any port at all.
|
|
68
|
+
*
|
|
69
|
+
* A tool that dies because something else is on 8777 is a tool that makes the
|
|
70
|
+
* reader find out what. It says which port it took instead.
|
|
71
|
+
*/
|
|
72
|
+
function listen(server, port, host) {
|
|
73
|
+
return new Promise((resolve, reject) => {
|
|
74
|
+
server.once('error', (error) => {
|
|
75
|
+
if (error.code !== 'EADDRINUSE') return reject(error);
|
|
76
|
+
server.listen(0, host, () => resolve(server.address().port));
|
|
77
|
+
});
|
|
78
|
+
server.listen(port, host, () => resolve(server.address().port));
|
|
79
|
+
});
|
|
80
|
+
}
|
package/src/update.js
CHANGED
|
@@ -124,7 +124,11 @@ export function isNewer(latest, current) {
|
|
|
124
124
|
}
|
|
125
125
|
|
|
126
126
|
/**
|
|
127
|
-
*
|
|
127
|
+
* What to say, and what was found.
|
|
128
|
+
*
|
|
129
|
+
* An object rather than a string because a caller may want to ACT on it — offer
|
|
130
|
+
* to upgrade — and parsing our own sentence back into a version number would be
|
|
131
|
+
* a worse way to learn what we already knew.
|
|
128
132
|
*
|
|
129
133
|
* @param {object} options
|
|
130
134
|
* @param {string} options.version what this copy is
|
|
@@ -134,7 +138,7 @@ export function isNewer(latest, current) {
|
|
|
134
138
|
* @param {{read: Function, write: Function}} [options.store]
|
|
135
139
|
* @param {() => Promise<string|null>} [options.latest]
|
|
136
140
|
* @param {object} [options.env]
|
|
137
|
-
* @returns {Promise<string|null>}
|
|
141
|
+
* @returns {Promise<{message: string|null, newer: string|null}>}
|
|
138
142
|
*/
|
|
139
143
|
export async function updateNotice({
|
|
140
144
|
version,
|
|
@@ -145,7 +149,12 @@ export async function updateNotice({
|
|
|
145
149
|
latest = latestFromRegistry,
|
|
146
150
|
env = process.env,
|
|
147
151
|
} = {}) {
|
|
148
|
-
|
|
152
|
+
const quiet = { message: null, newer: null };
|
|
153
|
+
// UPGRADED is the loop guard: the process that replaced itself re-runs this
|
|
154
|
+
// command on the new version, and that one must not go round again. A failed
|
|
155
|
+
// or partial upgrade would otherwise re-exec for ever.
|
|
156
|
+
if (!force && (env.CI || env.NO_UPDATE_NOTIFIER || env.PROLOG_NOTEBOOK_UPGRADED)) return quiet;
|
|
157
|
+
if (env.PROLOG_NOTEBOOK_UPGRADED) return quiet;
|
|
149
158
|
|
|
150
159
|
const remembered = store.read();
|
|
151
160
|
const fresh = !force && remembered && now - remembered.checked < ttl;
|
|
@@ -155,14 +164,20 @@ export async function updateNotice({
|
|
|
155
164
|
if (!fresh) store.write({ checked: now, latest: newest ?? null });
|
|
156
165
|
|
|
157
166
|
if (!newest) {
|
|
158
|
-
return fresh && !force
|
|
167
|
+
return fresh && !force
|
|
168
|
+
? quiet
|
|
169
|
+
: { message: 'Could not reach the npm registry to check for updates.', newer: null };
|
|
159
170
|
}
|
|
160
171
|
// BOTH LINES START WITH WHAT YOU HAVE, because that is the question being
|
|
161
172
|
// asked. "Prolog Notebook 0.4.0 is the latest" states a fact about the world
|
|
162
173
|
// and leaves the reader to work out that it is also a fact about them.
|
|
163
174
|
if (isNewer(newest, version)) {
|
|
164
|
-
return
|
|
165
|
-
|
|
175
|
+
return {
|
|
176
|
+
message: `You have Prolog Notebook ${version}. The latest is ${newest}.`,
|
|
177
|
+
newer: newest,
|
|
178
|
+
};
|
|
166
179
|
}
|
|
167
|
-
return force
|
|
180
|
+
return force
|
|
181
|
+
? { message: `You have the latest version of Prolog Notebook, ${version}.`, newer: null }
|
|
182
|
+
: quiet;
|
|
168
183
|
}
|
package/src/upgrade.js
ADDED
|
@@ -0,0 +1,128 @@
|
|
|
1
|
+
// Updating itself, which is mostly a question of knowing how it was installed.
|
|
2
|
+
//
|
|
3
|
+
// A global `npm i -g`, a project dependency, an npx run, a pnpm or bun global,
|
|
4
|
+
// a git checkout — each needs a different answer, and running `npm i -g` from
|
|
5
|
+
// the wrong one either fails or upgrades something the reader did not mean. So
|
|
6
|
+
// this proves the global case and REFUSES THE REST WITH THE RIGHT COMMAND
|
|
7
|
+
// rather than guessing: a tool that breaks somebody's project while trying to
|
|
8
|
+
// help is worse than one that tells them what to type.
|
|
9
|
+
//
|
|
10
|
+
// Nothing here happens without being asked. See bin/prolog-notebook.mjs for the
|
|
11
|
+
// prompt, which only appears at a terminal — a pipe, a script and CI are all
|
|
12
|
+
// places where a question is a hang.
|
|
13
|
+
import { spawn } from 'node:child_process';
|
|
14
|
+
import { execFile } from 'node:child_process';
|
|
15
|
+
import { promisify } from 'node:util';
|
|
16
|
+
|
|
17
|
+
const run = promisify(execFile);
|
|
18
|
+
|
|
19
|
+
/** npm is a batch file on Windows, and spawn will not find it otherwise. */
|
|
20
|
+
export const NPM = process.platform === 'win32' ? 'npm.cmd' : 'npm';
|
|
21
|
+
|
|
22
|
+
/**
|
|
23
|
+
* Which kind of copy is this?
|
|
24
|
+
*
|
|
25
|
+
* @param {{packageRoot: string, globalRoot: string|null}} where
|
|
26
|
+
* @returns {'global'|'local'|'source'}
|
|
27
|
+
*/
|
|
28
|
+
export function describeInstall({ packageRoot, globalRoot }) {
|
|
29
|
+
if (globalRoot && packageRoot.startsWith(globalRoot)) return 'global';
|
|
30
|
+
// `node_modules` in the path and not the global root: somebody's project
|
|
31
|
+
// depends on this, and upgrading it globally would leave that project on the
|
|
32
|
+
// version it pinned while changing a tool they did not ask about.
|
|
33
|
+
if (packageRoot.includes(`${'node_modules'}`)) return 'local';
|
|
34
|
+
return 'source';
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
/**
|
|
38
|
+
* What to do about it — a command to run, or words to print.
|
|
39
|
+
*
|
|
40
|
+
* @param {'global'|'local'|'source'} kind
|
|
41
|
+
* @param {string} version
|
|
42
|
+
* @returns {{argv: string[]}|{say: string}}
|
|
43
|
+
*/
|
|
44
|
+
export function upgradePlan(kind, version) {
|
|
45
|
+
if (kind === 'global') return { argv: ['i', '-g', `prolog-notebook@${version}`] };
|
|
46
|
+
if (kind === 'local') {
|
|
47
|
+
return {
|
|
48
|
+
say: 'This copy is a dependency of a project rather than a global install.\n'
|
|
49
|
+
+ `Upgrade it there: npm i prolog-notebook@${version}`,
|
|
50
|
+
};
|
|
51
|
+
}
|
|
52
|
+
return {
|
|
53
|
+
say: 'This copy is a source checkout, not an install. Upgrade it with git.',
|
|
54
|
+
};
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
/** Where npm keeps global packages, or null if it will not say. */
|
|
58
|
+
export async function globalRoot(exec = run) {
|
|
59
|
+
try {
|
|
60
|
+
const { stdout } = await exec(NPM, ['root', '-g'], { timeout: 5000 });
|
|
61
|
+
return stdout.trim() || null;
|
|
62
|
+
} catch {
|
|
63
|
+
return null;
|
|
64
|
+
}
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
/**
|
|
68
|
+
* Ask a yes/no question, defaulting to yes.
|
|
69
|
+
*
|
|
70
|
+
* ON STDERR, always: `run --stdout` is a notebook going down a pipe, and a
|
|
71
|
+
* question in the middle of it would corrupt the file it is writing.
|
|
72
|
+
*
|
|
73
|
+
* @returns {Promise<boolean>}
|
|
74
|
+
*/
|
|
75
|
+
export async function confirm(question, { input = process.stdin, output = process.stderr } = {}) {
|
|
76
|
+
const { createInterface } = await import('node:readline/promises');
|
|
77
|
+
const rl = createInterface({ input, output });
|
|
78
|
+
try {
|
|
79
|
+
const answer = await rl.question(`${question} [Y/n] `);
|
|
80
|
+
return !/^n/i.test(answer.trim());
|
|
81
|
+
} catch {
|
|
82
|
+
// Ctrl-C, a closed stream: not an answer, so not a yes.
|
|
83
|
+
return false;
|
|
84
|
+
} finally {
|
|
85
|
+
rl.close();
|
|
86
|
+
}
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
/**
|
|
90
|
+
* Do it, showing npm's own output rather than a spinner of our own.
|
|
91
|
+
*
|
|
92
|
+
* @returns {Promise<boolean>} whether npm was happy
|
|
93
|
+
*/
|
|
94
|
+
export function install(argv, spawnImpl = spawn) {
|
|
95
|
+
return new Promise((resolve) => {
|
|
96
|
+
const child = spawnImpl(NPM, argv, { stdio: ['ignore', 'inherit', 'inherit'] });
|
|
97
|
+
child.on('error', () => resolve(false));
|
|
98
|
+
child.on('close', (code) => resolve(code === 0));
|
|
99
|
+
});
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
/**
|
|
103
|
+
* Run this same command again, on the version that has just replaced us.
|
|
104
|
+
*
|
|
105
|
+
* THE PATH DOES NOT CHANGE, which is what makes this work: npm replaces the
|
|
106
|
+
* contents of the package directory, and the bin the reader typed still points
|
|
107
|
+
* at the same file. So the script to run is the one we are already running — its
|
|
108
|
+
* bytes are simply new.
|
|
109
|
+
*
|
|
110
|
+
* A child rather than a true exec, because Node has no execve: stdio is
|
|
111
|
+
* inherited so it looks like one process, and the child's exit code becomes
|
|
112
|
+
* ours. The marker in the environment stops the new process checking for updates
|
|
113
|
+
* again, which is what would otherwise turn a failed upgrade into a loop.
|
|
114
|
+
*
|
|
115
|
+
* @param {string[]} argv the original process.argv
|
|
116
|
+
* @param {Function} [spawnImpl]
|
|
117
|
+
* @returns {Promise<number>} the exit code to leave with
|
|
118
|
+
*/
|
|
119
|
+
export function relaunch(argv, spawnImpl = spawn) {
|
|
120
|
+
return new Promise((resolve) => {
|
|
121
|
+
const child = spawnImpl(argv[0], argv.slice(1), {
|
|
122
|
+
stdio: 'inherit',
|
|
123
|
+
env: { ...process.env, PROLOG_NOTEBOOK_UPGRADED: '1' },
|
|
124
|
+
});
|
|
125
|
+
child.on('error', () => resolve(1));
|
|
126
|
+
child.on('close', (code) => resolve(code ?? 0));
|
|
127
|
+
});
|
|
128
|
+
}
|
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.0';
|
|
14
14
|
|
|
15
15
|
/** The two facts a licence notice is actually made of. */
|
|
16
16
|
export const YEAR = '2026';
|