prolog-notebook 0.4.3 → 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 CHANGED
@@ -1,5 +1,40 @@
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
+
3
38
  ## [0.4.3] — 2026-08-30
4
39
 
5
40
  ### Added
package/README.md CHANGED
@@ -40,6 +40,27 @@ So a query cell gives you the first solution, and then you step.
40
40
 
41
41
  ## Try it
42
42
 
43
+ ```sh
44
+ npm i -g prolog-notebook
45
+ prolog-notebook view notebooks/ch04-cut.prolog.md
46
+ ```
47
+
48
+ That is a chapter — the `once/1` placement puzzle, a real worked section rather than a widget
49
+ demo. It opens in your browser with the cells live: press Run, then `; next`. Nothing is
50
+ installed but the command, and the chapter is readable before the engine arrives.
51
+
52
+ To send it to somebody, or host it:
53
+
54
+ ```sh
55
+ prolog-notebook build ch04-cut.prolog.md --out site/
56
+ ```
57
+
58
+ A plain directory: prerendered HTML with the saved answers in it, the runtime beside it, and
59
+ the 6.2 MB engine that is fetched only when a reader presses Run. No bundler, no build step of
60
+ your own, nothing to configure.
61
+
62
+ ### Or from a checkout
63
+
43
64
  ```sh
44
65
  git clone https://github.com/jarecsni/prolog-notebook
45
66
  cd prolog-notebook
@@ -59,7 +80,7 @@ are all in it, and there is no HTML anywhere. Point the viewer at any other note
59
80
  | | |
60
81
  |---|---|
61
82
  | `notebooks/` | chapters. The product. |
62
- | `viewer/` | one shell page that renders any of them. Replaced by `build` in v0.3. |
83
+ | `viewer/` | the development shell, for working on the renderer itself. `view` and `build` are what everyone else uses. |
63
84
 
64
85
  The chapter also reads on the repo page, [as a file](notebooks/ch04-cut.prolog.md), with no
65
86
  build step and no site: prose as prose, Prolog syntax-highlighted, the saved answers in place,
@@ -247,8 +268,8 @@ Working and tested:
247
268
  - program cells and query cells with `Run` / `; next` / `all` / `stop`, per-cell reset, and a
248
269
  page that says what the engine is holding
249
270
  - `hold` and `rerun="auto"` — the author decides what a reader may see and when it refreshes
250
- - **the CLI runner**: `prolog-notebook run` executes a chapter headlessly and writes its
251
- answers back
271
+ - **the CLI**: `run` executes a chapter headlessly and writes its answers back, `view` opens it
272
+ in a browser, `build` writes a page you can host or send
252
273
  - download your own copy of a chapter, answers and all
253
274
  - 186 passing tests
254
275
 
@@ -256,7 +277,6 @@ Not built yet:
256
277
 
257
278
  - `--check` — run a chapter in CI and fail the build when its answers have drifted. Needs a
258
279
  timeout first: a test suite that can hang forever is not a test suite.
259
- - `build` — a static page a reader can open, without a dev server
260
280
  - custom elements (`<prolog-program>`, `<prolog-query>`) so notebooks drop into any static site
261
281
  - a VS Code notebook controller — VS Code supplies the UI, this supplies the kernel, still no Python
262
282
  - persistence, so a reader's edits survive a reload
@@ -4,8 +4,9 @@
4
4
  // Code "run all" and a future --check get the same behaviour without going
5
5
  // through a shell (869ectt38, 869ectt3e).
6
6
  import { createRequire } from 'node:module';
7
- import { readFileSync, writeFileSync } from 'node:fs';
8
- import { basename } from 'node:path';
7
+ import { 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';
@@ -14,6 +15,8 @@ import { updateNotice } from '../src/update.js';
14
15
  import { confirm, describeInstall, globalRoot, install, relaunch, upgradePlan } from '../src/upgrade.js';
15
16
  import { exportSource } from '../src/export.js';
16
17
  import { runNotebook, DEFAULT_LIMIT } from '../src/run.js';
18
+ import { buildFiles } from '../src/build.js';
19
+ import { serve } from '../src/serve.js';
17
20
 
18
21
  // The engine is imported WHERE IT IS USED, never at the top. src/node.js pulls in
19
22
  // 5.9 MB of WebAssembly at module scope, so a static import here would mean that
@@ -37,6 +40,8 @@ const require = createRequire(import.meta.url);
37
40
 
38
41
  const USAGE = `prolog-notebook — Jupyter-style notebooks for Prolog
39
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
40
45
  prolog-notebook run <file.prolog.md>... run every cell, write the answers back
41
46
  prolog-notebook upgrade fetch the latest version
42
47
 
@@ -44,6 +49,9 @@ Options
44
49
  --limit <n> solutions to take from one query before stopping (default ${DEFAULT_LIMIT})
45
50
  --stdout print the result instead of writing the file
46
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
47
55
  --version version, engine and copyright
48
56
  --check-update ask npm whether a newer one exists, and say so either way
49
57
  -h, --help this
@@ -173,6 +181,7 @@ async function main(argv) {
173
181
  }
174
182
 
175
183
  const command = args.shift();
184
+ if (command === 'view' || command === 'build') return page(command, args);
176
185
  if (command === 'upgrade') {
177
186
  const { message, newer } = await updateNotice({ version: VERSION, force: true });
178
187
  if (message) process.stderr.write(`${message}\n`);
@@ -273,6 +282,80 @@ async function main(argv) {
273
282
  return status;
274
283
  }
275
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
+
276
359
  async function runFile(file, session, options) {
277
360
  const name = basename(file);
278
361
  let notebook;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "prolog-notebook",
3
- "version": "0.4.3",
3
+ "version": "0.5.0",
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": "bd4d4d6",
3
- "built": "2026-08-30 15:55:33 UTC"
2
+ "commit": "084182f",
3
+ "built": "2026-08-30 16:32:07 UTC"
4
4
  }
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) => ({ '&': '&amp;', '<': '&lt;', '>': '&gt;', '"': '&quot;' }[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, edits(cells)) }),
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/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.4.3';
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';