prolog-notebook 0.1.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 ADDED
@@ -0,0 +1,30 @@
1
+ # Changelog
2
+
3
+ ## [0.1.0] — 2026-08-02
4
+
5
+ First release. The execution core works; the file-backed renderer does not exist yet.
6
+
7
+ ### Added
8
+
9
+ - **Execution core** (`src/engine.js`) — SWI-Prolog via WebAssembly, with no DOM in it, so
10
+ the same module backs the browser, a future VS Code controller, and a headless runner.
11
+ `PrologSession.consult/2`, `session.query/1`, `query.next()`, `query.all()`.
12
+ - **Node entry point** (`prolog-notebook`) and **browser entry point**
13
+ (`prolog-notebook/browser`).
14
+ - **Cell wiring** (`src/notebook.js`) — program cells and query cells with `Run`, `; next`
15
+ and `all`. Stepping solutions one at a time is deliberate: a Prolog query yields answers
16
+ on backtracking, and watching that happen is usually the point.
17
+ - **Worked example** (`example/index.html`) — the `once/1` placement puzzle, a real section
18
+ rather than a widget demo.
19
+ - Eight tests covering duplicate proofs, `once` around a generator versus a test, ground
20
+ goals, failure, unknown predicates, and stepping.
21
+
22
+ ### Notes
23
+
24
+ Two behaviours of `swipl-wasm` that the core papers over, both found by driving a browser
25
+ rather than reading documentation:
26
+
27
+ - `prolog.query/1` runs with `system` as its context module while `consult/1` loads into
28
+ `user`, so goals are wrapped as `user:( Goal )`.
29
+ - `next()` can return the final binding together with `done: true`; treating `done` as
30
+ "stop" drops the last solution.
package/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 Johnny Jarecsni
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
package/README.md ADDED
@@ -0,0 +1,143 @@
1
+ # prolog-notebook
2
+
3
+ **Jupyter-style notebooks for Prolog. Runs in the browser, installs nothing.**
4
+
5
+ > ⚠️ **v0.1.0 — early.** The execution core works and is tested. The renderer that turns a
6
+ > notebook *file* into a page is not written yet; today you mark cells up in HTML by hand.
7
+ > See [Status](#status).
8
+
9
+ ## The idea
10
+
11
+ Prolog is unusually badly served by a printed page, and unusually well served by an
12
+ executable one.
13
+
14
+ Almost everything that trips up a Prolog learner is something you have to *watch happen*.
15
+ A query does not return an answer, it returns answers one at a time on backtracking. A rule
16
+ that reads correctly can be wrong because of the order its goals are in. `once/1` around one
17
+ goal is free and around the goal next to it is catastrophic. None of that survives being
18
+ described; all of it is obvious the moment you run it and press `;` a few times.
19
+
20
+ So the aim here is notebooks where the prose and the Prolog live together and the Prolog
21
+ actually runs — for the reader, not just the author. That last part is the whole problem.
22
+ A Jupyter kernel for Prolog [already exists][kernel] and is good, but it needs Python, then
23
+ Jupyter, then the kernel, then a local SWI-Prolog: four installs before the first query. A
24
+ reader who has to do that has already closed the tab.
25
+
26
+ `prolog-notebook` uses [SWI-Prolog compiled to WebAssembly][wasm], so the Prolog system runs
27
+ *inside the page*. No server, no kernel process, no install. You publish a static file and
28
+ the reader clicks a link.
29
+
30
+ ### `; next` is the point
31
+
32
+ The reason this is not "Jupyter with a different kernel" is the button marked `; next`.
33
+
34
+ Jupyter's model is request/response: run a cell, get a result. Prolog's model is a stream of
35
+ solutions you walk through. In the included example, `is_son(X)` reports edward *twice* — and
36
+ that duplication **is the lesson**, because it means Prolog found two proofs. A notebook that
37
+ showed only a final list of results would have hidden the very thing worth teaching.
38
+
39
+ So a query cell gives you the first solution, and then you step.
40
+
41
+ ## Try it
42
+
43
+ ```sh
44
+ git clone https://github.com/jarecsni/prolog-notebook
45
+ cd prolog-notebook
46
+ npm install
47
+ npm run example # then open http://localhost:8777/example/
48
+ ```
49
+
50
+ The example is a real worked section — the `once/1` placement puzzle — not a widget demo.
51
+ Predict what each version returns before you press Run.
52
+
53
+ It has to be **served over HTTP**. Opening `example/index.html` straight from disk leaves the
54
+ buttons inert, because browsers block ES modules over `file://` — the page detects this and
55
+ says so rather than failing silently.
56
+
57
+ ## Use it
58
+
59
+ Headless, in Node — this is how you test that every example in a document still works:
60
+
61
+ ```js
62
+ import { createSession, formatSolution } from 'prolog-notebook';
63
+
64
+ const session = await createSession();
65
+ session.consult(`
66
+ male(edward). male(alfred).
67
+ father(albert, edward). mother(victoria, edward).
68
+ parent(X, Y) :- father(X, Y) ; mother(X, Y).
69
+ is_son(X) :- male(X), parent(_, X).
70
+ `);
71
+
72
+ // Step solutions one at a time, as at the prompt
73
+ const q = session.query('is_son(X)');
74
+ let r;
75
+ while (!(r = q.next()).done) console.log(formatSolution(r.solution));
76
+
77
+ // …or drain it
78
+ const { solutions } = session.query('is_son(X)').all();
79
+ ```
80
+
81
+ In a browser, load the WASM bundle with a `<script>` tag, then import from
82
+ `prolog-notebook/browser`. See [`example/index.html`](example/index.html) for the cell
83
+ markup and [`src/notebook.js`](src/notebook.js) for the wiring.
84
+
85
+ ### API
86
+
87
+ | | |
88
+ |---|---|
89
+ | `createSession(options?)` | boots SWI-Prolog; returns a `PrologSession` |
90
+ | `session.consult(text, name?)` | loads a clause base into `user`; `{ok, error?}` |
91
+ | `session.query(goal)` | opens a query; returns a `PrologQuery` |
92
+ | `query.next()` | one solution: `{done, solution?, error?}` |
93
+ | `query.all(limit?)` | drains it: `{solutions, error?, truncated}` |
94
+ | `formatSolution(s)` | renders bindings the way a top level would |
95
+
96
+ ## Two things that will bite you if you build this yourself
97
+
98
+ Both were found by driving a real browser, not by reading documentation.
99
+
100
+ **Module context.** `prolog.query(Goal)` runs with `system` as its context module, while
101
+ `consult/1` loads into `user`. Unqualified goals raise `Unknown procedure: system:foo/1`
102
+ *even though the consult reported success*. Goals are wrapped as `user:( Goal )`.
103
+
104
+ **The last solution arrives with `done`.** `next()` can return `{done: true, value: {...}}` —
105
+ a final binding and the end of the search in a single step. Treating `done` as "stop, no more
106
+ answers" silently drops the last solution, which is the kind of bug you don't notice until a
107
+ lesson about backtracking quietly teaches the wrong thing.
108
+
109
+ ## Status
110
+
111
+ Working and tested:
112
+
113
+ - execution core, environment-agnostic (`src/engine.js`), 8 passing tests
114
+ - Node entry point, browser entry point
115
+ - program cells and query cells with `Run` / `; next` / `all`
116
+ - the worked example
117
+
118
+ Not built yet:
119
+
120
+ - **a file-backed renderer** — reading `.ipynb` or markdown and *generating* the cells.
121
+ Today the example's cells are hand-written HTML. This is the next real piece of work.
122
+ - custom elements (`<prolog-program>`, `<prolog-query>`) so notebooks drop into any static site
123
+ - a VS Code notebook controller — VS Code supplies the UI, this supplies the kernel, still no Python
124
+ - a CLI runner, to execute a document's cells in CI and fail the build when an example rots
125
+ - `trace/0` integration, for visible backtracking — where [prolog-trace-viz][ptv] would plug in
126
+ - syntax highlighting
127
+
128
+ ## Prior art
129
+
130
+ [prolog-jupyter-kernel][kernel] — a proper Jupyter kernel for SWI and SICStus, from Anne
131
+ Brecklinghaus's master's thesis at Düsseldorf. Use it if you already live in JupyterLab and
132
+ don't mind the installs.
133
+
134
+ [SWISH](https://swish.swi-prolog.org/) — SWI-Prolog's own browser environment, which has a
135
+ notebook feature. Server-hosted and sandboxed; this is neither.
136
+
137
+ ## License
138
+
139
+ MIT
140
+
141
+ [kernel]: https://github.com/hhu-stups/prolog-jupyter-kernel
142
+ [wasm]: https://github.com/SWI-Prolog/swipl-wasm
143
+ [ptv]: https://github.com/jarecsni/prolog-trace-viz
package/package.json ADDED
@@ -0,0 +1,53 @@
1
+ {
2
+ "name": "prolog-notebook",
3
+ "version": "0.1.0",
4
+ "description": "Jupyter-style notebooks for Prolog. Runs in the browser, installs nothing.",
5
+ "type": "module",
6
+ "main": "./src/node.js",
7
+ "exports": {
8
+ ".": "./src/node.js",
9
+ "./browser": "./src/browser.js",
10
+ "./engine": "./src/engine.js",
11
+ "./notebook.css": "./src/notebook.css"
12
+ },
13
+ "files": [
14
+ "src",
15
+ "README.md",
16
+ "LICENSE",
17
+ "CHANGELOG.md"
18
+ ],
19
+ "scripts": {
20
+ "test": "node --test test/*.test.mjs",
21
+ "example": "python3 -m http.server 8777",
22
+ "prepublishOnly": "npm test"
23
+ },
24
+ "keywords": [
25
+ "prolog",
26
+ "notebook",
27
+ "jupyter",
28
+ "swi-prolog",
29
+ "wasm",
30
+ "webassembly",
31
+ "logic-programming",
32
+ "literate-programming"
33
+ ],
34
+ "author": "Johnny Jarecsni",
35
+ "license": "MIT",
36
+ "repository": {
37
+ "type": "git",
38
+ "url": "git+https://github.com/jarecsni/prolog-notebook.git"
39
+ },
40
+ "bugs": {
41
+ "url": "https://github.com/jarecsni/prolog-notebook/issues"
42
+ },
43
+ "homepage": "https://github.com/jarecsni/prolog-notebook#readme",
44
+ "engines": {
45
+ "node": ">=18"
46
+ },
47
+ "dependencies": {
48
+ "swipl-wasm": "^8.0.4"
49
+ },
50
+ "publishConfig": {
51
+ "access": "public"
52
+ }
53
+ }
package/src/browser.js ADDED
@@ -0,0 +1,25 @@
1
+ // Browser entry point. The swipl-wasm bundle is loaded by a <script> tag and
2
+ // exposes a global SWIPL factory; keeping the load out of this module means the
3
+ // 5.9 MB bundle is fetched by the page, not by a bundler.
4
+ import { PrologSession } from './engine.js';
5
+
6
+ export * from './engine.js';
7
+
8
+ let session = null;
9
+
10
+ /**
11
+ * Boot (once) and return the shared session for this page.
12
+ * @returns {Promise<PrologSession>}
13
+ */
14
+ export function createSession(options = {}) {
15
+ if (!session) {
16
+ if (typeof globalThis.SWIPL !== 'function') {
17
+ throw new Error(
18
+ 'swipl-wasm not found. Load it first, e.g.\n' +
19
+ '<script src="node_modules/swipl-wasm/dist/swipl/swipl-bundle.js"></script>'
20
+ );
21
+ }
22
+ session = PrologSession.create(globalThis.SWIPL, options);
23
+ }
24
+ return session;
25
+ }
package/src/engine.js ADDED
@@ -0,0 +1,134 @@
1
+ // The execution core. No DOM, no browser assumptions — this same module backs the
2
+ // web renderer, a VS Code notebook controller, and a headless CLI runner.
3
+ //
4
+ // The engine is SWI-Prolog itself compiled to WebAssembly, so nothing is installed
5
+ // and nothing is spawned: the Prolog system runs inside the host process.
6
+
7
+ let fileSerial = 0;
8
+
9
+ export class PrologSession {
10
+ /**
11
+ * @param {Function} swiplFactory the SWIPL factory from swipl-wasm
12
+ * @param {object} [options] passed through to the factory
13
+ */
14
+ static async create(swiplFactory, options = {}) {
15
+ const module = await swiplFactory({ arguments: ['-q'], ...options });
16
+ return new PrologSession(module);
17
+ }
18
+
19
+ constructor(module) {
20
+ this.module = module;
21
+ }
22
+
23
+ /**
24
+ * Load a clause base into the `user` module.
25
+ * @param {string} text Prolog source
26
+ * @returns {{ok: boolean, error?: string}}
27
+ */
28
+ consult(text, name = `cell${fileSerial++}`) {
29
+ const path = `/${name}.pl`;
30
+ try {
31
+ this.module.FS.writeFile(path, text);
32
+ const r = this.module.prolog.query(`user:consult('${path}')`).once();
33
+ if (r && r.error) return { ok: false, error: r.message };
34
+ return { ok: true };
35
+ } catch (e) {
36
+ return { ok: false, error: e.message };
37
+ }
38
+ }
39
+
40
+ /**
41
+ * Open a query. Solutions are pulled one at a time — this is Prolog, not a
42
+ * function call, and stepping the solutions is usually the point.
43
+ * @param {string} goal
44
+ * @returns {PrologQuery}
45
+ */
46
+ query(goal) {
47
+ // Cells consult into `user`, but prolog.query/1 runs with `system` as the
48
+ // context module, so an unqualified goal resolves against the wrong one.
49
+ const cleaned = goal.trim().replace(/\.$/, '');
50
+ return new PrologQuery(this.module.prolog.query(`user:( ${cleaned} )`));
51
+ }
52
+ }
53
+
54
+ export class PrologQuery {
55
+ constructor(handle) {
56
+ this.handle = handle;
57
+ this.exhausted = false;
58
+ this.count = 0;
59
+ }
60
+
61
+ /**
62
+ * Pull the next solution.
63
+ * @returns {{done: boolean, solution?: object, error?: string}}
64
+ */
65
+ next() {
66
+ if (this.exhausted) return { done: true };
67
+ let r;
68
+ try {
69
+ r = this.handle.next();
70
+ } catch (e) {
71
+ this.exhausted = true;
72
+ return { done: true, error: e.message };
73
+ }
74
+ if (r.error) {
75
+ this.exhausted = true;
76
+ return { done: true, error: r.message };
77
+ }
78
+
79
+ // The engine can deliver the final solution *together with* done:true — a
80
+ // binding and the end of the search in one step. Reporting `done` without
81
+ // the binding silently loses the last solution.
82
+ const out = { done: !!r.done };
83
+ if (r.value) {
84
+ this.count += 1;
85
+ out.solution = bindingsOf(r.value);
86
+ }
87
+ if (r.done) this.exhausted = true;
88
+ return out;
89
+ }
90
+
91
+ /**
92
+ * Drain the query. `limit` guards against a genuinely infinite generator.
93
+ * @returns {{solutions: object[], error?: string, truncated: boolean}}
94
+ */
95
+ all(limit = 1000) {
96
+ const solutions = [];
97
+ let error;
98
+ while (!this.exhausted && solutions.length < limit) {
99
+ const r = this.next();
100
+ if (r.solution) solutions.push(r.solution);
101
+ if (r.error) error = r.error;
102
+ }
103
+ return { solutions, error, truncated: !this.exhausted };
104
+ }
105
+ }
106
+
107
+ /** Strip the engine's bookkeeping keys from a solution. */
108
+ export function bindingsOf(value) {
109
+ const out = {};
110
+ for (const [k, v] of Object.entries(value)) {
111
+ if (k === '$tag' || k === 'success') continue;
112
+ out[k] = v;
113
+ }
114
+ return out;
115
+ }
116
+
117
+ /** Render a solution the way a Prolog top level would. */
118
+ export function formatSolution(solution) {
119
+ const pairs = Object.entries(solution);
120
+ if (!pairs.length) return 'true';
121
+ return pairs.map(([k, v]) => `${k} = ${formatTerm(v)}`).join(', ');
122
+ }
123
+
124
+ export function formatTerm(v) {
125
+ if (v === null || v === undefined) return '_';
126
+ if (Array.isArray(v)) return `[${v.map(formatTerm).join(', ')}]`;
127
+ if (typeof v === 'object') {
128
+ if (v.$tag === 'string') return `"${v.text}"`;
129
+ if (v.functor) return `${v.functor}(${(v.args || []).map(formatTerm).join(', ')})`;
130
+ if (v.v !== undefined) return `_${v.v}`;
131
+ return JSON.stringify(v);
132
+ }
133
+ return String(v);
134
+ }
package/src/node.js ADDED
@@ -0,0 +1,10 @@
1
+ // Node entry point: boots the engine with the swipl-wasm Node build.
2
+ import SWIPL from 'swipl-wasm/dist/swipl-node.js';
3
+ import { PrologSession } from './engine.js';
4
+
5
+ export * from './engine.js';
6
+
7
+ /** @returns {Promise<PrologSession>} a session running in this Node process. */
8
+ export function createSession(options = {}) {
9
+ return PrologSession.create(SWIPL, options);
10
+ }
@@ -0,0 +1,201 @@
1
+ :root {
2
+ --ink: #1d1d1f;
3
+ --paper: #fdfcf7;
4
+ --rule: #e0dccf;
5
+ --accent: #b4451f;
6
+ --code-bg: #f5f2e8;
7
+ --ok: #2f6b3a;
8
+ --err: #a52014;
9
+ --hand: "Bradley Hand", "Segoe Print", "Comic Sans MS", cursive;
10
+ }
11
+
12
+ * { box-sizing: border-box; }
13
+
14
+ body {
15
+ margin: 0;
16
+ padding: 3rem 1.5rem 6rem;
17
+ background: var(--paper);
18
+ color: var(--ink);
19
+ font: 17px/1.65 Georgia, "Iowan Old Style", serif;
20
+ }
21
+
22
+ main { max-width: 46rem; margin: 0 auto; }
23
+
24
+ h1 { font-size: 2.1rem; line-height: 1.15; margin: 0 0 .3rem; }
25
+ h2 {
26
+ font-size: 1.35rem;
27
+ margin: 3rem 0 .8rem;
28
+ padding-bottom: .3rem;
29
+ border-bottom: 2px solid var(--rule);
30
+ }
31
+ .kicker {
32
+ font: 600 .8rem/1 system-ui, sans-serif;
33
+ letter-spacing: .12em;
34
+ text-transform: uppercase;
35
+ color: var(--accent);
36
+ margin-bottom: 1.4rem;
37
+ }
38
+
39
+ p { margin: 0 0 1rem; }
40
+ code { font: .92em ui-monospace, "SF Mono", Menlo, monospace; background: var(--code-bg); padding: .1em .3em; border-radius: 3px; }
41
+
42
+ /* --- cells --- */
43
+
44
+ .cell {
45
+ margin: 1.6rem 0;
46
+ border: 1px solid var(--rule);
47
+ border-radius: 6px;
48
+ background: #fff;
49
+ overflow: hidden;
50
+ }
51
+ .cell .bar {
52
+ display: flex;
53
+ align-items: center;
54
+ gap: .6rem;
55
+ padding: .45rem .7rem;
56
+ background: var(--code-bg);
57
+ border-bottom: 1px solid var(--rule);
58
+ font: 600 .72rem/1 system-ui, sans-serif;
59
+ letter-spacing: .09em;
60
+ text-transform: uppercase;
61
+ color: #6b6455;
62
+ }
63
+ .cell .bar .spacer { flex: 1; }
64
+
65
+ textarea, input {
66
+ width: 100%;
67
+ border: 0;
68
+ padding: .8rem .9rem;
69
+ font: 14px/1.55 ui-monospace, "SF Mono", Menlo, monospace;
70
+ color: var(--ink);
71
+ background: #fff;
72
+ resize: none;
73
+ }
74
+ textarea:focus, input:focus { outline: 2px solid #cfd8e8; outline-offset: -2px; }
75
+
76
+ input { border-bottom: 1px solid var(--rule); }
77
+ .query .prompt { display: flex; align-items: baseline; }
78
+ .query .prompt span {
79
+ padding-left: .9rem;
80
+ font: 14px ui-monospace, Menlo, monospace;
81
+ color: #8a8371;
82
+ white-space: nowrap;
83
+ }
84
+
85
+ button {
86
+ font: 600 .74rem/1 system-ui, sans-serif;
87
+ padding: .42rem .7rem;
88
+ border: 1px solid #c8c1ae;
89
+ border-radius: 4px;
90
+ background: #fff;
91
+ color: var(--ink);
92
+ cursor: pointer;
93
+ }
94
+ button:hover:not(:disabled) { background: var(--code-bg); }
95
+ button:disabled { opacity: .38; cursor: default; }
96
+ button.primary { background: var(--accent); border-color: var(--accent); color: #fff; }
97
+ button.primary:hover { background: #98380f; }
98
+
99
+ .status { font: 400 .74rem/1 system-ui, sans-serif; text-transform: none; letter-spacing: 0; }
100
+ .status.ok { color: var(--ok); }
101
+ .status.err { color: var(--err); }
102
+ .status.busy { color: #8a8371; }
103
+
104
+ .out {
105
+ font: 13.5px/1.7 ui-monospace, Menlo, monospace;
106
+ padding: .6rem .9rem;
107
+ max-height: 16rem;
108
+ overflow-y: auto;
109
+ background: #fbfaf5;
110
+ border-top: 1px solid var(--rule);
111
+ }
112
+ .out:empty { display: none; }
113
+ .line.echo { color: #8a8371; }
114
+ .line.sol { color: var(--ok); }
115
+ .line.done { color: #8a8371; font-style: italic; }
116
+ .line.err { color: var(--err); }
117
+
118
+ /* --- teaching devices --- */
119
+
120
+ .predict {
121
+ margin: 1.6rem 0;
122
+ padding: 1rem 1.2rem;
123
+ border: 2px dashed #c8b98a;
124
+ border-radius: 6px;
125
+ background: #fffdf2;
126
+ }
127
+ .predict h3 {
128
+ margin: 0 0 .5rem;
129
+ font: 700 .95rem/1.3 system-ui, sans-serif;
130
+ color: var(--accent);
131
+ }
132
+ .predict textarea {
133
+ border: 1px solid var(--rule);
134
+ border-radius: 4px;
135
+ background: #fff;
136
+ min-height: 3.4rem;
137
+ }
138
+ .predict summary {
139
+ cursor: pointer;
140
+ font: 600 .85rem/1 system-ui, sans-serif;
141
+ color: var(--accent);
142
+ margin-top: .7rem;
143
+ }
144
+ .predict[open] summary { margin-bottom: .6rem; }
145
+ .predict details p:last-child { margin-bottom: 0; }
146
+
147
+ .aside {
148
+ margin: 1.8rem 0;
149
+ padding: .9rem 1.1rem;
150
+ border-left: 4px solid var(--accent);
151
+ background: #fbf6ef;
152
+ font-size: .95rem;
153
+ }
154
+ .aside strong { display: block; margin-bottom: .3rem; }
155
+
156
+ .note {
157
+ font-family: var(--hand);
158
+ font-size: 1.12rem;
159
+ color: var(--accent);
160
+ transform: rotate(-1.2deg);
161
+ margin: .6rem 0 1.4rem 2rem;
162
+ }
163
+ .note::before { content: "↖ "; }
164
+
165
+ .bullets { border-top: 3px double var(--rule); padding-top: 1rem; margin-top: 2.5rem; }
166
+ .bullets h2 { border: 0; margin-top: 0; }
167
+ .bullets li { margin-bottom: .5rem; }
168
+
169
+ @media (prefers-color-scheme: dark) {
170
+ :root {
171
+ --ink: #e8e4da; --paper: #16151a; --rule: #35323c;
172
+ --code-bg: #22212a; --accent: #e2825c; --ok: #7fc98d; --err: #f0806f;
173
+ }
174
+ .cell, textarea, input { background: #1c1b22; }
175
+ .out { background: #1a191f; }
176
+ .predict { background: #201e19; border-color: #5a4f36; }
177
+ .aside { background: #201d1c; }
178
+ button { background: #24232b; border-color: #454150; color: var(--ink); }
179
+ button:hover:not(:disabled) { background: #2d2b35; }
180
+ button.primary { background: var(--accent); color: #16151a; }
181
+ }
182
+
183
+ /* Shown until mount() runs; see notebook.js. Its presence means the page is inert. */
184
+ #boot-warning {
185
+ margin: 0 0 2rem;
186
+ padding: 1rem 1.2rem;
187
+ border: 2px solid var(--err);
188
+ border-radius: 6px;
189
+ background: #fff5f3;
190
+ font-size: .95rem;
191
+ }
192
+ #boot-warning strong { color: var(--err); }
193
+ #boot-warning pre {
194
+ margin: .6rem 0 0;
195
+ padding: .6rem .8rem;
196
+ background: var(--code-bg);
197
+ border-radius: 4px;
198
+ font: 13px/1.5 ui-monospace, Menlo, monospace;
199
+ overflow-x: auto;
200
+ }
201
+ @media (prefers-color-scheme: dark) { #boot-warning { background: #2a1d1b; } }
@@ -0,0 +1,144 @@
1
+ // Browser wiring: turns marked-up cells in a page into a running notebook.
2
+ //
3
+ // Deliberately not a framework. A page declares its cells as ordinary elements and
4
+ // calls mount(); the DOM is the notebook. A file-backed renderer (reading .ipynb or
5
+ // markdown and generating these cells) is the next layer up, and is not written yet.
6
+ import { createSession, formatSolution } from './browser.js';
7
+
8
+ let serial = 0;
9
+ let booted = false;
10
+
11
+ export function mount(root = document) {
12
+ // A page can carry a #boot-warning element saying "this notebook is not running".
13
+ // It is removed only once mount() has actually run, so any failure that prevents
14
+ // this module from loading — opening the page over file://, a bad path, a syntax
15
+ // error — leaves the warning on screen instead of silently inert buttons.
16
+ document.getElementById('boot-warning')?.remove();
17
+
18
+ root.querySelectorAll('.cell.program').forEach(mountProgram);
19
+ root.querySelectorAll('.cell.query').forEach(mountQuery);
20
+ }
21
+
22
+ /** Boot the engine, reporting the first (slow, 5.9 MB) load through `status`. */
23
+ async function boot(status) {
24
+ if (!booted && status) {
25
+ status.textContent = 'starting SWI-Prolog (5.9 MB, first time only)…';
26
+ status.className = 'status busy';
27
+ }
28
+ const session = await createSession();
29
+ booted = true;
30
+ return session;
31
+ }
32
+
33
+ function mountProgram(cell) {
34
+ const source = cell.querySelector('textarea');
35
+ const button = cell.querySelector('button');
36
+ const status = cell.querySelector('.status');
37
+ const name = `cell${serial++}`;
38
+
39
+ autosize(source);
40
+
41
+ button.addEventListener('click', async () => {
42
+ const label = button.textContent;
43
+ button.disabled = true;
44
+ button.textContent = 'Working…';
45
+ try {
46
+ const session = await boot(status);
47
+ const r = session.consult(source.value, name);
48
+ status.textContent = r.ok ? '✓ consulted' : r.error;
49
+ status.className = `status ${r.ok ? 'ok' : 'err'}`;
50
+ } catch (e) {
51
+ status.textContent = e.message;
52
+ status.className = 'status err';
53
+ } finally {
54
+ button.disabled = false;
55
+ button.textContent = label;
56
+ }
57
+ });
58
+ }
59
+
60
+ function mountQuery(cell) {
61
+ const input = cell.querySelector('input');
62
+ const runBtn = cell.querySelector('[data-act="run"]');
63
+ const nextBtn = cell.querySelector('[data-act="next"]');
64
+ const allBtn = cell.querySelector('[data-act="all"]');
65
+ const out = cell.querySelector('.out');
66
+
67
+ let query = null;
68
+ let count = 0;
69
+
70
+ const write = (text, cls) => {
71
+ const line = document.createElement('div');
72
+ line.className = `line ${cls || ''}`;
73
+ line.textContent = text;
74
+ out.appendChild(line);
75
+ out.scrollTop = out.scrollHeight;
76
+ };
77
+
78
+ const finish = () => {
79
+ query = null;
80
+ nextBtn.disabled = true;
81
+ allBtn.disabled = true;
82
+ };
83
+
84
+ const step = () => {
85
+ if (!query) return;
86
+ const r = query.next();
87
+ if (r.solution) write(`${++count}. ${formatSolution(r.solution)}`, 'sol');
88
+ if (r.error) {
89
+ write(r.error, 'err');
90
+ finish();
91
+ return;
92
+ }
93
+ if (r.done) {
94
+ write(count === 0 ? 'false.' : 'no more solutions.', 'done');
95
+ finish();
96
+ }
97
+ };
98
+
99
+ runBtn.addEventListener('click', async () => {
100
+ out.innerHTML = '';
101
+ count = 0;
102
+ const goal = input.value.trim().replace(/\.$/, '');
103
+ if (!goal) return;
104
+ write(`?- ${goal}.`, 'echo');
105
+ runBtn.disabled = true;
106
+ try {
107
+ if (!booted) write('starting SWI-Prolog (5.9 MB, first time only)…', 'done');
108
+ const session = await boot();
109
+ query = session.query(goal);
110
+ nextBtn.disabled = false;
111
+ allBtn.disabled = false;
112
+ step();
113
+ } catch (e) {
114
+ write(e.message, 'err');
115
+ finish();
116
+ } finally {
117
+ runBtn.disabled = false;
118
+ }
119
+ });
120
+
121
+ nextBtn.addEventListener('click', step);
122
+ allBtn.addEventListener('click', () => {
123
+ let guard = 0;
124
+ while (query && guard++ < 500) step();
125
+ if (guard >= 500) write('stopped after 500 solutions.', 'done');
126
+ });
127
+
128
+ input.addEventListener('keydown', (e) => {
129
+ if (e.key === 'Enter') runBtn.click();
130
+ if (e.key === ';') {
131
+ e.preventDefault();
132
+ if (!nextBtn.disabled) step();
133
+ }
134
+ });
135
+ }
136
+
137
+ function autosize(ta) {
138
+ const fit = () => {
139
+ ta.style.height = 'auto';
140
+ ta.style.height = `${ta.scrollHeight}px`;
141
+ };
142
+ ta.addEventListener('input', fit);
143
+ requestAnimationFrame(fit);
144
+ }