prolog-notebook 0.6.5 → 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 CHANGED
@@ -1,5 +1,104 @@
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
+
3
102
  ## [0.6.5] — 2026-08-31
4
103
 
5
104
  Both from one field report on a real chapter.
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 --out site/
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 beside it, and
75
- the 6.2 MB engine that is fetched only when a reader presses Run. No bundler, no build step of
76
- your own, nothing to configure.
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 it writes. Default `<file>-site` |
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
 
@@ -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
- usage: 'prolog-notebook view <file.prolog.md>',
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
- usage: 'prolog-notebook build <file.prolog.md>',
80
+ takes: [FILE],
61
81
  blurb: 'write a page you can host or send',
62
- options: [['--out <dir>', 'where it writes (default: <file>-site)']],
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
- usage: 'prolog-notebook execute <file.prolog.md>...',
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
- usage: 'prolog-notebook clear <file.prolog.md>...',
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
- usage: 'prolog-notebook upgrade',
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
- const ANYWHERE = `Anywhere
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 this
131
+ -h, --help ${help}
101
132
  `;
102
133
 
103
- /** One command, laid out exactly as it is laid out in the full help. */
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 { usage, blurb, options } = COMMANDS[name];
106
- return [` ${usage.padEnd(44)}${blurb}`]
107
- .concat(options.map(([flag, what]) => ` ${flag.padEnd(16)}${what}`))
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(commandHelp).join('\n\n')}
196
+ ${Object.keys(COMMANDS).map(commandLine).join('\n')}
114
197
 
115
- ${ANYWHERE}
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${ANYWHERE}${note ? `\n${note}` : ''}`;
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
- const out = options.out ?? `${file.replace(/\.prolog\.md$/, '')}-site`;
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
- const target = join(out, name);
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
- process.stderr.write(`${out}: ${built.size} files\n`);
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 ${out} over HTTP — opening ${join(out, 'index.html')} from disk`
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
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "prolog-notebook",
3
- "version": "0.6.5",
3
+ "version": "0.7.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": "60e27bc",
3
- "built": "2026-08-30 23:44:28 UTC"
2
+ "commit": "7e40763",
3
+ "built": "2026-08-31 10:59:41 UTC"
4
4
  }
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) => ({ '&': '&amp;', '<': '&lt;', '>': '&gt;', '"': '&quot;' }[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 './lib/notebook.js';
217
- import { parse } from './lib/format.js';
218
- import { exportSource } from './lib/export.js';
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('./swipl/${ENGINE}', import.meta.url).href,
272
+ swiplUrl: new URL('${prefix}swipl/${ENGINE}', import.meta.url).href,
228
273
  });
229
274
 
230
275
  const notebook = parse(SOURCE);
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) => ({ '&': '&amp;', '<': '&lt;', '>': '&gt;', '"': '&quot;' }[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.6.5';
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';