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 +30 -0
- package/LICENSE +21 -0
- package/README.md +143 -0
- package/package.json +53 -0
- package/src/browser.js +25 -0
- package/src/engine.js +134 -0
- package/src/node.js +10 -0
- package/src/notebook.css +201 -0
- package/src/notebook.js +144 -0
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
|
+
}
|
package/src/notebook.css
ADDED
|
@@ -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; } }
|
package/src/notebook.js
ADDED
|
@@ -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
|
+
}
|