prolog-notebook 0.6.4 → 0.7.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 +128 -0
- package/README.md +19 -5
- package/bin/prolog-notebook.mjs +200 -22
- package/package.json +1 -1
- package/src/browser.js +10 -2
- package/src/build-info.json +2 -2
- package/src/build.js +56 -11
- package/src/notebook.js +50 -18
- package/src/site.js +259 -0
- package/src/version.js +1 -1
package/CHANGELOG.md
CHANGED
|
@@ -1,5 +1,133 @@
|
|
|
1
1
|
# Changelog
|
|
2
2
|
|
|
3
|
+
## [0.7.0] — 2026-08-31
|
|
4
|
+
|
|
5
|
+
A site of notebooks, rather than a folder of unrelated pages.
|
|
6
|
+
|
|
7
|
+
### Added
|
|
8
|
+
|
|
9
|
+
- **A site has exactly one runtime, and `build` keeps it that way.** A page's `app.js` and the
|
|
10
|
+
shared `lib/` are not a stable contract — `offerDownload` gained arguments in #28, `editsOf`
|
|
11
|
+
moved modules in #40 — so a site holding two generations of page has no safe resting state:
|
|
12
|
+
overwrite `lib/` and the older page imports a symbol that has moved; leave it and the page just
|
|
13
|
+
built is the broken one. So a build reconciles rather than warns.
|
|
14
|
+
|
|
15
|
+
| the site was written by | what happens |
|
|
16
|
+
|---|---|
|
|
17
|
+
| the same versions | the page is written and **nothing else is touched** — the 6.2 MB engine is not copied again |
|
|
18
|
+
| an older version | the shared files are replaced and **every page is regenerated**, said out loud: `runtime 0.6.0 → 0.7.0 · 2 pages regenerated` |
|
|
19
|
+
| an older engine | the same, plus what regeneration cannot fix: `engine 8.0.1 → 8.0.7 · re-run \`execute\` on your chapters` |
|
|
20
|
+
| a newer version | **refused.** A silent downgrade of pages you did not name is the case you almost certainly did not mean |
|
|
21
|
+
|
|
22
|
+
Outputs are never cleared on an engine bump. We would know the answers came from a different
|
|
23
|
+
engine, not that they are wrong, and erasing asserts more than we know — the complication of
|
|
24
|
+
version management belongs in what is recorded and reported, not in what is destroyed.
|
|
25
|
+
|
|
26
|
+
- **A page carries the chapter it was built from.** `prolog-notebook-site/lists/lists.prolog.md`
|
|
27
|
+
sits beside the page it produced, so a site can rebuild itself with no source tree, no
|
|
28
|
+
repository and no manifest — and a reader can have the markdown.
|
|
29
|
+
|
|
30
|
+
- **A site, with an index.** `build` writes every chapter into one `prolog-notebook-site`, and
|
|
31
|
+
regenerates `index.html` from the directory each time — each entry titled by that chapter's own
|
|
32
|
+
H1, alphabetical, because a notebook never states its own position and an index is the site's
|
|
33
|
+
opinion rather than the notebook's.
|
|
34
|
+
|
|
35
|
+
- **`--here`**, for writing `prolog-notebook-site` beside the notebook instead of at the project
|
|
36
|
+
root.
|
|
37
|
+
|
|
38
|
+
### Changed
|
|
39
|
+
|
|
40
|
+
- **`build` writes to one site at the project root, not a folder beside each notebook.** It used
|
|
41
|
+
to default to `<file>-site`, so `notebooks/lists.prolog.md` became `notebooks/lists-site/`:
|
|
42
|
+
twenty chapters gave you twenty orphan sites, each with its own copy of the engine, and nowhere
|
|
43
|
+
for a table of contents to live because there was no "the site" for one to be a table of
|
|
44
|
+
contents of.
|
|
45
|
+
|
|
46
|
+
The destination is now found by walking up from the notebook and taking the first hit — an
|
|
47
|
+
existing `prolog-notebook-site`, then a `.git`, then the working directory. The first clue is
|
|
48
|
+
the one that matters: a second chapter, built from a different folder, joins the first's site
|
|
49
|
+
without being told to.
|
|
50
|
+
|
|
51
|
+
**The runtime, the engine and the stylesheet are the site's, not the page's.** They are written
|
|
52
|
+
once at the root and every page reaches them with `../`. Two chapters used to cost 12.6 MB; they
|
|
53
|
+
now cost 6.3.
|
|
54
|
+
|
|
55
|
+
And every build says where it went — `3 files → ../prolog-notebook-site/lists/ (12 shared with
|
|
56
|
+
the site)` — plus, on the build that creates it, one line noting you may want the directory in
|
|
57
|
+
`.gitignore`. Writing outside the directory you named is not something a tool should do quietly.
|
|
58
|
+
|
|
59
|
+
|
|
60
|
+
- **The bare help screen names the commands and leaves everything else to them.** It printed
|
|
61
|
+
every option of every command, which made the first screen a new reader meets the longest one in
|
|
62
|
+
the tool and left the per-command help earning nothing. What remains is the name, what it does,
|
|
63
|
+
the three flags that really do work anywhere, and where to ask for more:
|
|
64
|
+
|
|
65
|
+
```
|
|
66
|
+
view read it in a browser, cells and all
|
|
67
|
+
build write a page you can host or send
|
|
68
|
+
execute run every query, write the answers in
|
|
69
|
+
clear take the answers back out
|
|
70
|
+
upgrade fetch the latest version
|
|
71
|
+
```
|
|
72
|
+
|
|
73
|
+
Not even the operand: every command takes a file, so naming it here said nothing about the
|
|
74
|
+
choice this screen exists to help with. The summary answers *which command*; the command's own
|
|
75
|
+
help answers *how to call it*.
|
|
76
|
+
|
|
77
|
+
- **Every command's help is laid out the same way**, because the usage line is now derived rather
|
|
78
|
+
than written out five times:
|
|
79
|
+
|
|
80
|
+
```
|
|
81
|
+
prolog-notebook clear [<options>] <file(s)> take the answers back out
|
|
82
|
+
<file(s)> space separated list of Prolog Notebook files (.md)
|
|
83
|
+
--stdout print the result instead of writing the file
|
|
84
|
+
--quiet report only failures
|
|
85
|
+
```
|
|
86
|
+
|
|
87
|
+
What the command takes is named on a line beside the switches — they are the same kind of fact,
|
|
88
|
+
and a reader who has to learn two shapes to read one screen is paying for our tidiness.
|
|
89
|
+
Options come before operands, as POSIX has it and as every tool a reader has already met prints
|
|
90
|
+
it, and they are bracketed because they may be left out where the file may not. `[<options>]`
|
|
91
|
+
appears only on a command that has any. A command taking several files says `<file(s)>` rather
|
|
92
|
+
than the conventional `<file>...` — the ellipsis is punctuation you have to already know, and
|
|
93
|
+
nobody types either form, so the cost of being legible here is nothing.
|
|
94
|
+
|
|
95
|
+
The `--limit` footnote goes with them, to `execute --help` where the flag it explains lives.
|
|
96
|
+
|
|
97
|
+
- **`-h, --help` now says which help it gave you.** The line read `this` — written when there
|
|
98
|
+
was only one screen it could have meant, and never revisited when commands learned to answer
|
|
99
|
+
about themselves. The summary now offers `prolog-notebook build --help`; a command's own help
|
|
100
|
+
still says `this`, where it is once again true.
|
|
101
|
+
|
|
102
|
+
## [0.6.5] — 2026-08-31
|
|
103
|
+
|
|
104
|
+
Both from one field report on a real chapter.
|
|
105
|
+
|
|
106
|
+
### Fixed
|
|
107
|
+
|
|
108
|
+
- **`Attempt to access not innermost query` on a perfectly ordinary Run.** Loading the program
|
|
109
|
+
cells above a query emits `consulted`, which is exactly what `rerun="auto"` waits for — so an
|
|
110
|
+
auto cell's re-run was queued and ran while the manual run was still inside its awaits. It
|
|
111
|
+
asked whether anybody was mid-sequence, saw nobody, and opened its query underneath the one
|
|
112
|
+
about to be opened. One engine allows one open query, and whichever is not innermost cannot
|
|
113
|
+
be stepped, so the reader — who had pressed Run on one cell and touched nothing else — got
|
|
114
|
+
SWI's own words for a stack they are never supposed to meet.
|
|
115
|
+
|
|
116
|
+
A cell now claims the engine when its run **starts**, not when it succeeds. Two supporting
|
|
117
|
+
changes close the same class: the engine's version probe is awaited during boot rather than
|
|
118
|
+
fired beside the reader's first Run, and `all()` holds the session's one slot until the
|
|
119
|
+
worker has answered rather than releasing it up front.
|
|
120
|
+
|
|
121
|
+
- **No way to clear your own answers in a chapter that shipped without any.** The outputs row
|
|
122
|
+
removed itself when the FILE had no saved answers — but that argument dies the moment a
|
|
123
|
+
reader presses Run, and a chapter published unrun is most chapters while an author is still
|
|
124
|
+
writing. The row now exists whenever there is a query cell, and counts what is on the page
|
|
125
|
+
rather than what is in the file: *No outputs yet*, *3 outputs on this page*, *3 outputs
|
|
126
|
+
cleared*.
|
|
127
|
+
|
|
128
|
+
The hide row above it stays keyed to the file, and correctly — hiding is for the chapter's
|
|
129
|
+
saved answers, and a reader's own run is not a spoiler.
|
|
130
|
+
|
|
3
131
|
## [0.6.4] — 2026-08-31
|
|
4
132
|
|
|
5
133
|
### Changed
|
package/README.md
CHANGED
|
@@ -68,12 +68,25 @@ four splits arrive one at a time. Nothing is installed but the command.
|
|
|
68
68
|
To send it to somebody, or host it:
|
|
69
69
|
|
|
70
70
|
```sh
|
|
71
|
-
prolog-notebook build splitting.prolog.md
|
|
71
|
+
prolog-notebook build splitting.prolog.md
|
|
72
|
+
# 2 files → prolog-notebook-site/splitting/ (11 shared with the site)
|
|
73
|
+
# prolog-notebook-site/index.html lists 1 notebook
|
|
72
74
|
```
|
|
73
75
|
|
|
74
|
-
A plain directory: prerendered HTML with the saved answers in it, the runtime
|
|
75
|
-
|
|
76
|
-
your own, nothing to
|
|
76
|
+
A plain directory: prerendered HTML with the saved answers in it, the runtime and the 6.2 MB
|
|
77
|
+
engine shared at the root, and an index listing every chapter you have built. The engine is
|
|
78
|
+
fetched only when a reader presses Run. No bundler, no build step of your own, nothing to
|
|
79
|
+
configure.
|
|
80
|
+
|
|
81
|
+
Build a second chapter from anywhere in the project and it joins the same site — `build` walks
|
|
82
|
+
up for an existing `prolog-notebook-site`, then for a `.git`, so chapters that live in
|
|
83
|
+
different folders still publish as one thing. `--here` puts the site beside the notebook
|
|
84
|
+
instead; `--out <dir>` puts it wherever you say.
|
|
85
|
+
|
|
86
|
+
A site has exactly one runtime and one engine, so the second post costs its own page rather than
|
|
87
|
+
another 6.2 MB. When you upgrade the tool, the next build brings the whole site with it and says
|
|
88
|
+
so — `runtime 0.6.0 → 0.7.0 · 2 pages regenerated` — because each page keeps the chapter it was
|
|
89
|
+
built from.
|
|
77
90
|
|
|
78
91
|
### Or from a checkout
|
|
79
92
|
|
|
@@ -139,7 +152,8 @@ Each option belongs to a command, and typing one under the wrong command tells y
|
|
|
139
152
|
| `--limit <n>` | `execute` | solutions to take from one query before stopping. Default 100. |
|
|
140
153
|
| `--stdout` | `execute`, `clear` | print the result instead of writing the file |
|
|
141
154
|
| `--quiet` | `execute`, `clear` | report only failures |
|
|
142
|
-
| `--out <dir>` | `build` | where
|
|
155
|
+
| `--out <dir>` | `build` | where the site is. Default: the nearest `prolog-notebook-site`, else one at the project root |
|
|
156
|
+
| `--here` | `build` | write `prolog-notebook-site` beside the notebook instead |
|
|
143
157
|
| `--port <n>` | `view` | what it listens on. Default 8777, and it takes another if that one is busy |
|
|
144
158
|
| `--no-open` | `view` | print the URL instead of opening a browser |
|
|
145
159
|
|
package/bin/prolog-notebook.mjs
CHANGED
|
@@ -4,7 +4,7 @@
|
|
|
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 { copyFileSync, mkdirSync, readFileSync, writeFileSync } from 'node:fs';
|
|
7
|
+
import { copyFileSync, existsSync, mkdirSync, readFileSync, writeFileSync } from 'node:fs';
|
|
8
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';
|
|
@@ -14,7 +14,10 @@ import { updateNotice } from '../src/update.js';
|
|
|
14
14
|
import { confirm, describeInstall, globalRoot, install, relaunch, upgradePlan } from '../src/upgrade.js';
|
|
15
15
|
import { clearedSource, exportSource } from '../src/export.js';
|
|
16
16
|
import { runNotebook, DEFAULT_LIMIT } from '../src/run.js';
|
|
17
|
-
import { livePages } from '../src/build.js';
|
|
17
|
+
import { buildFiles, livePages } from '../src/build.js';
|
|
18
|
+
import {
|
|
19
|
+
SITE, findSite, indexHtml, isShared, pageName, pagesIn, reconcile, shownAs, sourceOf,
|
|
20
|
+
} from '../src/site.js';
|
|
18
21
|
import { openInBrowser, serve } from '../src/serve.js';
|
|
19
22
|
|
|
20
23
|
// The engine is imported WHERE IT IS USED, never at the top. src/node.js pulls in
|
|
@@ -37,6 +40,23 @@ for (const stream of [process.stdout, process.stderr]) {
|
|
|
37
40
|
// src/version.js, where a page can import it too.
|
|
38
41
|
const require = createRequire(import.meta.url);
|
|
39
42
|
|
|
43
|
+
/**
|
|
44
|
+
* WHAT A COMMAND TAKES BESIDE ITS OPTIONS, named and explained once.
|
|
45
|
+
*
|
|
46
|
+
* `.prolog.md` is a convention and nothing enforces it — any markdown file runs —
|
|
47
|
+
* so the operand is `<file>` everywhere and the line below the usage says what
|
|
48
|
+
* kind of file to hand it.
|
|
49
|
+
*
|
|
50
|
+
* SEVERAL FILES ARE `<file(s)>`, NOT THE POSIX `<file>...`, and the departure is
|
|
51
|
+
* deliberate. The ellipsis is the convention — Base Specifications 12.1, and what
|
|
52
|
+
* cc, cp, grep and git print — but it is punctuation you have to already know, and
|
|
53
|
+
* the Captain read it as saying less than it does. `(s)` is legible to someone who
|
|
54
|
+
* has never read a man page, and the row below spells it out in words anyway.
|
|
55
|
+
* Nobody types either form, so the cost of being unconventional here is zero.
|
|
56
|
+
*/
|
|
57
|
+
const FILE = ['<file>', 'Prolog Notebook file (.md)'];
|
|
58
|
+
const FILES = ['<file(s)>', 'space separated list of Prolog Notebook files (.md)'];
|
|
59
|
+
|
|
40
60
|
/**
|
|
41
61
|
* THE COMMANDS, AND WHAT EACH ONE TAKES — one table, three readers (869erqra0).
|
|
42
62
|
*
|
|
@@ -49,7 +69,7 @@ const require = createRequire(import.meta.url);
|
|
|
49
69
|
*/
|
|
50
70
|
const COMMANDS = {
|
|
51
71
|
view: {
|
|
52
|
-
|
|
72
|
+
takes: [FILE],
|
|
53
73
|
blurb: 'read it in a browser, cells and all',
|
|
54
74
|
options: [
|
|
55
75
|
['--port <n>', 'what it listens on (default 8777)'],
|
|
@@ -57,12 +77,15 @@ const COMMANDS = {
|
|
|
57
77
|
],
|
|
58
78
|
},
|
|
59
79
|
build: {
|
|
60
|
-
|
|
80
|
+
takes: [FILE],
|
|
61
81
|
blurb: 'write a page you can host or send',
|
|
62
|
-
options: [
|
|
82
|
+
options: [
|
|
83
|
+
['--out <dir>', `where the site is (default: the nearest ${SITE})`],
|
|
84
|
+
['--here', `write ${SITE} beside the notebook instead`],
|
|
85
|
+
],
|
|
63
86
|
},
|
|
64
87
|
execute: {
|
|
65
|
-
|
|
88
|
+
takes: [FILES],
|
|
66
89
|
blurb: 'run every query, write the answers in',
|
|
67
90
|
options: [
|
|
68
91
|
['--limit <n>', `solutions to take from one query before stopping (default ${DEFAULT_LIMIT})`],
|
|
@@ -74,7 +97,7 @@ const COMMANDS = {
|
|
|
74
97
|
+ "format's way of saying the search was never exhausted. Nothing is invented.\n",
|
|
75
98
|
},
|
|
76
99
|
clear: {
|
|
77
|
-
|
|
100
|
+
takes: [FILES],
|
|
78
101
|
blurb: 'take the answers back out',
|
|
79
102
|
options: [
|
|
80
103
|
['--stdout', 'print the result instead of writing the file'],
|
|
@@ -82,7 +105,7 @@ const COMMANDS = {
|
|
|
82
105
|
],
|
|
83
106
|
},
|
|
84
107
|
upgrade: {
|
|
85
|
-
|
|
108
|
+
takes: [],
|
|
86
109
|
blurb: 'fetch the latest version',
|
|
87
110
|
options: [],
|
|
88
111
|
},
|
|
@@ -94,26 +117,85 @@ const ALIASES = { exec: 'execute', run: 'execute' };
|
|
|
94
117
|
/** The command this argument names, aliases resolved, or null. */
|
|
95
118
|
const commandNamed = (arg) => (COMMANDS[arg] ? arg : ALIASES[arg] ?? null);
|
|
96
119
|
|
|
97
|
-
|
|
120
|
+
/**
|
|
121
|
+
* What works anywhere, and one line saying what --help has just done.
|
|
122
|
+
*
|
|
123
|
+
* The flag is contextual and that line was not: `-h, --help this` was written
|
|
124
|
+
* before a command could be asked about itself and nobody revisited it, so the
|
|
125
|
+
* summary sat there promising the summary (869ery5hj). Each screen says which
|
|
126
|
+
* of the two it is.
|
|
127
|
+
*/
|
|
128
|
+
const anywhere = (help) => `Anywhere
|
|
98
129
|
--check-update ask npm whether a newer one exists, and say so either way
|
|
99
130
|
--version version, engine and copyright
|
|
100
|
-
-h, --help
|
|
131
|
+
-h, --help ${help}
|
|
101
132
|
`;
|
|
102
133
|
|
|
103
|
-
/**
|
|
134
|
+
/**
|
|
135
|
+
* One command, one line — the summary's unit.
|
|
136
|
+
*
|
|
137
|
+
* Everything but the name and the blurb belongs to the command's own help, where
|
|
138
|
+
* the line is the one you would actually type rather than an entry in a list.
|
|
139
|
+
* This screen answers WHICH COMMAND; that one answers HOW TO CALL IT.
|
|
140
|
+
*/
|
|
141
|
+
/**
|
|
142
|
+
* How the command is called: options before operands, as POSIX has it and as
|
|
143
|
+
* every tool a reader has already met prints it.
|
|
144
|
+
*
|
|
145
|
+
* `[<options>]` IS BRACKETED AND `<file(s)>` IS NOT, which is the same convention
|
|
146
|
+
* saying the two are not alike: brackets mean you may leave it out, and every
|
|
147
|
+
* command here works with no options and none works with no file.
|
|
148
|
+
*
|
|
149
|
+
* The rows below the line stay operand-first, because that row explains the
|
|
150
|
+
* placeholder in the line above and is no use to anyone underneath five switches.
|
|
151
|
+
*/
|
|
152
|
+
const called = (name) => {
|
|
153
|
+
const { takes, options } = COMMANDS[name];
|
|
154
|
+
return [name, options.length ? '[<options>]' : '', ...takes.map(([operand]) => operand)]
|
|
155
|
+
.filter(Boolean)
|
|
156
|
+
.join(' ');
|
|
157
|
+
};
|
|
158
|
+
|
|
159
|
+
function commandLine(name) {
|
|
160
|
+
// THE NAME AND WHAT IT DOES, AND NOTHING ELSE. A reader on this screen is
|
|
161
|
+
// choosing a command, and every one of them takes a file — so the operand told
|
|
162
|
+
// them nothing about the choice while making five lines wider than the answer
|
|
163
|
+
// they came for.
|
|
164
|
+
return ` ${name.padEnd(11)}${COMMANDS[name].blurb}`;
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
/**
|
|
168
|
+
* One command, everything it takes, and nothing another command takes.
|
|
169
|
+
*
|
|
170
|
+
* Operands and options are laid out the same way because they are the same kind
|
|
171
|
+
* of fact — what may follow the command — and a reader who has to learn two
|
|
172
|
+
* shapes to read one screen is being charged for our tidiness.
|
|
173
|
+
*/
|
|
104
174
|
function commandHelp(name) {
|
|
105
|
-
const {
|
|
106
|
-
return [` ${
|
|
107
|
-
.concat(options.map(([
|
|
175
|
+
const { takes, blurb, options } = COMMANDS[name];
|
|
176
|
+
return [` prolog-notebook ${called(name).padEnd(32)}${blurb}`]
|
|
177
|
+
.concat([...takes, ...options].map(([what, why]) => ` ${what.padEnd(16)}${why}`))
|
|
108
178
|
.join('\n');
|
|
109
179
|
}
|
|
110
180
|
|
|
181
|
+
/**
|
|
182
|
+
* THE SUMMARY NAMES THE COMMANDS AND NOTHING ELSE (869ery5hj).
|
|
183
|
+
*
|
|
184
|
+
* The Captain, on running the tool bare: "this is not great, why do we have
|
|
185
|
+
* command level help then." It printed every option of every command, so the tier
|
|
186
|
+
* below it earned nothing and the first screen a new reader met was the longest
|
|
187
|
+
* one in the tool. What is left is the list, the three flags that do work
|
|
188
|
+
* anywhere, and where to ask for more.
|
|
189
|
+
*
|
|
190
|
+
* The execute note goes with them. It explains --limit, and the comment on it in
|
|
191
|
+
* COMMANDS says it travels wherever --limit goes and nowhere else — a rule this
|
|
192
|
+
* screen was breaking.
|
|
193
|
+
*/
|
|
111
194
|
const USAGE = `prolog-notebook — Jupyter-style notebooks for Prolog
|
|
112
195
|
|
|
113
|
-
${Object.keys(COMMANDS).map(
|
|
196
|
+
${Object.keys(COMMANDS).map(commandLine).join('\n')}
|
|
114
197
|
|
|
115
|
-
${
|
|
116
|
-
${COMMANDS.execute.note}`;
|
|
198
|
+
${anywhere("this, or one command's: prolog-notebook build --help")}`;
|
|
117
199
|
|
|
118
200
|
/**
|
|
119
201
|
* JUST THE COMMAND ASKED ABOUT (869erqra0).
|
|
@@ -127,7 +209,7 @@ ${COMMANDS.execute.note}`;
|
|
|
127
209
|
*/
|
|
128
210
|
function helpFor(name) {
|
|
129
211
|
const { note } = COMMANDS[name];
|
|
130
|
-
return `${commandHelp(name)}\n\n${
|
|
212
|
+
return `${commandHelp(name)}\n\n${anywhere('this')}${note ? `\n${note}` : ''}`;
|
|
131
213
|
}
|
|
132
214
|
|
|
133
215
|
/**
|
|
@@ -490,6 +572,7 @@ async function page(command, args, asked = false) {
|
|
|
490
572
|
return 2;
|
|
491
573
|
}
|
|
492
574
|
if (arg === '--out') options.out = args.shift();
|
|
575
|
+
else if (arg === '--here') options.here = true;
|
|
493
576
|
else if (arg === '--port') {
|
|
494
577
|
options.port = Number(args.shift());
|
|
495
578
|
if (!Number.isInteger(options.port) || options.port < 0 || options.port > 65535) {
|
|
@@ -522,6 +605,9 @@ async function page(command, args, asked = false) {
|
|
|
522
605
|
// when the server started.
|
|
523
606
|
const pages = livePages(() => readFileSync(file, 'utf8'), {
|
|
524
607
|
filename: basename(file),
|
|
608
|
+
// A built page is one page of a site and reaches the shared runtime with
|
|
609
|
+
// `../`; `view` serves a single page at the root, where it is `./`.
|
|
610
|
+
prefix: command === 'build' ? '../' : './',
|
|
525
611
|
onError: (e) => process.stderr.write(`${file}: ${e.message}\n`),
|
|
526
612
|
});
|
|
527
613
|
let built;
|
|
@@ -533,20 +619,112 @@ async function page(command, args, asked = false) {
|
|
|
533
619
|
}
|
|
534
620
|
|
|
535
621
|
if (command === 'build') {
|
|
536
|
-
|
|
622
|
+
// THREE WAYS TO SAY WHERE, and only the first is a decision the author has to
|
|
623
|
+
// make twice: --out is the explicit one, --here is beside the notebook, and
|
|
624
|
+
// the default is the site this project already has (869ery5e8).
|
|
625
|
+
const site = options.out ? resolve(options.out)
|
|
626
|
+
: options.here ? join(dirname(resolve(file)), SITE)
|
|
627
|
+
: findSite(file);
|
|
628
|
+
const existed = existsSync(site);
|
|
629
|
+
const page = pageName(file);
|
|
630
|
+
|
|
631
|
+
// WHAT WROTE THIS SITE, AND WHAT IS WRITING NOW (869erqwkp). A site has
|
|
632
|
+
// exactly one runtime, so this decides whether the shared files are already
|
|
633
|
+
// the right ones, need replacing, or are newer than us.
|
|
634
|
+
const state = reconcile(site);
|
|
635
|
+
if (state.verdict === 'older') {
|
|
636
|
+
process.stderr.write(`${shownAs(site)} was built by prolog-notebook `
|
|
637
|
+
+ `${state.have.runtime ?? '?'} with engine ${state.have.engine ?? '?'};`
|
|
638
|
+
+ ` you are running ${state.ours.runtime} with ${state.ours.engine}.\n`
|
|
639
|
+
// Overwriting would downgrade every page in the site, none of which the
|
|
640
|
+
// author named. Refusing is the only move that breaks nothing.
|
|
641
|
+
+ 'Run `prolog-notebook upgrade`, or build somewhere else with --out.\n');
|
|
642
|
+
return 1;
|
|
643
|
+
}
|
|
644
|
+
// The ordinary loop writes 52 KB: the page, and nothing else.
|
|
645
|
+
const writeShared = state.verdict !== 'same';
|
|
646
|
+
|
|
647
|
+
let own = 0;
|
|
648
|
+
let shared = 0;
|
|
537
649
|
for (const [name, entry] of built) {
|
|
538
|
-
|
|
650
|
+
// The runtime, the engine and the stylesheet are the site's; the page is
|
|
651
|
+
// the page's. One copy each, however many chapters.
|
|
652
|
+
if (isShared(name) && !writeShared) continue;
|
|
653
|
+
const target = isShared(name) ? join(site, name) : join(site, page, name);
|
|
654
|
+
if (isShared(name)) shared += 1; else own += 1;
|
|
539
655
|
mkdirSync(dirname(target), { recursive: true });
|
|
540
656
|
if (entry.text !== undefined) writeFileSync(target, entry.text);
|
|
541
657
|
else copyFileSync(entry.copy, target);
|
|
542
658
|
}
|
|
543
|
-
|
|
659
|
+
|
|
660
|
+
// EVERY OTHER PAGE COMES WITH US. A page generated by an older tool imports
|
|
661
|
+
// symbols from a lib/ that has just moved under it, so leaving it alone is
|
|
662
|
+
// not the cautious option — it is the one that breaks it. Each page holds the
|
|
663
|
+
// chapter it was built from, so the site can rebuild itself.
|
|
664
|
+
const regenerated = [];
|
|
665
|
+
const stranded = [];
|
|
666
|
+
if (state.verdict === 'newer') {
|
|
667
|
+
for (const other of pagesIn(site)) {
|
|
668
|
+
if (other.name === page) continue;
|
|
669
|
+
const chapter = sourceOf(site, other.name);
|
|
670
|
+
if (!chapter) { stranded.push(other.name); continue; }
|
|
671
|
+
const files = buildFiles(parse(chapter.source), chapter.source, {
|
|
672
|
+
filename: chapter.filename, prefix: '../',
|
|
673
|
+
});
|
|
674
|
+
for (const [name, entry] of files) {
|
|
675
|
+
if (isShared(name)) continue;
|
|
676
|
+
const target = join(site, other.name, name);
|
|
677
|
+
mkdirSync(dirname(target), { recursive: true });
|
|
678
|
+
if (entry.text !== undefined) writeFileSync(target, entry.text);
|
|
679
|
+
else copyFileSync(entry.copy, target);
|
|
680
|
+
}
|
|
681
|
+
regenerated.push(other.name);
|
|
682
|
+
}
|
|
683
|
+
}
|
|
684
|
+
|
|
685
|
+
// REGENERATED FROM THE DIRECTORY, EVERY TIME. The site is the only thing that
|
|
686
|
+
// knows what the site contains — builds happen one chapter at a time, from
|
|
687
|
+
// different folders, days apart (869erptbr).
|
|
688
|
+
const listed = pagesIn(site);
|
|
689
|
+
writeFileSync(join(site, 'index.html'), indexHtml(listed));
|
|
690
|
+
|
|
691
|
+
// SAY WHERE IT WENT. This is the one command that writes outside the
|
|
692
|
+
// directory it was pointed at, and doing that in silence is spooky.
|
|
693
|
+
if (!existed) {
|
|
694
|
+
process.stderr.write(`created ${shownAs(site)}/ — you may want it in .gitignore\n`);
|
|
695
|
+
}
|
|
696
|
+
// COUNTED APART, because they answer different questions: how big is this
|
|
697
|
+
// chapter, and what does the site cost. "13 files" of a two-file page was
|
|
698
|
+
// true of the write and false about the page.
|
|
699
|
+
process.stderr.write(`${own} files → ${shownAs(join(site, page))}/`
|
|
700
|
+
+ (writeShared ? ` (${shared} shared with the site)\n` : ' (runtime and engine already'
|
|
701
|
+
+ ' there)\n'));
|
|
702
|
+
|
|
703
|
+
// SAID OUT LOUD, because a build aimed at one file has just rewritten others.
|
|
704
|
+
// One line naming what moved and how many pages came with it, the way a
|
|
705
|
+
// lockfile update reads.
|
|
706
|
+
if (state.verdict === 'newer' && state.runtimeMoved) {
|
|
707
|
+
process.stderr.write(`runtime ${state.have.runtime ?? 'unknown'} → ${state.ours.runtime}`
|
|
708
|
+
+ ` · ${regenerated.length} page${regenerated.length === 1 ? '' : 's'} regenerated\n`);
|
|
709
|
+
}
|
|
710
|
+
if (state.verdict === 'newer' && state.engineMoved) {
|
|
711
|
+
// What regeneration cannot fix: the pages' code is current, their saved
|
|
712
|
+
// answers came out of the old engine and still live in the author's file.
|
|
713
|
+
process.stderr.write(`engine ${state.have.engine ?? 'unknown'} → ${state.ours.engine}`
|
|
714
|
+
+ ' · re-run `execute` on your chapters\n');
|
|
715
|
+
}
|
|
716
|
+
if (stranded.length) {
|
|
717
|
+
process.stderr.write(`could not regenerate ${stranded.join(', ')} — no chapter beside `
|
|
718
|
+
+ `${stranded.length === 1 ? 'it' : 'them'}. Build from the notebook again.\n`);
|
|
719
|
+
}
|
|
720
|
+
process.stderr.write(`${shownAs(join(site, 'index.html'))} lists `
|
|
721
|
+
+ `${listed.length} notebook${listed.length === 1 ? '' : 's'}\n`);
|
|
544
722
|
// SAID HERE BECAUSE THIS IS WHERE IT IS ACTED ON. The obvious next move is to
|
|
545
723
|
// double-click index.html, and that is the one thing that cannot work:
|
|
546
724
|
// browsers refuse ES modules over file:// and the engine cannot be fetched
|
|
547
725
|
// there either (869erqq1u). The page says so too, but by then somebody is
|
|
548
726
|
// already looking at a chapter whose buttons do nothing.
|
|
549
|
-
process.stderr.write(`Host ${
|
|
727
|
+
process.stderr.write(`Host ${shownAs(site)} over HTTP — opening it from disk`
|
|
550
728
|
+ ' will not run.\n');
|
|
551
729
|
return 0;
|
|
552
730
|
}
|
package/package.json
CHANGED
package/src/browser.js
CHANGED
|
@@ -229,8 +229,16 @@ class WorkerQuery {
|
|
|
229
229
|
async all(limit) {
|
|
230
230
|
if (this.done) return { solutions: [], truncated: false };
|
|
231
231
|
const qid = await this.#open();
|
|
232
|
-
|
|
233
|
-
|
|
232
|
+
try {
|
|
233
|
+
return await this.session.send('all', { qid, limit });
|
|
234
|
+
} finally {
|
|
235
|
+
// AFTER THE WORKER HAS ANSWERED, not before. Releasing the session's one
|
|
236
|
+
// slot up front said "nothing is open here" while the engine was still
|
|
237
|
+
// inside the goal, so anything that opened next nested inside a frame the
|
|
238
|
+
// session had already forgotten (869erqvzu). The slot is the claim that
|
|
239
|
+
// this query is the innermost one, and that stays true until it is done.
|
|
240
|
+
this.#finish();
|
|
241
|
+
}
|
|
234
242
|
}
|
|
235
243
|
|
|
236
244
|
async close({ superseded = false } = {}) {
|
package/src/build-info.json
CHANGED
package/src/build.js
CHANGED
|
@@ -15,9 +15,22 @@
|
|
|
15
15
|
// contain — generated text, or a path to copy — so that `build` can write it,
|
|
16
16
|
// `view` can serve it, and a test can read it, without any of the three
|
|
17
17
|
// disagreeing about what a page is.
|
|
18
|
+
import { createRequire } from 'node:module';
|
|
18
19
|
import { parse } from './format.js';
|
|
19
20
|
import { renderNotebook } from './render.js';
|
|
20
21
|
|
|
22
|
+
/**
|
|
23
|
+
* Which swipl-wasm the bundle beside us came from.
|
|
24
|
+
*
|
|
25
|
+
* READ FROM THE DEPENDENCY, never written down twice: a constant we maintain by
|
|
26
|
+
* hand is a constant that is wrong the first time somebody bumps the dependency
|
|
27
|
+
* and forgets. This is the only thing in this file that touches the disk to
|
|
28
|
+
* answer a question, and it is a question about the package rather than about a
|
|
29
|
+
* notebook.
|
|
30
|
+
*/
|
|
31
|
+
const require = createRequire(import.meta.url);
|
|
32
|
+
export const ENGINE_VERSION = require('swipl-wasm/package.json').version;
|
|
33
|
+
|
|
21
34
|
/** The runtime a page needs. Copied side by side, so their relative imports hold. */
|
|
22
35
|
export const RUNTIME = [
|
|
23
36
|
'notebook.js', 'browser.js', 'session.js', 'engine.js', 'worker.js',
|
|
@@ -31,7 +44,7 @@ export const RUNTIME = [
|
|
|
31
44
|
* every single load and this audience opens the console (869ernmxe). An SVG so it
|
|
32
45
|
* scales to whatever size the tab wants.
|
|
33
46
|
*/
|
|
34
|
-
const FAVICON = encodeURIComponent(
|
|
47
|
+
export const FAVICON = encodeURIComponent(
|
|
35
48
|
'<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 32 32">'
|
|
36
49
|
+ '<rect width="32" height="32" rx="7" fill="#faf7f0"/>'
|
|
37
50
|
+ '<text x="16" y="23" font-family="ui-monospace,Menlo,monospace" font-size="19"'
|
|
@@ -41,15 +54,32 @@ const FAVICON = encodeURIComponent(
|
|
|
41
54
|
/** The one engine file: the bundle carries its own data. */
|
|
42
55
|
export const ENGINE = 'swipl-bundle.js';
|
|
43
56
|
|
|
57
|
+
/**
|
|
58
|
+
* WHICH ENGINE IS IN THIS DIRECTORY, written beside it (869erqwkp).
|
|
59
|
+
*
|
|
60
|
+
* The design for this said to read the versions back out of the site rather than
|
|
61
|
+
* write a manifest, and for the runtime that works — lib/version.js is already
|
|
62
|
+
* there and carries ours. THE BUNDLE CANNOT ANSWER FOR ITSELF: it is a megabyte
|
|
63
|
+
* of minified glue around base64 data with no version string in it, and comparing
|
|
64
|
+
* bytes can only say DIFFERENT, never NEWER. So the one fact we cannot recover is
|
|
65
|
+
* recorded, in the smallest place that makes sense: a module beside the artefact
|
|
66
|
+
* it describes, not a manifest at the root describing everything.
|
|
67
|
+
*/
|
|
68
|
+
export const ENGINE_VERSION_FILE = 'swipl/version.js';
|
|
69
|
+
|
|
44
70
|
/**
|
|
45
71
|
* The page, as a map of file name to what belongs there.
|
|
46
72
|
*
|
|
47
73
|
* @param {{frontMatter: Map<string,string>, cells: object[]}} notebook parsed
|
|
48
74
|
* @param {string} source the notebook's own bytes, for the download
|
|
49
|
-
* @param {{filename?: string, src?: URL, engine?: URL}} [options]
|
|
75
|
+
* @param {{filename?: string, src?: URL, engine?: URL, prefix?: string}} [options]
|
|
50
76
|
* `src` is the directory holding the runtime modules and `engine` the
|
|
51
77
|
* directory holding swipl-wasm's bundle — arguments rather than constants so a
|
|
52
78
|
* test can point them anywhere and an installed package can find its own.
|
|
79
|
+
* `prefix` is how the page reaches the shared files: `./` when it is alone in a
|
|
80
|
+
* directory, `../` when it is one page of a site whose runtime and engine live
|
|
81
|
+
* at the root (869ery5e8). It is the ONLY thing that differs between the two,
|
|
82
|
+
* which is why the map's keys do not change.
|
|
53
83
|
* @returns {Map<string, {text: string}|{copy: URL}>}
|
|
54
84
|
*/
|
|
55
85
|
export function buildFiles(notebook, source, options = {}) {
|
|
@@ -57,14 +87,29 @@ export function buildFiles(notebook, source, options = {}) {
|
|
|
57
87
|
filename = 'notebook.prolog.md',
|
|
58
88
|
src = new URL('./', import.meta.url),
|
|
59
89
|
engine = new URL('../node_modules/swipl-wasm/dist/swipl/', import.meta.url),
|
|
90
|
+
prefix = './',
|
|
91
|
+
engineVersion = ENGINE_VERSION,
|
|
60
92
|
} = options;
|
|
61
93
|
|
|
62
94
|
const files = new Map();
|
|
63
|
-
files.set('index.html', { text: page(notebook) });
|
|
64
|
-
files.set('app.js', { text: app(source, filename) });
|
|
95
|
+
files.set('index.html', { text: page(notebook, prefix) });
|
|
96
|
+
files.set('app.js', { text: app(source, filename, prefix) });
|
|
65
97
|
files.set('notebook.css', { copy: new URL('notebook.css', src) });
|
|
66
98
|
for (const module of RUNTIME) files.set(`lib/${module}`, { copy: new URL(module, src) });
|
|
67
99
|
files.set(`swipl/${ENGINE}`, { copy: new URL(ENGINE, engine) });
|
|
100
|
+
files.set(ENGINE_VERSION_FILE, {
|
|
101
|
+
text: '// Generated by prolog-notebook build. Which engine is in this directory.\n'
|
|
102
|
+
+ `export const SWIPL_WASM = ${JSON.stringify(engineVersion)};\n`,
|
|
103
|
+
});
|
|
104
|
+
// THE CHAPTER ITSELF, beside the page it produced.
|
|
105
|
+
//
|
|
106
|
+
// It is already inside app.js, because the "as published" download hands back
|
|
107
|
+
// the author's own bytes. As a real file it is also what a rebuild reads to
|
|
108
|
+
// regenerate this page against a newer runtime (869erqwkp) — regexing a source
|
|
109
|
+
// back out of generated JavaScript would work and would be a thing nobody
|
|
110
|
+
// should have to look at. And the markdown sitting on the site next to the page
|
|
111
|
+
// is the whole argument for the format.
|
|
112
|
+
files.set(filename, { text: source });
|
|
68
113
|
return files;
|
|
69
114
|
}
|
|
70
115
|
|
|
@@ -164,7 +209,7 @@ function escapeHtml(text) {
|
|
|
164
209
|
return String(text).replace(/[&<>"]/g, (c) => ({ '&': '&', '<': '<', '>': '>', '"': '"' }[c]));
|
|
165
210
|
}
|
|
166
211
|
|
|
167
|
-
function page(notebook) {
|
|
212
|
+
function page(notebook, prefix) {
|
|
168
213
|
return `<!doctype html>
|
|
169
214
|
<html lang="en">
|
|
170
215
|
<head>
|
|
@@ -172,7 +217,7 @@ function page(notebook) {
|
|
|
172
217
|
<meta name="viewport" content="width=device-width, initial-scale=1">
|
|
173
218
|
<title>${escapeHtml(titleOf(notebook))}</title>
|
|
174
219
|
<link rel="icon" href="data:image/svg+xml,${FAVICON}">
|
|
175
|
-
<link rel="stylesheet" href="notebook.css">
|
|
220
|
+
<link rel="stylesheet" href="${prefix}notebook.css">
|
|
176
221
|
</head>
|
|
177
222
|
<body>
|
|
178
223
|
<main>
|
|
@@ -210,12 +255,12 @@ ${renderNotebook(notebook)}
|
|
|
210
255
|
* mean the bytes the author wrote, not a re-serialisation of the model — a
|
|
211
256
|
* hand-written chapter would otherwise come back reformatted.
|
|
212
257
|
*/
|
|
213
|
-
function app(source, filename) {
|
|
258
|
+
function app(source, filename, prefix) {
|
|
214
259
|
return `// Generated by prolog-notebook build. The chapter is already in index.html;
|
|
215
260
|
// this only wires it up.
|
|
216
|
-
import { editsOf, mount, offerDownload } from '
|
|
217
|
-
import { parse } from '
|
|
218
|
-
import { exportSource } from '
|
|
261
|
+
import { editsOf, mount, offerDownload } from '${prefix}lib/notebook.js';
|
|
262
|
+
import { parse } from '${prefix}lib/format.js';
|
|
263
|
+
import { exportSource } from '${prefix}lib/export.js';
|
|
219
264
|
|
|
220
265
|
const SOURCE = ${JSON.stringify(source)};
|
|
221
266
|
const FILENAME = ${JSON.stringify(filename)};
|
|
@@ -224,7 +269,7 @@ const root = document.querySelector('main');
|
|
|
224
269
|
// The engine lives beside this file rather than in a node_modules the browser
|
|
225
270
|
// cannot see, so its location is passed rather than guessed.
|
|
226
271
|
const cells = mount(root, {
|
|
227
|
-
swiplUrl: new URL('
|
|
272
|
+
swiplUrl: new URL('${prefix}swipl/${ENGINE}', import.meta.url).href,
|
|
228
273
|
});
|
|
229
274
|
|
|
230
275
|
const notebook = parse(SOURCE);
|
package/src/notebook.js
CHANGED
|
@@ -478,11 +478,18 @@ function mountPageBar(root, options, bus, programs, queries) {
|
|
|
478
478
|
const outputsUnit = bar.querySelector('.unit.outputs');
|
|
479
479
|
const outputsState = bar.querySelector('.outputs-state');
|
|
480
480
|
let refreshOutputs = () => {};
|
|
481
|
-
//
|
|
482
|
-
//
|
|
483
|
-
//
|
|
484
|
-
//
|
|
485
|
-
|
|
481
|
+
// ANY QUERY CELL AT ALL, not just a chapter that shipped answers.
|
|
482
|
+
//
|
|
483
|
+
// This used to go when the FILE had none, on the argument that an unrun
|
|
484
|
+
// chapter is the CLI's business. That argument was wrong the moment a reader
|
|
485
|
+
// pressed Run: a chapter published without answers — which is most of them
|
|
486
|
+
// while an author is still writing — gave them no way to clear the answers
|
|
487
|
+
// they had just produced, and a panel with the row missing reads as a control
|
|
488
|
+
// that has broken rather than one that had nothing to do (869erqw08).
|
|
489
|
+
//
|
|
490
|
+
// The row above stays keyed to the FILE, and correctly: hiding is for the
|
|
491
|
+
// chapter's saved answers, and a reader's own run is not a spoiler.
|
|
492
|
+
if (!queries.length) {
|
|
486
493
|
outputsUnit.remove();
|
|
487
494
|
} else {
|
|
488
495
|
const wipe = document.createElement('button');
|
|
@@ -503,12 +510,14 @@ function mountPageBar(root, options, bus, programs, queries) {
|
|
|
503
510
|
// to give back, so a page of only those leaves restore with no work.
|
|
504
511
|
const restorable = gone.filter((q) => q.hasSaved).length;
|
|
505
512
|
wipe.disabled = back ? restorable === 0 : left.length === 0;
|
|
506
|
-
// COUNTED FROM THE
|
|
507
|
-
//
|
|
508
|
-
//
|
|
509
|
-
outputsState.textContent = gone.length
|
|
510
|
-
? `${plural(
|
|
511
|
-
:
|
|
513
|
+
// COUNTED FROM THE PAGE, which is the only count that stays true for both
|
|
514
|
+
// kinds of chapter: one published with its answers in it, and one still
|
|
515
|
+
// being written where every output on screen is the reader's own.
|
|
516
|
+
outputsState.textContent = gone.length > 0
|
|
517
|
+
? `${plural(gone.length, 'output')} cleared`
|
|
518
|
+
: left.length > 0
|
|
519
|
+
? `${plural(left.length, 'output')} on this page`
|
|
520
|
+
: 'No outputs yet';
|
|
512
521
|
label(wipe, back ? 'restore' : 'erase',
|
|
513
522
|
back ? 'Restore outputs' : 'Clear all outputs');
|
|
514
523
|
wipe.title = back
|
|
@@ -667,12 +676,22 @@ async function boot(options, bus, status) {
|
|
|
667
676
|
bus.booted = true;
|
|
668
677
|
if (!wasBooted) {
|
|
669
678
|
bus.emit({ kind: 'started', at: clock() });
|
|
670
|
-
//
|
|
671
|
-
//
|
|
672
|
-
//
|
|
673
|
-
|
|
674
|
-
|
|
675
|
-
|
|
679
|
+
// AWAITED, AND THAT IS A CORRECTNESS MATTER RATHER THAN A PREFERENCE.
|
|
680
|
+
//
|
|
681
|
+
// This was fired and forgotten, on the argument that the light going green
|
|
682
|
+
// answers "did it start" and a version number should not hold it back. But
|
|
683
|
+
// asking for the version IS A QUERY, and one engine allows ONE OPEN QUERY
|
|
684
|
+
// (869epzqpc). Firing it alongside the reader's own Run put two opens into
|
|
685
|
+
// the worker's queue, and whichever arrived second nested inside the first —
|
|
686
|
+
// so the cell that started the engine got `Attempt to access not innermost
|
|
687
|
+
// query` while the panel cheerfully displayed the version that had won the
|
|
688
|
+
// race (869erqvzu).
|
|
689
|
+
//
|
|
690
|
+
// The light still goes green first: the event above is emitted before this
|
|
691
|
+
// waits. What it now costs is one flag lookup on the first boot only, and
|
|
692
|
+
// what it buys is that no cell query can ever be in flight beside it.
|
|
693
|
+
const version = await prologVersion(session).catch(() => null);
|
|
694
|
+
if (version) bus.emit({ kind: 'engine-version', version });
|
|
676
695
|
}
|
|
677
696
|
return session;
|
|
678
697
|
}
|
|
@@ -1227,6 +1246,20 @@ function mountQuery(cell, options, bus, { above = [], below = [], prediction = n
|
|
|
1227
1246
|
: whose, 'from');
|
|
1228
1247
|
write(`?- ${goal}.`, 'echo');
|
|
1229
1248
|
setRunning(true);
|
|
1249
|
+
// THE ENGINE IS CLAIMED HERE, BEFORE THE CONSULTS — not after the query is
|
|
1250
|
+
// opened, which is where this used to be (869erqvzu).
|
|
1251
|
+
//
|
|
1252
|
+
// Loading the cells above emits `consulted`, and that is exactly what an
|
|
1253
|
+
// auto cell waits for. Its re-run is queued and runs on the next turn, while
|
|
1254
|
+
// this run is still somewhere inside its awaits — so it looked at a page
|
|
1255
|
+
// where nobody was stepping, saw a free engine, and opened its own query
|
|
1256
|
+
// underneath the one this cell was about to open. The reader, who had
|
|
1257
|
+
// pressed Run on one cell and touched nothing else, got `Attempt to access
|
|
1258
|
+
// not innermost query`.
|
|
1259
|
+
//
|
|
1260
|
+
// A cell is using the engine from the moment it starts, not from the moment
|
|
1261
|
+
// it succeeds. finish() lets go, on every path out of here.
|
|
1262
|
+
bus.stepping.add(cell);
|
|
1230
1263
|
try {
|
|
1231
1264
|
if (!bus.booted) write('starting SWI-Prolog (5.9 MB, first time only)…', 'done');
|
|
1232
1265
|
session = await boot(options, bus);
|
|
@@ -1243,7 +1276,6 @@ function mountQuery(cell, options, bus, { above = [], below = [], prediction = n
|
|
|
1243
1276
|
return;
|
|
1244
1277
|
}
|
|
1245
1278
|
query = session.query(goal);
|
|
1246
|
-
bus.stepping.add(cell);
|
|
1247
1279
|
// A sequence ends when another one starts — one engine, one open query
|
|
1248
1280
|
// (869epzqpc). The reader hears it here, in the cell it happened to, with
|
|
1249
1281
|
// the solutions they did take still under it and Run still lit. Silence was
|
package/src/site.js
ADDED
|
@@ -0,0 +1,259 @@
|
|
|
1
|
+
// One site, however many notebooks, and it finds itself (869ery5e8).
|
|
2
|
+
//
|
|
3
|
+
// `build` used to write beside the notebook it was given: lists.prolog.md became
|
|
4
|
+
// notebooks/lists-site/. Twenty chapters gave you twenty orphan sites, each with
|
|
5
|
+
// its own copy of the runtime, and nowhere for a table of contents to live —
|
|
6
|
+
// there was no "the site" for one to be a table of contents OF.
|
|
7
|
+
//
|
|
8
|
+
// So the destination is a property of the PROJECT rather than of the file, and
|
|
9
|
+
// the second chapter lands beside the first without being told to.
|
|
10
|
+
//
|
|
11
|
+
// NOTHING HERE WRITES ANYTHING, for the same reason build.js does not: this
|
|
12
|
+
// decides names and produces text, and the command does the I/O.
|
|
13
|
+
import { existsSync, readFileSync, readdirSync, statSync } from 'node:fs';
|
|
14
|
+
import { basename, dirname, join, relative, resolve } from 'node:path';
|
|
15
|
+
import { ENGINE_VERSION, ENGINE_VERSION_FILE, FAVICON } from './build.js';
|
|
16
|
+
import { VERSION } from './version.js';
|
|
17
|
+
|
|
18
|
+
/** The one name, wherever it lands. */
|
|
19
|
+
export const SITE = 'prolog-notebook-site';
|
|
20
|
+
|
|
21
|
+
/**
|
|
22
|
+
* WHERE THE SITE IS, from the notebook being built.
|
|
23
|
+
*
|
|
24
|
+
* Walk up and take the first hit, in this order:
|
|
25
|
+
*
|
|
26
|
+
* 1. an existing prolog-notebook-site/ — somebody has already decided
|
|
27
|
+
* 2. a .git/ — the project's own idea of where it begins
|
|
28
|
+
* 3. the working directory — nothing to go on, so do not go looking
|
|
29
|
+
*
|
|
30
|
+
* CLUE 1 IS THE ONE THAT MATTERS and it is deliberately first: it means chapter
|
|
31
|
+
* two, built from a different subfolder, joins chapter one's site rather than
|
|
32
|
+
* starting a second one next to it. That is what makes an index possible at all,
|
|
33
|
+
* and it is the precondition for a shared engine (869erqwkp).
|
|
34
|
+
*
|
|
35
|
+
* Clue 2 answers the first build, when clue 1 cannot exist yet. Both are looked
|
|
36
|
+
* for on every step of the walk rather than one pass each, so a notebook inside a
|
|
37
|
+
* submodule finds its own project rather than the one containing it.
|
|
38
|
+
*
|
|
39
|
+
* @param {string} from a notebook's path
|
|
40
|
+
* @param {string} [stop] where to give up — the working directory
|
|
41
|
+
* @returns {string} the site directory, which may not exist yet
|
|
42
|
+
*/
|
|
43
|
+
export function findSite(from, stop = process.cwd()) {
|
|
44
|
+
let dir = resolve(dirname(from));
|
|
45
|
+
const root = resolve(dir).split(/[\\/]/)[0] || '/';
|
|
46
|
+
for (;;) {
|
|
47
|
+
if (existsSync(join(dir, SITE))) return join(dir, SITE);
|
|
48
|
+
if (existsSync(join(dir, '.git'))) return join(dir, SITE);
|
|
49
|
+
const up = dirname(dir);
|
|
50
|
+
if (up === dir || dir === root) return join(resolve(stop), SITE);
|
|
51
|
+
dir = up;
|
|
52
|
+
}
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
/**
|
|
56
|
+
* The notebook's own directory inside the site.
|
|
57
|
+
*
|
|
58
|
+
* The name a reader sees in the URL, so it comes from the file rather than from
|
|
59
|
+
* the chapter's title: an author who renames their H1 has not asked for every
|
|
60
|
+
* link to their page to break.
|
|
61
|
+
*/
|
|
62
|
+
export function pageName(file) {
|
|
63
|
+
return basename(file).replace(/\.prolog\.md$/, '').replace(/\.md$/, '') || 'notebook';
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
/**
|
|
67
|
+
* Files that belong to the SITE rather than to one page.
|
|
68
|
+
*
|
|
69
|
+
* The runtime, the engine and the stylesheet are identical for every chapter, so
|
|
70
|
+
* they are written once at the site root and every page reaches them with `../`.
|
|
71
|
+
* A six-chapter site was six copies of a 6.2 MB engine.
|
|
72
|
+
*/
|
|
73
|
+
export function isShared(name) {
|
|
74
|
+
return name.startsWith('lib/') || name.startsWith('swipl/') || name === 'notebook.css';
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
/**
|
|
78
|
+
* WHAT WROTE THIS SITE — the two keys a rebuild has to compare (869erqwkp).
|
|
79
|
+
*
|
|
80
|
+
* prolog-notebook decides lib/*.js, notebook.css and how a page is generated
|
|
81
|
+
* swipl-wasm decides swipl-bundle.js, the bytes we copy
|
|
82
|
+
*
|
|
83
|
+
* SWI-Prolog's own version is a property of swipl-wasm rather than a third axis,
|
|
84
|
+
* and it is not recorded: getting it means booting the engine, which is a second
|
|
85
|
+
* of every build spent on a label. It belongs on the output block that the answers
|
|
86
|
+
* came from, which is a different ticket and the place a reader would look.
|
|
87
|
+
*
|
|
88
|
+
* Both are read back out of the site rather than kept in a manifest at the root.
|
|
89
|
+
* A directory that has been half-deleted then reports what it actually has.
|
|
90
|
+
*
|
|
91
|
+
* @returns {{runtime: string|null, engine: string|null}} null where the site is silent
|
|
92
|
+
*/
|
|
93
|
+
export function siteVersions(dir) {
|
|
94
|
+
return {
|
|
95
|
+
runtime: constIn(join(dir, 'lib/version.js'), /VERSION = '([^']+)'/),
|
|
96
|
+
engine: constIn(join(dir, ENGINE_VERSION_FILE), /SWIPL_WASM = "([^"]+)"/),
|
|
97
|
+
};
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
function constIn(file, pattern) {
|
|
101
|
+
if (!existsSync(file)) return null;
|
|
102
|
+
const found = pattern.exec(readFileSync(file, 'utf8'));
|
|
103
|
+
return found ? found[1] : null;
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
/** -1, 0 or 1. Numeric where both sides are numeric, which ours and swipl-wasm's are. */
|
|
107
|
+
export function compareVersions(a, b) {
|
|
108
|
+
const parts = (v) => String(v).split('.').map((n) => Number.parseInt(n, 10) || 0);
|
|
109
|
+
const [x, y] = [parts(a), parts(b)];
|
|
110
|
+
for (let i = 0; i < Math.max(x.length, y.length); i += 1) {
|
|
111
|
+
if ((x[i] ?? 0) !== (y[i] ?? 0)) return (x[i] ?? 0) < (y[i] ?? 0) ? -1 : 1;
|
|
112
|
+
}
|
|
113
|
+
return 0;
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
/**
|
|
117
|
+
* A SITE HAS EXACTLY ONE RUNTIME: whichever tool last touched it (869erqwkp).
|
|
118
|
+
*
|
|
119
|
+
* The contract between a page's app.js and lib/ is NOT stable — `offerDownload`
|
|
120
|
+
* gained arguments in #28 and `editsOf` moved modules in #40 — so a site holding
|
|
121
|
+
* two generations of page has no safe resting state. Overwrite lib/ and the older
|
|
122
|
+
* page imports a symbol that has moved; leave it and the page just built is the
|
|
123
|
+
* broken one. There is no third option in which everything works, which is why
|
|
124
|
+
* this reconciles rather than warns.
|
|
125
|
+
*
|
|
126
|
+
* BUILD IS SCOPED IN THE PAGES IT ADDS, NOT IN THE CONSISTENCY IT GUARANTEES.
|
|
127
|
+
* Ordinarily that is one page and nothing else moves. The moment a key differs the
|
|
128
|
+
* scope widens to the whole site, because that is the only state in which nothing
|
|
129
|
+
* is broken.
|
|
130
|
+
*
|
|
131
|
+
* @returns {{verdict: 'fresh'|'same'|'newer'|'older', have: object, ours: object}}
|
|
132
|
+
* fresh nothing there yet — write everything
|
|
133
|
+
* same write the page; the runtime and engine are already the right ones
|
|
134
|
+
* newer overwrite the shared files and regenerate every page
|
|
135
|
+
* older refuse: a silent downgrade of pages the author did not name
|
|
136
|
+
*/
|
|
137
|
+
export function reconcile(dir, ours = { runtime: VERSION, engine: ENGINE_VERSION }) {
|
|
138
|
+
const have = siteVersions(dir);
|
|
139
|
+
if (!have.runtime && !have.engine) return { verdict: 'fresh', have, ours };
|
|
140
|
+
const runtime = compareVersions(ours.runtime, have.runtime ?? '0');
|
|
141
|
+
const engine = compareVersions(ours.engine, have.engine ?? '0');
|
|
142
|
+
if (runtime < 0 || engine < 0) return { verdict: 'older', have, ours };
|
|
143
|
+
if (runtime === 0 && engine === 0) return { verdict: 'same', have, ours };
|
|
144
|
+
return {
|
|
145
|
+
verdict: 'newer', have, ours, runtimeMoved: runtime > 0, engineMoved: engine > 0,
|
|
146
|
+
};
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
/**
|
|
150
|
+
* A built page's own chapter, for regenerating it against a newer runtime.
|
|
151
|
+
*
|
|
152
|
+
* The page directory holds the .prolog.md it was built from, so a site can be
|
|
153
|
+
* rebuilt with no source tree, no repository and no manifest — it describes
|
|
154
|
+
* itself. A page with no source file was not written by a version that emitted
|
|
155
|
+
* one, and says so by returning null rather than by being silently skipped.
|
|
156
|
+
*
|
|
157
|
+
* @returns {{filename: string, source: string}|null}
|
|
158
|
+
*/
|
|
159
|
+
export function sourceOf(dir, page) {
|
|
160
|
+
const where = join(dir, page);
|
|
161
|
+
if (!existsSync(where)) return null;
|
|
162
|
+
const found = readdirSync(where).find((f) => f.endsWith('.md'));
|
|
163
|
+
return found ? { filename: found, source: readFileSync(join(where, found), 'utf8') } : null;
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
/**
|
|
167
|
+
* What the site already contains, newest build included.
|
|
168
|
+
*
|
|
169
|
+
* READ BACK OFF DISK, NOT REMEMBERED. `build` is called once per chapter, often
|
|
170
|
+
* from different directories and days apart, so the only thing that knows the
|
|
171
|
+
* whole site is the site. Each page's own <title> is the answer — it is written
|
|
172
|
+
* from the chapter's H1 at build time, so there is no manifest to keep in step
|
|
173
|
+
* and no second place for a title to be wrong.
|
|
174
|
+
*
|
|
175
|
+
* A directory with no index.html is not a page and is left alone: the site is
|
|
176
|
+
* somebody's directory and may hold things we did not put there.
|
|
177
|
+
*
|
|
178
|
+
* @returns {{name: string, title: string}[]} alphabetical by directory name
|
|
179
|
+
*/
|
|
180
|
+
export function pagesIn(dir) {
|
|
181
|
+
if (!existsSync(dir)) return [];
|
|
182
|
+
const pages = [];
|
|
183
|
+
for (const name of readdirSync(dir).sort()) {
|
|
184
|
+
const index = join(dir, name, 'index.html');
|
|
185
|
+
if (name === 'lib' || name === 'swipl') continue;
|
|
186
|
+
if (!existsSync(index) || !statSync(join(dir, name)).isDirectory()) continue;
|
|
187
|
+
const html = readFileSync(index, 'utf8');
|
|
188
|
+
const title = /<title>([^<]*)<\/title>/.exec(html);
|
|
189
|
+
pages.push({ name, title: title ? unescapeHtml(title[1]) : name });
|
|
190
|
+
}
|
|
191
|
+
return pages;
|
|
192
|
+
}
|
|
193
|
+
|
|
194
|
+
/**
|
|
195
|
+
* The site's front page, rewritten on every build (869erptbr).
|
|
196
|
+
*
|
|
197
|
+
* ORDER IS THE SITE'S BUSINESS, NOT THE NOTEBOOK'S. A chapter never states its
|
|
198
|
+
* own position — that is the rule the whole format is built on (binding.md) — so
|
|
199
|
+
* an index is an opinion held by the directory, and alphabetical is the honest
|
|
200
|
+
* placeholder until there is somewhere for a real order to live.
|
|
201
|
+
*
|
|
202
|
+
* It borrows the chapter stylesheet rather than carrying its own, so it inherits
|
|
203
|
+
* the palette, the dark mode and the typography, and cannot drift from the pages
|
|
204
|
+
* it lists.
|
|
205
|
+
*/
|
|
206
|
+
export function indexHtml(pages, { title = 'Prolog notebooks' } = {}) {
|
|
207
|
+
const items = pages.length
|
|
208
|
+
? pages.map((p) => `<li><a href="${encodeURIComponent(p.name)}/">${escapeHtml(p.title)}</a></li>`)
|
|
209
|
+
.join('\n')
|
|
210
|
+
: '<li class="empty">No notebooks here yet.</li>';
|
|
211
|
+
return `<!doctype html>
|
|
212
|
+
<html lang="en">
|
|
213
|
+
<head>
|
|
214
|
+
<meta charset="utf-8">
|
|
215
|
+
<meta name="viewport" content="width=device-width, initial-scale=1">
|
|
216
|
+
<title>${escapeHtml(title)}</title>
|
|
217
|
+
<link rel="icon" href="data:image/svg+xml,${FAVICON}">
|
|
218
|
+
<link rel="stylesheet" href="notebook.css">
|
|
219
|
+
<style>
|
|
220
|
+
.contents { list-style: none; padding: 0; margin: 2.5rem 0 0; }
|
|
221
|
+
.contents li { border-top: 1px solid var(--rule); }
|
|
222
|
+
.contents li:last-child { border-bottom: 1px solid var(--rule); }
|
|
223
|
+
.contents a { display: block; padding: 1rem .2rem; text-decoration: none; color: inherit; }
|
|
224
|
+
.contents a:hover { color: var(--accent); }
|
|
225
|
+
.contents .empty { padding: 1rem .2rem; opacity: .6; font-style: italic; }
|
|
226
|
+
</style>
|
|
227
|
+
</head>
|
|
228
|
+
<body>
|
|
229
|
+
<main>
|
|
230
|
+
<h1>${escapeHtml(title)}</h1>
|
|
231
|
+
<ul class="contents">
|
|
232
|
+
${items}
|
|
233
|
+
</ul>
|
|
234
|
+
</main>
|
|
235
|
+
</body>
|
|
236
|
+
</html>
|
|
237
|
+
`;
|
|
238
|
+
}
|
|
239
|
+
|
|
240
|
+
/**
|
|
241
|
+
* Where the build went, as the reader would type it.
|
|
242
|
+
*
|
|
243
|
+
* The relative form wins whenever it is shorter, INCLUDING when it climbs — the
|
|
244
|
+
* whole point of the default destination is that the site is above the notebook,
|
|
245
|
+
* so `../prolog-notebook-site/lists/` is the normal case and the absolute path is
|
|
246
|
+
* the unreadable one.
|
|
247
|
+
*/
|
|
248
|
+
export function shownAs(target) {
|
|
249
|
+
const here = relative(process.cwd(), target);
|
|
250
|
+
return here && here.length < target.length ? here : target;
|
|
251
|
+
}
|
|
252
|
+
|
|
253
|
+
function escapeHtml(text) {
|
|
254
|
+
return String(text).replace(/[&<>"]/g, (c) => ({ '&': '&', '<': '<', '>': '>', '"': '"' }[c]));
|
|
255
|
+
}
|
|
256
|
+
|
|
257
|
+
function unescapeHtml(text) {
|
|
258
|
+
return String(text).replace(/&(amp|lt|gt|quot);/g, (_, name) => ({ amp: '&', lt: '<', gt: '>', quot: '"' }[name]));
|
|
259
|
+
}
|
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.7.0';
|
|
14
14
|
|
|
15
15
|
/** The two facts a licence notice is actually made of. */
|
|
16
16
|
export const YEAR = '2026';
|