prolog-notebook 0.1.2 → 0.3.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +211 -0
- package/README.md +130 -27
- package/bin/prolog-notebook.mjs +206 -0
- package/package.json +19 -4
- package/src/browser.js +252 -16
- package/src/build-info.js +103 -0
- package/src/build-info.json +4 -0
- package/src/clauses.js +236 -0
- package/src/engine.js +227 -16
- package/src/export.js +126 -0
- package/src/format.js +649 -0
- package/src/node.js +14 -4
- package/src/notebook.css +319 -1
- package/src/notebook.js +1261 -60
- package/src/page.js +85 -0
- package/src/render.js +367 -0
- package/src/run.js +128 -0
- package/src/session.js +227 -0
- package/src/version.js +51 -0
- package/src/worker.js +77 -0
package/src/format.js
ADDED
|
@@ -0,0 +1,649 @@
|
|
|
1
|
+
// The notebook source format: markdown in, cell model out, and back again.
|
|
2
|
+
//
|
|
3
|
+
// No DOM and no filesystem in this module — it is the boundary the renderer, the
|
|
4
|
+
// CLI runner and a future VS Code serializer all sit behind, so it must run
|
|
5
|
+
// unchanged in a browser, in Node and in a Web Worker.
|
|
6
|
+
//
|
|
7
|
+
// The grammar is spelled out in docs/format.md. Two properties of it drive every
|
|
8
|
+
// decision here: cells begin at column 0, which is what lets this be a line
|
|
9
|
+
// scanner rather than a markdown AST walk; and prose is passed through byte for
|
|
10
|
+
// byte, because the CLI writes outputs back into the author's own file on every
|
|
11
|
+
// run and a lossy serialiser would quietly mangle their text.
|
|
12
|
+
|
|
13
|
+
/** Cell kinds this version understands. Anything else is preserved, not refused. */
|
|
14
|
+
const CELL_KINDS = new Set(['program', 'query', 'output']);
|
|
15
|
+
|
|
16
|
+
/** Containers with a visual meaning. Unknown ones stay ordinary blockquotes. */
|
|
17
|
+
const CONTAINERS = new Set(['predict', 'aside', 'margin', 'bullets']);
|
|
18
|
+
|
|
19
|
+
/** Attribute order in canonical form: id first, then the kind's own, then the rest. */
|
|
20
|
+
const ATTR_ORDER = {
|
|
21
|
+
program: ['id', 'src'],
|
|
22
|
+
query: ['id', 'rerun', 'hold'],
|
|
23
|
+
output: ['for', 'input-hash'],
|
|
24
|
+
};
|
|
25
|
+
|
|
26
|
+
const ID_RE = /^[a-z0-9][a-z0-9-]*$/;
|
|
27
|
+
|
|
28
|
+
export class NotebookError extends Error {
|
|
29
|
+
/**
|
|
30
|
+
* @param {string} message
|
|
31
|
+
* @param {number} [line] 1-based line number in the source
|
|
32
|
+
*/
|
|
33
|
+
constructor(message, line) {
|
|
34
|
+
super(line ? `line ${line}: ${message}` : message);
|
|
35
|
+
this.name = 'NotebookError';
|
|
36
|
+
this.line = line;
|
|
37
|
+
}
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
// ---------------------------------------------------------------- parsing
|
|
41
|
+
|
|
42
|
+
/**
|
|
43
|
+
* Parse notebook source into `{frontMatter, cells}`.
|
|
44
|
+
*
|
|
45
|
+
* Ids are minted for any program or query cell lacking one, so a hand-written
|
|
46
|
+
* chapter parses; the moment anything serialises the model back, those ids are
|
|
47
|
+
* written into the file and it self-heals to canonical form.
|
|
48
|
+
*
|
|
49
|
+
* @param {string} text
|
|
50
|
+
* @returns {{frontMatter: Map<string, string>, cells: object[]}}
|
|
51
|
+
*/
|
|
52
|
+
export function parse(text) {
|
|
53
|
+
if (text.includes('\u0000')) throw new NotebookError('NUL is not permitted in a notebook');
|
|
54
|
+
|
|
55
|
+
// CRLF is tolerated on read and never written back; the format is LF.
|
|
56
|
+
const lines = text.replace(/\r\n/g, '\n').split('\n');
|
|
57
|
+
const state = { lines, i: 0 };
|
|
58
|
+
|
|
59
|
+
const frontMatter = parseFrontMatter(state);
|
|
60
|
+
const format = frontMatter.get('format');
|
|
61
|
+
if (format !== undefined) {
|
|
62
|
+
const major = /^prolog-notebook\/(\d+)$/.exec(format);
|
|
63
|
+
if (!major) throw new NotebookError(`unrecognised format "${format}"`, 2);
|
|
64
|
+
// A newer major is refused rather than guessed at.
|
|
65
|
+
if (Number(major[1]) > 1) throw new NotebookError(`format ${format} is newer than this parser understands`, 2);
|
|
66
|
+
}
|
|
67
|
+
// The notebook-wide default for query cells (§2). Checked here rather than
|
|
68
|
+
// where it is applied, because the renderer is not the only thing that applies
|
|
69
|
+
// it and an unrecognised value must not depend on who read it first.
|
|
70
|
+
const rerun = frontMatter.get('rerun');
|
|
71
|
+
if (rerun !== undefined && rerun !== 'manual' && rerun !== 'auto') {
|
|
72
|
+
throw new NotebookError(`rerun: ${rerun} is not manual or auto`, 2);
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
const cells = [];
|
|
76
|
+
const ids = new Set();
|
|
77
|
+
let prose = [];
|
|
78
|
+
|
|
79
|
+
const flushProse = () => {
|
|
80
|
+
const source = trimBlankEdges(prose).join('\n');
|
|
81
|
+
if (source !== '') cells.push({ kind: 'markdown', source });
|
|
82
|
+
prose = [];
|
|
83
|
+
};
|
|
84
|
+
|
|
85
|
+
while (state.i < lines.length) {
|
|
86
|
+
const line = lines[state.i];
|
|
87
|
+
const lineNo = state.i + 1;
|
|
88
|
+
|
|
89
|
+
const fence = /^(`{3,})(.*)$/.exec(line);
|
|
90
|
+
if (fence) {
|
|
91
|
+
const block = readFence(state);
|
|
92
|
+
const info = parseInfoString(block.info, lineNo);
|
|
93
|
+
|
|
94
|
+
if (info === null || !CELL_KINDS.has(info.kind)) {
|
|
95
|
+
// Either an ordinary code block, or a cell kind from a future version.
|
|
96
|
+
// Both are kept verbatim so a v0.2 parser degrades instead of refusing.
|
|
97
|
+
if (info !== null && info.kind !== null) {
|
|
98
|
+
flushProse();
|
|
99
|
+
cells.push({ kind: 'unknown', source: block.raw });
|
|
100
|
+
} else {
|
|
101
|
+
prose.push(...block.raw.split('\n'));
|
|
102
|
+
}
|
|
103
|
+
continue;
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
flushProse();
|
|
107
|
+
|
|
108
|
+
if (info.kind === 'output') {
|
|
109
|
+
attachOutput(cells, info, block, lineNo);
|
|
110
|
+
continue;
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
const cell = info.kind === 'program'
|
|
114
|
+
? buildProgram(info, block, lineNo)
|
|
115
|
+
: buildQuery(info, block, lineNo);
|
|
116
|
+
|
|
117
|
+
if (cell.id !== null) {
|
|
118
|
+
if (!ID_RE.test(cell.id) || cell.id.length > 64) {
|
|
119
|
+
throw new NotebookError(`id "${cell.id}" is not [a-z0-9][a-z0-9-]* within 64 chars`, lineNo);
|
|
120
|
+
}
|
|
121
|
+
if (ids.has(cell.id)) throw new NotebookError(`duplicate cell id "${cell.id}"`, lineNo);
|
|
122
|
+
ids.add(cell.id);
|
|
123
|
+
}
|
|
124
|
+
// A cell held until a prediction is answered has to have one to wait for.
|
|
125
|
+
// The author declared the wait; only its SUBJECT is positional, and an
|
|
126
|
+
// author who moved the prediction away learns here rather than shipping a
|
|
127
|
+
// chapter whose answers never appear.
|
|
128
|
+
if (cell.hold === 'until-answered' && !cells.some((c) => c.variant === 'predict')) {
|
|
129
|
+
throw new NotebookError('hold="until-answered" has no prediction above it', lineNo);
|
|
130
|
+
}
|
|
131
|
+
cells.push(cell);
|
|
132
|
+
continue;
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
const container = parseContainerHead(line, lineNo);
|
|
136
|
+
if (container && CONTAINERS.has(container.variant)) {
|
|
137
|
+
flushProse();
|
|
138
|
+
cells.push(readContainer(state, container));
|
|
139
|
+
continue;
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
prose.push(line);
|
|
143
|
+
state.i++;
|
|
144
|
+
}
|
|
145
|
+
flushProse();
|
|
146
|
+
|
|
147
|
+
mintIds(cells, ids);
|
|
148
|
+
return { frontMatter, cells };
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
/**
|
|
152
|
+
* Front matter is a deliberately restricted subset of YAML — flat `key: value`,
|
|
153
|
+
* no nesting, no lists, no anchors — so that this stays ~20 lines and the package
|
|
154
|
+
* carries no YAML dependency into the browser.
|
|
155
|
+
*/
|
|
156
|
+
function parseFrontMatter(state) {
|
|
157
|
+
const map = new Map();
|
|
158
|
+
if (state.lines[0] !== '---') return map;
|
|
159
|
+
|
|
160
|
+
for (let i = 1; i < state.lines.length; i++) {
|
|
161
|
+
if (state.lines[i] === '---') {
|
|
162
|
+
state.i = i + 1;
|
|
163
|
+
// The blank line separating front matter from the body is structural.
|
|
164
|
+
if (state.lines[state.i] === '') state.i++;
|
|
165
|
+
return map;
|
|
166
|
+
}
|
|
167
|
+
const kv = /^([A-Za-z][\w-]*):\s*(.*)$/.exec(state.lines[i]);
|
|
168
|
+
if (!kv) throw new NotebookError(`front matter must be flat key: value pairs`, i + 1);
|
|
169
|
+
map.set(kv[1], kv[2]);
|
|
170
|
+
}
|
|
171
|
+
throw new NotebookError('front matter is not closed', 1);
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
/** Read a fenced block starting at state.i. Leaves state.i after the closing fence. */
|
|
175
|
+
function readFence(state) {
|
|
176
|
+
const open = /^(`{3,})(.*)$/.exec(state.lines[state.i]);
|
|
177
|
+
const ticks = open[1];
|
|
178
|
+
const info = open[2].trim();
|
|
179
|
+
const body = [];
|
|
180
|
+
let i = state.i + 1;
|
|
181
|
+
for (; i < state.lines.length; i++) {
|
|
182
|
+
if (state.lines[i].startsWith(ticks) && state.lines[i].trim() === ticks) break;
|
|
183
|
+
body.push(state.lines[i]);
|
|
184
|
+
}
|
|
185
|
+
const raw = state.lines.slice(state.i, Math.min(i + 1, state.lines.length)).join('\n');
|
|
186
|
+
state.i = i + 1;
|
|
187
|
+
return { ticks, info, body, raw };
|
|
188
|
+
}
|
|
189
|
+
|
|
190
|
+
/**
|
|
191
|
+
* `<language> <kind> [key="value"]...`
|
|
192
|
+
*
|
|
193
|
+
* Token 1 is the highlight language and exists for GitHub, which uses the first
|
|
194
|
+
* word and ignores the rest — that single fact is what makes the format degrade
|
|
195
|
+
* well. Returns null for a bare language (an ordinary code block).
|
|
196
|
+
*/
|
|
197
|
+
function parseInfoString(info, lineNo) {
|
|
198
|
+
if (info === '') return null;
|
|
199
|
+
const head = /^(\S+)(\s+)(\S+)/.exec(info);
|
|
200
|
+
if (!head) return null;
|
|
201
|
+
const [, language, gap, kind] = head;
|
|
202
|
+
if (/^[a-z][a-z0-9-]*$/.test(kind) === false) return null;
|
|
203
|
+
|
|
204
|
+
const attrs = new Map();
|
|
205
|
+
// Slice by position rather than searching for the kind: a language that happens
|
|
206
|
+
// to contain the kind as a substring would otherwise cut in the wrong place.
|
|
207
|
+
const attrText = info.slice(language.length + gap.length + kind.length);
|
|
208
|
+
const re = /([a-z][a-z0-9-]*)="((?:[^"\\]|\\.)*)"/g;
|
|
209
|
+
let consumed = 0;
|
|
210
|
+
let m;
|
|
211
|
+
while ((m = re.exec(attrText)) !== null) {
|
|
212
|
+
attrs.set(m[1], m[2].replace(/\\(["\\])/g, '$1'));
|
|
213
|
+
consumed = m.index + m[0].length;
|
|
214
|
+
}
|
|
215
|
+
if (attrText.slice(consumed).trim() !== '') {
|
|
216
|
+
throw new NotebookError(`attributes must be key="value": ${attrText.trim()}`, lineNo);
|
|
217
|
+
}
|
|
218
|
+
return { language, kind, attrs };
|
|
219
|
+
}
|
|
220
|
+
|
|
221
|
+
function buildProgram(info, block, lineNo) {
|
|
222
|
+
const source = trimBlankEdges(block.body).join('\n');
|
|
223
|
+
const src = info.attrs.get('src') ?? null;
|
|
224
|
+
if (src !== null) {
|
|
225
|
+
// Recognised and refused rather than ignored, so a future notebook fails
|
|
226
|
+
// loudly here instead of silently consulting nothing.
|
|
227
|
+
if (source !== '') throw new NotebookError('a program cell has either src= or a body, never both', lineNo);
|
|
228
|
+
throw new NotebookError('src= is specified but not implemented in this version', lineNo);
|
|
229
|
+
}
|
|
230
|
+
return {
|
|
231
|
+
kind: 'program',
|
|
232
|
+
id: info.attrs.get('id') ?? null,
|
|
233
|
+
source,
|
|
234
|
+
src,
|
|
235
|
+
language: info.language,
|
|
236
|
+
attrs: otherAttrs(info.attrs, 'program'),
|
|
237
|
+
};
|
|
238
|
+
}
|
|
239
|
+
|
|
240
|
+
function buildQuery(info, block, lineNo) {
|
|
241
|
+
// A goal may span lines; the lines are joined with a space.
|
|
242
|
+
const joined = trimBlankEdges(block.body).join(' ').trim();
|
|
243
|
+
const goal = stripTerminator(joined, lineNo);
|
|
244
|
+
if (goal === '') throw new NotebookError('a query cell needs a goal', lineNo);
|
|
245
|
+
return {
|
|
246
|
+
kind: 'query',
|
|
247
|
+
id: info.attrs.get('id') ?? null,
|
|
248
|
+
goal,
|
|
249
|
+
rerun: readRerun(info.attrs.get('rerun'), lineNo),
|
|
250
|
+
hold: readHold(info.attrs.get('hold'), lineNo),
|
|
251
|
+
language: info.language,
|
|
252
|
+
attrs: otherAttrs(info.attrs, 'query'),
|
|
253
|
+
output: null,
|
|
254
|
+
};
|
|
255
|
+
}
|
|
256
|
+
|
|
257
|
+
/**
|
|
258
|
+
* `rerun` says who decides when this cell's answers are refreshed (§5).
|
|
259
|
+
*
|
|
260
|
+
* Validated for the same reason `hold` is, and with more at stake: a
|
|
261
|
+
* `rerun="atuo"` that quietly fell back to manual would leave the author's
|
|
262
|
+
* demonstration cell showing answers from a program the reader has since edited
|
|
263
|
+
* — which is the exact failure the attribute was written to prevent, wearing the
|
|
264
|
+
* face of the fix.
|
|
265
|
+
*/
|
|
266
|
+
function readRerun(value, lineNo) {
|
|
267
|
+
if (value === undefined) return null;
|
|
268
|
+
if (value !== 'manual' && value !== 'auto') {
|
|
269
|
+
throw new NotebookError(`rerun="${value}" is not manual or auto`, lineNo);
|
|
270
|
+
}
|
|
271
|
+
return value;
|
|
272
|
+
}
|
|
273
|
+
|
|
274
|
+
/**
|
|
275
|
+
* `hold` withholds this cell's saved answers from a reader who has not earned
|
|
276
|
+
* them yet (§5). Two values, and a typo is an error rather than a silent no-op:
|
|
277
|
+
* `hold="untill-run"` that quietly did nothing would spoil the prediction it was
|
|
278
|
+
* written to protect, and the author would never find out.
|
|
279
|
+
*/
|
|
280
|
+
function readHold(value, lineNo) {
|
|
281
|
+
if (value === undefined) return null;
|
|
282
|
+
if (value !== 'until-run' && value !== 'until-answered') {
|
|
283
|
+
throw new NotebookError(`hold="${value}" is not until-run or until-answered`, lineNo);
|
|
284
|
+
}
|
|
285
|
+
return value;
|
|
286
|
+
}
|
|
287
|
+
|
|
288
|
+
/**
|
|
289
|
+
* One goal per cell, because the output block keys one solution sequence to one
|
|
290
|
+
* cell. The trailing full stop is optional and canonical form omits it, matching
|
|
291
|
+
* what a reader types at a toplevel prompt.
|
|
292
|
+
*/
|
|
293
|
+
function stripTerminator(text, lineNo) {
|
|
294
|
+
const end = findClauseEnd(text);
|
|
295
|
+
if (end === -1) return text;
|
|
296
|
+
if (text.slice(end + 1).trim() !== '') {
|
|
297
|
+
throw new NotebookError('a query cell holds exactly one goal', lineNo);
|
|
298
|
+
}
|
|
299
|
+
return text.slice(0, end).trim();
|
|
300
|
+
}
|
|
301
|
+
|
|
302
|
+
/** Index of the first `.` that ends a term: not quoted, not a decimal point. */
|
|
303
|
+
function findClauseEnd(text) {
|
|
304
|
+
let quote = null;
|
|
305
|
+
for (let i = 0; i < text.length; i++) {
|
|
306
|
+
const c = text[i];
|
|
307
|
+
if (quote) {
|
|
308
|
+
if (c === '\\') i++;
|
|
309
|
+
else if (c === quote) quote = null;
|
|
310
|
+
continue;
|
|
311
|
+
}
|
|
312
|
+
if (c === "'" || c === '"' || c === '`') { quote = c; continue; }
|
|
313
|
+
if (c === '.') {
|
|
314
|
+
const before = text[i - 1];
|
|
315
|
+
const after = text[i + 1];
|
|
316
|
+
if (/\d/.test(before ?? '') && /\d/.test(after ?? '')) continue;
|
|
317
|
+
if (after === undefined || /\s/.test(after)) return i;
|
|
318
|
+
}
|
|
319
|
+
}
|
|
320
|
+
return -1;
|
|
321
|
+
}
|
|
322
|
+
|
|
323
|
+
/**
|
|
324
|
+
* Attachment is structural: an output block belongs to the cell it immediately
|
|
325
|
+
* follows, and in the model it is not a cell at all but a property of the query.
|
|
326
|
+
* That makes an orphaned output — a stale answer attached to nothing — impossible
|
|
327
|
+
* to construct rather than merely discouraged.
|
|
328
|
+
*/
|
|
329
|
+
function attachOutput(cells, info, block, lineNo) {
|
|
330
|
+
const previous = cells[cells.length - 1];
|
|
331
|
+
if (!previous || previous.kind !== 'query') {
|
|
332
|
+
throw new NotebookError('an output block must follow a query cell', lineNo);
|
|
333
|
+
}
|
|
334
|
+
if (previous.output !== null) {
|
|
335
|
+
throw new NotebookError(`query "${previous.id}" already has an output block`, lineNo);
|
|
336
|
+
}
|
|
337
|
+
const declared = info.attrs.get('for');
|
|
338
|
+
// `for` is redundant with position, and stays: redundancy is the check that
|
|
339
|
+
// catches a GUI bug, and it makes the block self-describing on GitHub.
|
|
340
|
+
if (declared !== undefined && previous.id !== null && declared !== previous.id) {
|
|
341
|
+
throw new NotebookError(`output for="${declared}" follows query "${previous.id}"`, lineNo);
|
|
342
|
+
}
|
|
343
|
+
const body = trimBlankEdges(block.body);
|
|
344
|
+
// An output block with nothing in it claims a query has answers and then shows
|
|
345
|
+
// none. There is no reading of that which is true: a query with no answers to
|
|
346
|
+
// show simply has no output block, and that is already valid (§6).
|
|
347
|
+
if (body.length === 0) {
|
|
348
|
+
throw new NotebookError(`output for="${previous.id ?? ''}" is empty`, lineNo);
|
|
349
|
+
}
|
|
350
|
+
previous.output = {
|
|
351
|
+
...parseSolutions(body),
|
|
352
|
+
inputHash: info.attrs.get('input-hash') ?? null,
|
|
353
|
+
language: info.language,
|
|
354
|
+
attrs: otherAttrs(info.attrs, 'output'),
|
|
355
|
+
};
|
|
356
|
+
}
|
|
357
|
+
|
|
358
|
+
/**
|
|
359
|
+
* The body is SWI's own toplevel spelling. We store the SEQUENCE rather than a
|
|
360
|
+
* blob because `; next` must replay saved solutions one at a time with no engine
|
|
361
|
+
* present — the stepping has to survive on a cold page.
|
|
362
|
+
*
|
|
363
|
+
* Bindings can wrap across lines, so a solution is every line up to and including
|
|
364
|
+
* the one ending in ` ;`.
|
|
365
|
+
*
|
|
366
|
+
* A sequence whose LAST line ends in ` ;` has no terminator, and that is the
|
|
367
|
+
* format's spelling for NOT EXHAUSTED (§6): the reader took three of six and
|
|
368
|
+
* stopped, or the author is showing the first four of infinitely many. It falls
|
|
369
|
+
* out of the loop below rather than being detected — the last ` ;` closes a
|
|
370
|
+
* solution and leaves `current` empty, so `terminator` stays `''`, which is the
|
|
371
|
+
* one value no finished sequence can have.
|
|
372
|
+
*/
|
|
373
|
+
function parseSolutions(bodyLines) {
|
|
374
|
+
const solutions = [];
|
|
375
|
+
let current = [];
|
|
376
|
+
let terminator = '';
|
|
377
|
+
for (let i = 0; i < bodyLines.length; i++) {
|
|
378
|
+
const line = bodyLines[i];
|
|
379
|
+
if (line.endsWith(' ;')) {
|
|
380
|
+
current.push(line.slice(0, -2));
|
|
381
|
+
solutions.push(current.join('\n'));
|
|
382
|
+
current = [];
|
|
383
|
+
} else if (i === bodyLines.length - 1) {
|
|
384
|
+
current.push(line);
|
|
385
|
+
terminator = current.join('\n');
|
|
386
|
+
} else {
|
|
387
|
+
current.push(line);
|
|
388
|
+
}
|
|
389
|
+
}
|
|
390
|
+
return { solutions, terminator };
|
|
391
|
+
}
|
|
392
|
+
|
|
393
|
+
function parseContainerHead(line, lineNo) {
|
|
394
|
+
const m = /^> \[!([a-z][a-z0-9-]*)([^\]]*)\](.*)$/.exec(line);
|
|
395
|
+
if (!m) return null;
|
|
396
|
+
const attrs = new Map();
|
|
397
|
+
if (m[2].trim() !== '') {
|
|
398
|
+
const re = /([a-z][a-z0-9-]*)="((?:[^"\\]|\\.)*)"/g;
|
|
399
|
+
let hit;
|
|
400
|
+
while ((hit = re.exec(m[2])) !== null) attrs.set(hit[1], hit[2].replace(/\\(["\\])/g, '$1'));
|
|
401
|
+
if (attrs.size === 0) throw new NotebookError(`unparsable container attributes: ${m[2]}`, lineNo);
|
|
402
|
+
}
|
|
403
|
+
return { variant: m[1], attrs, title: m[3].trim() };
|
|
404
|
+
}
|
|
405
|
+
|
|
406
|
+
function readContainer(state, head) {
|
|
407
|
+
const body = [];
|
|
408
|
+
state.i++;
|
|
409
|
+
while (state.i < state.lines.length) {
|
|
410
|
+
const line = state.lines[state.i];
|
|
411
|
+
if (line === '>') { body.push(''); state.i++; continue; }
|
|
412
|
+
if (line.startsWith('> ')) { body.push(line.slice(2)); state.i++; continue; }
|
|
413
|
+
break;
|
|
414
|
+
}
|
|
415
|
+
return {
|
|
416
|
+
kind: 'container',
|
|
417
|
+
variant: head.variant,
|
|
418
|
+
title: head.title,
|
|
419
|
+
body: trimBlankEdges(body).join('\n'),
|
|
420
|
+
attrs: head.attrs,
|
|
421
|
+
};
|
|
422
|
+
}
|
|
423
|
+
|
|
424
|
+
/** Attributes we did not interpret, kept in read order so nothing is lost on write-back. */
|
|
425
|
+
function otherAttrs(attrs, kind) {
|
|
426
|
+
const known = new Set(ATTR_ORDER[kind]);
|
|
427
|
+
if (kind === 'output') known.add('for').add('input-hash');
|
|
428
|
+
const rest = new Map();
|
|
429
|
+
for (const [k, v] of attrs) if (!known.has(k)) rest.set(k, v);
|
|
430
|
+
return rest;
|
|
431
|
+
}
|
|
432
|
+
|
|
433
|
+
/**
|
|
434
|
+
* `id` is optional in hand-written source and required in canonical form. Minted
|
|
435
|
+
* ids are held in memory until something writes the file back, at which point the
|
|
436
|
+
* chapter self-heals — hand-write it, run it once, the ids are there.
|
|
437
|
+
*/
|
|
438
|
+
function mintIds(cells, taken) {
|
|
439
|
+
const counters = { program: 0, query: 0 };
|
|
440
|
+
for (const cell of cells) {
|
|
441
|
+
if (cell.kind !== 'program' && cell.kind !== 'query') continue;
|
|
442
|
+
if (cell.id !== null) continue;
|
|
443
|
+
const prefix = cell.kind === 'program' ? 'p' : 'q';
|
|
444
|
+
let id;
|
|
445
|
+
do { id = `${prefix}-${++counters[cell.kind]}`; } while (taken.has(id));
|
|
446
|
+
taken.add(id);
|
|
447
|
+
cell.id = id;
|
|
448
|
+
}
|
|
449
|
+
}
|
|
450
|
+
|
|
451
|
+
function trimBlankEdges(lines) {
|
|
452
|
+
let start = 0;
|
|
453
|
+
let end = lines.length;
|
|
454
|
+
while (start < end && lines[start].trim() === '') start++;
|
|
455
|
+
while (end > start && lines[end - 1].trim() === '') end--;
|
|
456
|
+
return lines.slice(start, end);
|
|
457
|
+
}
|
|
458
|
+
|
|
459
|
+
// ------------------------------------------------------------ serialising
|
|
460
|
+
|
|
461
|
+
/**
|
|
462
|
+
* Model back to markdown, canonically.
|
|
463
|
+
*
|
|
464
|
+
* Stronger than round-trip: round-trip says do not corrupt what is there, this
|
|
465
|
+
* says agree on what to write, so a cell inserted by a GUI and one typed by a
|
|
466
|
+
* human produce the same bytes and commits stop alternating between two
|
|
467
|
+
* spellings of the same file.
|
|
468
|
+
*
|
|
469
|
+
* @param {{frontMatter: Map<string, string>, cells: object[]}} notebook
|
|
470
|
+
* @returns {string}
|
|
471
|
+
*/
|
|
472
|
+
export function serialise(notebook) {
|
|
473
|
+
const parts = [];
|
|
474
|
+
|
|
475
|
+
if (notebook.frontMatter.size > 0) {
|
|
476
|
+
const fm = ['---'];
|
|
477
|
+
for (const [k, v] of notebook.frontMatter) fm.push(`${k}: ${v}`);
|
|
478
|
+
fm.push('---');
|
|
479
|
+
parts.push(fm.join('\n'));
|
|
480
|
+
}
|
|
481
|
+
|
|
482
|
+
for (const cell of notebook.cells) {
|
|
483
|
+
switch (cell.kind) {
|
|
484
|
+
case 'markdown':
|
|
485
|
+
case 'unknown':
|
|
486
|
+
parts.push(cell.source);
|
|
487
|
+
break;
|
|
488
|
+
case 'program':
|
|
489
|
+
parts.push(fenced(cell.language ?? 'prolog', 'program', orderedAttrs(cell, 'program'), cell.source));
|
|
490
|
+
break;
|
|
491
|
+
case 'query': {
|
|
492
|
+
parts.push(fenced(cell.language ?? 'prolog', 'query', orderedAttrs(cell, 'query'), cell.goal));
|
|
493
|
+
if (cell.output) {
|
|
494
|
+
// No terminator means the sequence was never exhausted, and the file
|
|
495
|
+
// says so by ending on a ` ;` — writing a blank line in its place
|
|
496
|
+
// would not round-trip, since the parser trims blank edges.
|
|
497
|
+
const solutions = cell.output.solutions.map((s) => `${s} ;`);
|
|
498
|
+
const body = cell.output.terminator
|
|
499
|
+
? [...solutions, cell.output.terminator]
|
|
500
|
+
: solutions;
|
|
501
|
+
parts.push(fenced(
|
|
502
|
+
cell.output.language ?? 'text',
|
|
503
|
+
'output',
|
|
504
|
+
orderedAttrs(cell, 'output'),
|
|
505
|
+
body.join('\n')
|
|
506
|
+
));
|
|
507
|
+
}
|
|
508
|
+
break;
|
|
509
|
+
}
|
|
510
|
+
case 'container': {
|
|
511
|
+
const head = `> [!${cell.variant}${containerAttrs(cell.attrs)}]${cell.title ? ` ${cell.title}` : ''}`;
|
|
512
|
+
const body = cell.body === '' ? [] : cell.body.split('\n').map((l) => (l === '' ? '>' : `> ${l}`));
|
|
513
|
+
parts.push([head, ...body].join('\n'));
|
|
514
|
+
break;
|
|
515
|
+
}
|
|
516
|
+
default:
|
|
517
|
+
throw new NotebookError(`cannot serialise cell kind "${cell.kind}"`);
|
|
518
|
+
}
|
|
519
|
+
}
|
|
520
|
+
|
|
521
|
+
// Exactly one blank line between blocks. Runs of blank lines *within* prose are
|
|
522
|
+
// the author's and are interior to a markdown cell, so they survive untouched.
|
|
523
|
+
return `${parts.join('\n\n')}\n`;
|
|
524
|
+
}
|
|
525
|
+
|
|
526
|
+
function orderedAttrs(cell, kind) {
|
|
527
|
+
const out = new Map();
|
|
528
|
+
if (kind === 'program') {
|
|
529
|
+
out.set('id', cell.id);
|
|
530
|
+
if (cell.src) out.set('src', cell.src);
|
|
531
|
+
for (const [k, v] of cell.attrs) out.set(k, v);
|
|
532
|
+
} else if (kind === 'query') {
|
|
533
|
+
out.set('id', cell.id);
|
|
534
|
+
if (cell.rerun) out.set('rerun', cell.rerun);
|
|
535
|
+
if (cell.hold) out.set('hold', cell.hold);
|
|
536
|
+
for (const [k, v] of cell.attrs) out.set(k, v);
|
|
537
|
+
} else {
|
|
538
|
+
out.set('for', cell.id);
|
|
539
|
+
if (cell.output.inputHash) out.set('input-hash', cell.output.inputHash);
|
|
540
|
+
for (const [k, v] of cell.output.attrs) out.set(k, v);
|
|
541
|
+
}
|
|
542
|
+
return out;
|
|
543
|
+
}
|
|
544
|
+
|
|
545
|
+
function containerAttrs(attrs) {
|
|
546
|
+
if (!attrs || attrs.size === 0) return '';
|
|
547
|
+
return ` ${[...attrs].map(([k, v]) => `${k}="${escapeAttr(v)}"`).join(' ')}`;
|
|
548
|
+
}
|
|
549
|
+
|
|
550
|
+
function fenced(language, kind, attrs, body) {
|
|
551
|
+
const pairs = [...attrs].map(([k, v]) => `${k}="${escapeAttr(v)}"`);
|
|
552
|
+
const info = [language, kind, ...pairs].join(' ');
|
|
553
|
+
// Three backticks always, lengthened only if the content itself forces it.
|
|
554
|
+
let ticks = '```';
|
|
555
|
+
const longest = body.split('\n').reduce((n, line) => {
|
|
556
|
+
const m = /^(`{3,})/.exec(line);
|
|
557
|
+
return m ? Math.max(n, m[1].length) : n;
|
|
558
|
+
}, 0);
|
|
559
|
+
if (longest >= 3) ticks = '`'.repeat(longest + 1);
|
|
560
|
+
return body === '' ? `${ticks}${info}\n${ticks}` : `${ticks}${info}\n${body}\n${ticks}`;
|
|
561
|
+
}
|
|
562
|
+
|
|
563
|
+
function escapeAttr(value) {
|
|
564
|
+
return String(value).replace(/\\/g, '\\\\').replace(/"/g, '\\"');
|
|
565
|
+
}
|
|
566
|
+
|
|
567
|
+
// ------------------------------------------------------------ input hash
|
|
568
|
+
|
|
569
|
+
const FNV_OFFSET = 0xcbf29ce484222325n;
|
|
570
|
+
const FNV_PRIME = 0x100000001b3n;
|
|
571
|
+
const MASK = 0xffffffffffffffffn;
|
|
572
|
+
|
|
573
|
+
/**
|
|
574
|
+
* FNV-1a, 64-bit, over the UTF-8 bytes of the digest string:
|
|
575
|
+
*
|
|
576
|
+
* <goal>\0 ( <cell-id>\0 <resolved-source>\0 )*
|
|
577
|
+
*
|
|
578
|
+
* for every program cell preceding the query in document order. Pinned exactly,
|
|
579
|
+
* because it is a file-format constant.
|
|
580
|
+
*
|
|
581
|
+
* Not a security boundary and not trying to be — a notebook that lies about its
|
|
582
|
+
* outputs can lie about the hash too. It is a change detector, and a fast
|
|
583
|
+
* dependency-free sync hash beats SHA-256 via WebCrypto, which is async in the
|
|
584
|
+
* browser and would push staleness past first paint for no benefit.
|
|
585
|
+
*
|
|
586
|
+
* @param {string} goal canonical goal text
|
|
587
|
+
* @param {{id: string, source: string}[]} programCells preceding program cells, in order
|
|
588
|
+
* @returns {string} 16 lowercase hex digits
|
|
589
|
+
*/
|
|
590
|
+
export function inputHash(goal, programCells) {
|
|
591
|
+
let digest = `${goal}\u0000`;
|
|
592
|
+
for (const cell of programCells) digest += `${cell.id}\u0000${cell.source}\u0000`;
|
|
593
|
+
|
|
594
|
+
const bytes = new TextEncoder().encode(digest);
|
|
595
|
+
let hash = FNV_OFFSET;
|
|
596
|
+
for (const byte of bytes) {
|
|
597
|
+
hash = (hash ^ BigInt(byte)) * FNV_PRIME & MASK;
|
|
598
|
+
}
|
|
599
|
+
return hash.toString(16).padStart(16, '0');
|
|
600
|
+
}
|
|
601
|
+
|
|
602
|
+
/**
|
|
603
|
+
* A run's answers in the format's own spelling (§6).
|
|
604
|
+
*
|
|
605
|
+
* ONE PLACE, because two callers need it and they must not drift: the page,
|
|
606
|
+
* where a reader ran a cell and may download the result, and the CLI runner,
|
|
607
|
+
* which fills a chapter's answers in before it is published. A file written by
|
|
608
|
+
* one and read by the other has to mean the same thing.
|
|
609
|
+
*
|
|
610
|
+
* Three answers, and the distinctions are the point:
|
|
611
|
+
* null nothing to write down at all — no answers, no failure, no
|
|
612
|
+
* exhausted search. A query cell with no output block is valid.
|
|
613
|
+
* an object `solutions` are the lines that end in ` ;`, `terminator` is the
|
|
614
|
+
* final line. An EMPTY terminator is the format's way of saying the
|
|
615
|
+
* search was never exhausted: the reader stopped part-way, or the
|
|
616
|
+
* runner hit its limit. Writing `false.` there would forge an
|
|
617
|
+
* exhaustion, which is the one thing we may not do.
|
|
618
|
+
*
|
|
619
|
+
* The last solution IS the terminator when a query ran to the end, because that
|
|
620
|
+
* is what a toplevel prints; replaySolutions() reads it back the same way, which
|
|
621
|
+
* is what keeps a downloaded file rendering identically to the page it came from.
|
|
622
|
+
*
|
|
623
|
+
* @param {{solutions?: string[], exhausted?: boolean, error?: string|null}} run
|
|
624
|
+
* @returns {{solutions: string[], terminator: string}|null}
|
|
625
|
+
*/
|
|
626
|
+
export function solutionSequence({ solutions = [], exhausted = false, error = null } = {}) {
|
|
627
|
+
if (error) return { solutions, terminator: `ERROR: ${error}` };
|
|
628
|
+
if (!exhausted) return solutions.length ? { solutions, terminator: '' } : null;
|
|
629
|
+
if (!solutions.length) return { solutions: [], terminator: 'false.' };
|
|
630
|
+
return { solutions: solutions.slice(0, -1), terminator: `${solutions[solutions.length - 1]}.` };
|
|
631
|
+
}
|
|
632
|
+
|
|
633
|
+
/**
|
|
634
|
+
* v0.2 hashes against ALL preceding program cells rather than the dependency
|
|
635
|
+
* closure. It over-approximates — editing an unrelated earlier cell marks a query
|
|
636
|
+
* stale — which is the cheap, correct-by-construction side to be wrong on.
|
|
637
|
+
*
|
|
638
|
+
* @param {{cells: object[]}} notebook
|
|
639
|
+
* @param {object} queryCell
|
|
640
|
+
* @returns {string}
|
|
641
|
+
*/
|
|
642
|
+
export function hashFor(notebook, queryCell) {
|
|
643
|
+
const preceding = [];
|
|
644
|
+
for (const cell of notebook.cells) {
|
|
645
|
+
if (cell === queryCell) break;
|
|
646
|
+
if (cell.kind === 'program') preceding.push({ id: cell.id, source: cell.source });
|
|
647
|
+
}
|
|
648
|
+
return inputHash(queryCell.goal, preceding);
|
|
649
|
+
}
|
package/src/node.js
CHANGED
|
@@ -1,10 +1,20 @@
|
|
|
1
|
-
// Node entry point: boots the engine with the swipl-wasm Node build
|
|
1
|
+
// Node entry point: boots the engine with the swipl-wasm Node build, in this
|
|
2
|
+
// process.
|
|
3
|
+
//
|
|
4
|
+
// Same async interface as the browser (src/session.js), so the CLI, the tests and
|
|
5
|
+
// a VS Code controller are all written the same way — but WITHOUT the worker, so
|
|
6
|
+
// a non-terminating goal blocks this process. That is a deliberate, stated limit
|
|
7
|
+
// rather than an oversight: the browser is where a frozen thread costs a reader
|
|
8
|
+
// their tab, and where the worker therefore earns its complexity.
|
|
2
9
|
import SWIPL from 'swipl-wasm/dist/swipl-node.js';
|
|
3
10
|
import { PrologSession } from './engine.js';
|
|
11
|
+
import { InProcessSession } from './session.js';
|
|
4
12
|
|
|
5
13
|
export * from './engine.js';
|
|
14
|
+
export { InProcessSession, ConsultLog } from './session.js';
|
|
6
15
|
|
|
7
|
-
/** @returns {Promise<
|
|
8
|
-
export function createSession(options = {}) {
|
|
9
|
-
|
|
16
|
+
/** @returns {Promise<InProcessSession>} a session running in this Node process. */
|
|
17
|
+
export async function createSession(options = {}) {
|
|
18
|
+
const build = () => PrologSession.create(SWIPL, options);
|
|
19
|
+
return new InProcessSession(await build(), build);
|
|
10
20
|
}
|