prolog-notebook 0.5.0 → 0.5.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/CHANGELOG.md CHANGED
@@ -1,5 +1,42 @@
1
1
  # Changelog
2
2
 
3
+ ## [0.5.1] — 2026-08-30
4
+
5
+ Everything here came from one field report: a chapter written with the tool rather than a test
6
+ written against it.
7
+
8
+ ### Fixed
9
+
10
+ - **`view` bound quietly behind a squatter on the same port.** A stale
11
+ `python3 -m http.server --bind ::` holds `*:8777` on IPv6; binding `127.0.0.1:8777` on IPv4
12
+ does not collide with it, so `EADDRINUSE` never fired and the auto-bump never ran. `localhost`
13
+ then resolves to `::1` first and the reader gets somebody else's directory listing where a
14
+ chapter should be. The port is now connect-tested on **both stacks** before binding, because
15
+ *can I bind* and *will the reader reach me* are different questions.
16
+
17
+ - **`view` printed its URL on stderr**, where a wrapper never saw it — which is how somebody
18
+ came to type `localhost` by hand. It is the command's output and is on stdout now; `view`
19
+ writes no notebook and no data there, so nothing can be corrupted by it.
20
+
21
+ - **`view` and `build` never looked for an update.** The offer went into the `run` path first
22
+ and stayed there. Every command that does real work now goes through one door, always before
23
+ the work — the only point at which the answer can change the outcome.
24
+
25
+ - **A prediction released the hold on blur, not on typing.** A reader who typed their prediction
26
+ and looked up saw nothing happen, and the link between *I wrote something* and *the answers
27
+ appeared* was broken by a pause with no cause. Debounced `input` at 1.2 s now, with `change`
28
+ kept so leaving the box is still immediate.
29
+
30
+ - **The console was not clean, and this audience opens the console.** A `favicon.ico` 404 on
31
+ every load — now a `data:` URI carrying the notebook's own `?-` prompt — and an issues-panel
32
+ warning for every form field on the page, all of which were anonymous. Every field has an id:
33
+ `src-<cell>`, `goal-<cell>`, `predict-<n>`.
34
+
35
+ - **A missing browser opener took the whole command down.** `spawn` reports that
36
+ asynchronously, so the `try/catch` around it caught nothing and an unhandled `'error'` event
37
+ killed `view` after the server had started. `start` on Windows is a shell builtin and was
38
+ never going to work spawned by name; it goes through `cmd` now.
39
+
3
40
  ## [0.5.0] — 2026-08-30
4
41
 
5
42
  **The CLI can show a notebook.** Until now the only way to see one running was to clone this
@@ -4,7 +4,6 @@
4
4
  // Code "run all" and a future --check get the same behaviour without going
5
5
  // through a shell (869ectt38, 869ectt3e).
6
6
  import { createRequire } from 'node:module';
7
- import { spawn } from 'node:child_process';
8
7
  import { copyFileSync, mkdirSync, readFileSync, writeFileSync } from 'node:fs';
9
8
  import { basename, dirname, join, resolve } from 'node:path';
10
9
  import { parse, NotebookError } from '../src/format.js';
@@ -16,7 +15,7 @@ import { confirm, describeInstall, globalRoot, install, relaunch, upgradePlan }
16
15
  import { exportSource } from '../src/export.js';
17
16
  import { runNotebook, DEFAULT_LIMIT } from '../src/run.js';
18
17
  import { buildFiles } from '../src/build.js';
19
- import { serve } from '../src/serve.js';
18
+ import { openInBrowser, serve } from '../src/serve.js';
20
19
 
21
20
  // The engine is imported WHERE IT IS USED, never at the top. src/node.js pulls in
22
21
  // 5.9 MB of WebAssembly at module scope, so a static import here would mean that
@@ -141,6 +140,38 @@ function canAsk() {
141
140
  return Boolean(process.stdin.isTTY && process.stderr.isTTY);
142
141
  }
143
142
 
143
+ /**
144
+ * The offer, BEFORE the command does anything — which is the only place it can
145
+ * change the outcome. Afterwards the files are written, the server is up, and a
146
+ * newer version has nothing left to do.
147
+ *
148
+ * Every command that does real work goes through here: `run`, `view` and
149
+ * `build`. It went in the run path first and stayed there, so `view` — the
150
+ * command somebody is most likely to leave running — was the one that never
151
+ * looked.
152
+ *
153
+ * It costs a network round trip once a day, not once a run: the rest of the day
154
+ * is a file read.
155
+ *
156
+ * @returns {Promise<number|null>} an exit code when the command has been handed
157
+ * to a newer version, null to carry on here.
158
+ */
159
+ async function upgradeFirst({ quiet = false, asked = false } = {}) {
160
+ if (!canAsk() || (quiet && !asked)) return null;
161
+ const ahead = await updateNotice({ version: VERSION, force: asked })
162
+ .catch(() => ({ message: null, newer: null }));
163
+ if (ahead.message) process.stderr.write(`${ahead.message}\n`);
164
+ if (!ahead.newer || !(await confirm('Update and continue on the new version?'))) return null;
165
+ if ((await upgrade(ahead.newer)) !== 0) {
166
+ process.stderr.write('Carrying on with the version you have.\n');
167
+ return null;
168
+ }
169
+ process.stderr.write('Continuing on the new version.\n');
170
+ // The path has not changed — npm replaced what is behind it — so this is the
171
+ // same command, running the bytes that have just arrived.
172
+ return relaunch(process.argv);
173
+ }
174
+
144
175
  async function offerUpgrade(newer) {
145
176
  if (!canAsk()) {
146
177
  // Nobody to ask, so say what to type instead. `prolog-notebook upgrade`
@@ -220,30 +251,9 @@ async function main(argv) {
220
251
  }
221
252
  if (!options.quiet) process.stderr.write(`${RUNAWAY_WARNING}\n`);
222
253
 
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
- }
254
+ const jump = await upgradeFirst({ quiet: options.quiet, asked });
255
+ if (jump !== null) return jump;
256
+ checked = canAsk() && (!options.quiet || asked);
247
257
 
248
258
  // STARTED NOW, READ AT THE END. The registry is somebody else's machine on
249
259
  // somebody else's network, and none of that should stand between the reader
@@ -312,6 +322,12 @@ async function page(command, args) {
312
322
  return 2;
313
323
  }
314
324
 
325
+ // The same offer the run path makes, and for the same reason: a server about to
326
+ // start, or a directory about to be written, is work that a newer version
327
+ // should be doing.
328
+ const jump = await upgradeFirst();
329
+ if (jump !== null) return jump;
330
+
315
331
  const file = files[0];
316
332
  let built;
317
333
  try {
@@ -336,26 +352,20 @@ async function page(command, args) {
336
352
  }
337
353
 
338
354
  const server = await serve(built, { port: options.port });
339
- process.stderr.write(`${basename(file)} at ${server.url}\n`);
355
+ // THE URL IS THIS COMMAND'S OUTPUT. `view` writes no notebook and no data to
356
+ // stdout, so there is nothing for it to corrupt — and a URL on stderr is a URL
357
+ // a wrapper does not see, which is how somebody came to type localhost by hand
358
+ // and land on another server entirely (869ernmvh).
359
+ process.stdout.write(`${server.url}\n`);
340
360
  if (server.port !== options.port) {
341
- process.stderr.write(`(${options.port} was taken)\n`);
361
+ process.stderr.write(`${options.port} was already answering — using ${server.port} instead.\n`);
342
362
  }
343
- process.stderr.write('Ctrl-C to stop.\n');
363
+ process.stderr.write(`${basename(file)} is at ${server.url} — Ctrl-C to stop.\n`);
344
364
  if (options.open) openInBrowser(server.url);
345
365
  // Deliberately never resolves: the server is the command.
346
366
  return new Promise(() => {});
347
367
  }
348
368
 
349
- /** Hand the URL to whatever the desktop uses. Failure is not worth reporting. */
350
- function openInBrowser(url) {
351
- const opener = { darwin: 'open', win32: 'start' }[process.platform] ?? 'xdg-open';
352
- try {
353
- spawn(opener, [url], { stdio: 'ignore', detached: true }).unref();
354
- } catch {
355
- // No desktop, or no opener: the URL is on screen either way.
356
- }
357
- }
358
-
359
369
  async function runFile(file, session, options) {
360
370
  const name = basename(file);
361
371
  let notebook;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "prolog-notebook",
3
- "version": "0.5.0",
3
+ "version": "0.5.1",
4
4
  "description": "Jupyter-style notebooks for Prolog. Runs in the browser, installs nothing.",
5
5
  "type": "module",
6
6
  "main": "./src/node.js",
@@ -1,4 +1,4 @@
1
1
  {
2
- "commit": "084182f",
3
- "built": "2026-08-30 16:32:07 UTC"
2
+ "commit": "3ed80f2",
3
+ "built": "2026-08-30 17:12:10 UTC"
4
4
  }
package/src/build.js CHANGED
@@ -23,6 +23,20 @@ export const RUNTIME = [
23
23
  'clauses.js', 'export.js', 'format.js', 'version.js',
24
24
  ];
25
25
 
26
+ /**
27
+ * The notebook's own prompt, `?-`, as a tab icon.
28
+ *
29
+ * A data: URI rather than a file, because the alternative is a favicon.ico 404 on
30
+ * every single load and this audience opens the console (869ernmxe). An SVG so it
31
+ * scales to whatever size the tab wants.
32
+ */
33
+ const FAVICON = encodeURIComponent(
34
+ '<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 32 32">'
35
+ + '<rect width="32" height="32" rx="7" fill="#faf7f0"/>'
36
+ + '<text x="16" y="23" font-family="ui-monospace,Menlo,monospace" font-size="19"'
37
+ + ' font-weight="600" fill="#8a3b1e" text-anchor="middle">?-</text></svg>',
38
+ );
39
+
26
40
  /** The one engine file: the bundle carries its own data. */
27
41
  export const ENGINE = 'swipl-bundle.js';
28
42
 
@@ -80,6 +94,7 @@ function page(notebook) {
80
94
  <meta charset="utf-8">
81
95
  <meta name="viewport" content="width=device-width, initial-scale=1">
82
96
  <title>${escapeHtml(titleOf(notebook))}</title>
97
+ <link rel="icon" href="data:image/svg+xml,${FAVICON}">
83
98
  <link rel="stylesheet" href="notebook.css">
84
99
  </head>
85
100
  <body>
package/src/notebook.js CHANGED
@@ -25,6 +25,14 @@ let serial = 0;
25
25
  let panels = 0;
26
26
 
27
27
  /** Absolute, never relative: "3 minutes ago" is wrong the moment it is written. */
28
+ /**
29
+ * How long a reader may stop typing before a prediction counts as written.
30
+ *
31
+ * Long enough that a first keystroke does not reveal the answers, short enough
32
+ * that somebody who has finished sees the consequence of finishing.
33
+ */
34
+ const PREDICTION_PAUSE = 1200;
35
+
28
36
  function clock(date = new Date()) {
29
37
  return date.toLocaleTimeString(undefined, { hour12: false });
30
38
  }
@@ -1294,11 +1302,31 @@ function mountQuery(cell, options, bus, { above = [], below = [], prediction = n
1294
1302
 
1295
1303
  if (held) {
1296
1304
  hidden = true;
1297
- // `change` rather than `input`: it fires when they leave the box, so the
1298
- // answers do not appear under a reader who is still mid-sentence. An empty
1299
- // box is not a prediction, so it does not end the wait.
1300
- prediction?.addEventListener('change', () => {
1305
+ /**
1306
+ * AS THEY WRITE, not when they leave the box.
1307
+ *
1308
+ * This listened on `change` alone, which fires on blur — so a reader who
1309
+ * typed their prediction and looked up saw nothing happen, and the link
1310
+ * between "I wrote something" and "the answers appeared" was broken by a
1311
+ * pause with no cause (869ernmzh). The original reasoning was about the
1312
+ * FIRST KEYSTROKE and it over-corrected: they have committed as soon as they
1313
+ * have written something.
1314
+ *
1315
+ * Debounced, so one character does not reveal the chapter and a reader who is
1316
+ * still typing is not interrupted. `change` stays as well, so leaving the box
1317
+ * is immediate. An empty box is still not a prediction.
1318
+ */
1319
+ const release = () => {
1301
1320
  if (held && prediction.value.trim() !== '') setHidden(false);
1321
+ };
1322
+ let pause = null;
1323
+ prediction?.addEventListener('input', () => {
1324
+ clearTimeout(pause);
1325
+ pause = setTimeout(release, PREDICTION_PAUSE);
1326
+ });
1327
+ prediction?.addEventListener('change', () => {
1328
+ clearTimeout(pause);
1329
+ release();
1302
1330
  });
1303
1331
  }
1304
1332
 
package/src/render.js CHANGED
@@ -85,7 +85,7 @@ export function escapeHtml(text) {
85
85
  * @param {{variant: string, title: string, body: string}} cell
86
86
  * @returns {string}
87
87
  */
88
- export function renderContainer(cell) {
88
+ export function renderContainer(cell, ordinal = 1) {
89
89
  switch (cell.variant) {
90
90
  case 'margin':
91
91
  // The whole note lives in the head line — `> [!margin] text with no body` —
@@ -122,7 +122,7 @@ function joinHeadAndBody(cell) {
122
122
  * markup for a place to answer it. The reveal is a <details> so that it still
123
123
  * works, unclicked, on the GitHub page.
124
124
  */
125
- function renderPredict(cell) {
125
+ function renderPredict(cell, ordinal = 1) {
126
126
  const { before, summary, reveal } = splitReveal(cell.body);
127
127
  const parts = [];
128
128
  if (cell.title) parts.push(`<h3>${renderInline(cell.title)}</h3>`);
@@ -131,7 +131,11 @@ function renderPredict(cell) {
131
131
  // the format has no spelling for a per-prediction one, and inventing an
132
132
  // attribute for it would be a format change to save one line of prose — the
133
133
  // author's own question is directly above it and says what to write.
134
- parts.push('<textarea placeholder="your prediction…" spellcheck="false"></textarea>');
134
+ // An id, because a form field without one is a warning in every browser's
135
+ // issues panel and this audience opens the issues panel (869ernmxe). Minted
136
+ // from position, since a container carries no id in the model — and stable for
137
+ // a given chapter, which is what a saved prediction will need (869ectt5d).
138
+ parts.push(`<textarea id="predict-${ordinal}" placeholder="your prediction…" spellcheck="false"></textarea>`);
135
139
  if (reveal !== null) {
136
140
  parts.push(`<details>\n<summary>${escapeHtml(summary)}</summary>\n${renderProse(reveal)}\n</details>`);
137
141
  }
@@ -177,7 +181,7 @@ export function renderProgram(cell) {
177
181
  <div class="bar">program<span class="spacer"></span><span class="status"></span>
178
182
  <button data-act="reset" disabled>reset</button>
179
183
  <button class="primary" data-act="consult">Consult</button></div>
180
- <textarea spellcheck="false">${escapeHtml(cell.source)}</textarea>
184
+ <textarea id="src-${escapeHtml(cell.id)}" spellcheck="false">${escapeHtml(cell.source)}</textarea>
181
185
  </div>`;
182
186
  }
183
187
 
@@ -217,7 +221,8 @@ export function renderQuery(cell, options = {}) {
217
221
  <button data-act="next" disabled>; next</button>
218
222
  <button data-act="all" disabled>all</button>
219
223
  <button data-act="stop" disabled>stop</button></div>
220
- <div class="prompt"><span>?-</span><input value="${escapeHtml(cell.goal)}" spellcheck="false"></div>
224
+ <div class="prompt"><span>?-</span>`
225
+ + `<input id="goal-${escapeHtml(cell.id)}" value="${escapeHtml(cell.goal)}" spellcheck="false"></div>
221
226
  <div class="out">${renderSavedOutput(cell, { stale })}</div>
222
227
  </div>`;
223
228
  }
@@ -305,7 +310,7 @@ export function renderCell(cell, options = {}) {
305
310
  case 'markdown':
306
311
  return renderProse(cell.source);
307
312
  case 'container':
308
- return renderContainer(cell);
313
+ return renderContainer(cell, options.ordinal);
309
314
  case 'program':
310
315
  return renderProgram(cell);
311
316
  case 'query':
@@ -333,7 +338,9 @@ export function renderNotebook(notebook) {
333
338
  const parts = [];
334
339
  const kicker = renderKicker(notebook.frontMatter);
335
340
  if (kicker) parts.push(kicker);
341
+ let predictions = 0;
336
342
  for (const cell of notebook.cells) {
343
+ if (cell.kind === 'container' && cell.variant === 'predict') predictions += 1;
337
344
  // Staleness is decided here rather than in renderQuery, because it is a fact
338
345
  // about the cell's PLACE in the notebook — the program cells above it — and a
339
346
  // query cell on its own cannot know it. Computed before first paint: a 64-bit
@@ -348,7 +355,7 @@ export function renderNotebook(notebook) {
348
355
  const rerun = cell.kind === 'query'
349
356
  ? cell.rerun ?? notebook.frontMatter.get('rerun') ?? 'manual'
350
357
  : null;
351
- parts.push(renderCell(cell, { stale, rerun }));
358
+ parts.push(renderCell(cell, { stale, rerun, ordinal: predictions }));
352
359
  }
353
360
  return `${parts.join('\n\n')}\n`;
354
361
  }
package/src/serve.js CHANGED
@@ -8,8 +8,10 @@
8
8
  // A server of about forty lines rather than a dependency: it answers GET for a
9
9
  // fixed set of paths that this process generated, and 404s everything else. It
10
10
  // is not a static file server and must not become one.
11
+ import { spawn } from 'node:child_process';
11
12
  import { createReadStream } from 'node:fs';
12
13
  import { createServer } from 'node:http';
14
+ import { connect } from 'node:net';
13
15
 
14
16
  const TYPES = {
15
17
  '.html': 'text/html; charset=utf-8',
@@ -34,6 +36,15 @@ export function contentType(name) {
34
36
  * @returns {Promise<{url: string, port: number, close: () => Promise<void>}>}
35
37
  */
36
38
  export async function serve(files, { port = 8777, host = '127.0.0.1' } = {}) {
39
+ // ASK WHETHER ANYBODY IS THERE, on both stacks, before binding to one of them.
40
+ //
41
+ // An IPv6 wildcard listener — `python3 -m http.server --bind ::` — does not
42
+ // collide with an IPv4 loopback bind, so EADDRINUSE never fires and the bind
43
+ // succeeds. `localhost` then resolves to ::1 first, and the reader gets the
44
+ // other server's directory listing while this one sits unreachable on
45
+ // 127.0.0.1 with nothing anywhere saying why (869ernmvh). Found by somebody
46
+ // authoring their first chapter, which is exactly where it would be found.
47
+ if (port !== 0 && await occupied(port)) port = 0;
37
48
  const server = createServer((request, response) => {
38
49
  // Only GET, and only the names this process generated: the path never
39
50
  // reaches the filesystem, so there is nothing for a `..` to escape into.
@@ -78,3 +89,53 @@ function listen(server, port, host) {
78
89
  server.listen(port, host, () => resolve(server.address().port));
79
90
  });
80
91
  }
92
+
93
+ /**
94
+ * Hand the URL to whatever the desktop uses.
95
+ *
96
+ * TWO WAYS THIS GOES WRONG, both found by reading it rather than running it:
97
+ *
98
+ * - `start` on Windows is a SHELL BUILTIN, not a program, so spawning it by name
99
+ * fails every time. It has to be run through cmd, and the empty string is
100
+ * cmd's title argument — without it, a quoted URL becomes the window title and
101
+ * nothing opens.
102
+ * - spawn reports a missing program ASYNCHRONOUSLY. A try/catch around it catches
103
+ * nothing, and an 'error' event with no listener is an uncaught exception —
104
+ * which took the whole command down, AFTER the server had started, on any
105
+ * machine without an opener. A listener that does nothing is the fix: the URL
106
+ * is on screen either way, and a browser that will not open is not a reason to
107
+ * stop serving.
108
+ */
109
+ export function openInBrowser(url, { spawnImpl = spawn, platform = process.platform } = {}) {
110
+ const argv = platform === 'win32'
111
+ ? ['cmd', ['/c', 'start', '', url]]
112
+ : [platform === 'darwin' ? 'open' : 'xdg-open', [url]];
113
+ const child = spawnImpl(argv[0], argv[1], { stdio: 'ignore', detached: true });
114
+ child.on('error', () => {});
115
+ child.unref?.();
116
+ return child;
117
+ }
118
+
119
+ /**
120
+ * Is something already answering on this port, on either stack?
121
+ *
122
+ * A connect, not a bind: the question is "will the reader reach somebody else
123
+ * here", and a bind can succeed while the answer is yes.
124
+ */
125
+ export async function occupied(port, { hosts = ['127.0.0.1', '::1'], timeout = 300 } = {}) {
126
+ const answers = await Promise.all(hosts.map((host) => reachable(host, port, timeout)));
127
+ return answers.some(Boolean);
128
+ }
129
+
130
+ function reachable(host, port, timeout) {
131
+ return new Promise((resolve) => {
132
+ const socket = connect({ host, port, timeout });
133
+ const done = (answer) => {
134
+ socket.destroy();
135
+ resolve(answer);
136
+ };
137
+ socket.once('connect', () => done(true));
138
+ socket.once('error', () => done(false));
139
+ socket.once('timeout', () => done(false));
140
+ });
141
+ }
package/src/version.js CHANGED
@@ -10,7 +10,7 @@
10
10
  export const NAME = 'Prolog Notebook';
11
11
 
12
12
  /** Must equal package.json's `version` — test/run.test.mjs enforces it. */
13
- export const VERSION = '0.5.0';
13
+ export const VERSION = '0.5.1';
14
14
 
15
15
  /** The two facts a licence notice is actually made of. */
16
16
  export const YEAR = '2026';