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/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<PrologSession>} a session running in this Node process. */
8
- export function createSession(options = {}) {
9
- return PrologSession.create(SWIPL, options);
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
  }