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/engine.js
CHANGED
|
@@ -6,11 +6,29 @@
|
|
|
6
6
|
|
|
7
7
|
let fileSerial = 0;
|
|
8
8
|
|
|
9
|
-
//
|
|
10
|
-
//
|
|
11
|
-
//
|
|
12
|
-
//
|
|
13
|
-
|
|
9
|
+
// What we teach the engine about itself, consulted once at startup.
|
|
10
|
+
//
|
|
11
|
+
// TWO THINGS, and both are about telling the truth to a reader.
|
|
12
|
+
//
|
|
13
|
+
// 1. SWI reports "Redefined static procedure" and friends by printing to
|
|
14
|
+
// user_error and then carrying on, so a consult that quietly destroyed
|
|
15
|
+
// another cell's clauses still succeeds. message_hook/3 is the documented way
|
|
16
|
+
// to intercept those; failing at the end lets the normal printing happen too.
|
|
17
|
+
//
|
|
18
|
+
// 2. AN ANSWER IS RENDERED BY PROLOG, NOT BY US. Formatting each binding in a
|
|
19
|
+
// separate round trip loses the one thing a Prolog answer is mostly about —
|
|
20
|
+
// which variables are the SAME variable. `app([1,2], Tail, L)` really does
|
|
21
|
+
// print `L = [1, 2|Tail]` at a toplevel, and we printed
|
|
22
|
+
// `L = [1,2|_20306], Tail = _20428`: two differently-numbered variables
|
|
23
|
+
// where there is one, with the reader's own name for it thrown away
|
|
24
|
+
// (869erjw27). For a chapter about partial lists that is the opposite of the
|
|
25
|
+
// lesson.
|
|
26
|
+
//
|
|
27
|
+
// The rule the toplevel follows, and this reproduces: a variable is NAMED by the
|
|
28
|
+
// last binding that mentions it, and that binding is then omitted — so `X = Y`
|
|
29
|
+
// prints as `X = Y`, and an unbound `Tail` disappears from the list of bindings
|
|
30
|
+
// and reappears inside `L`. Anything still unnamed becomes _A, _B, … as SWI does.
|
|
31
|
+
const HOOK = String.raw`
|
|
14
32
|
:- dynamic '$nb_message'/2.
|
|
15
33
|
user:message_hook(_Term, Kind, Lines) :-
|
|
16
34
|
memberchk(Kind, [warning, error]),
|
|
@@ -19,6 +37,68 @@ user:message_hook(_Term, Kind, Lines) :-
|
|
|
19
37
|
_, S = ''),
|
|
20
38
|
assertz('$nb_message'(Kind, S)),
|
|
21
39
|
fail.
|
|
40
|
+
|
|
41
|
+
% One solution: the text a toplevel would print, and the bindings by name.
|
|
42
|
+
% The goal arrives as a STRING BOUND TO A VARIABLE, never interpolated into this
|
|
43
|
+
% query, so a goal containing quotes or brackets needs no escaping anywhere.
|
|
44
|
+
'$nb_answer'(GoalText, Text, Names, Values) :-
|
|
45
|
+
read_term_from_atom(GoalText, Goal, [variable_names(Bindings)]),
|
|
46
|
+
call(Goal),
|
|
47
|
+
'$nb_render'(Bindings, Text),
|
|
48
|
+
findall(N, member(N=_, Bindings), Names),
|
|
49
|
+
findall(V, member(_=V, Bindings), Values).
|
|
50
|
+
|
|
51
|
+
'$nb_render'(Bindings, Text) :-
|
|
52
|
+
'$nb_names'(Bindings, Bindings, Named),
|
|
53
|
+
exclude('$nb_named_itself'(Named), Bindings, Shown),
|
|
54
|
+
( Shown == []
|
|
55
|
+
-> Text = true
|
|
56
|
+
; '$nb_anonymous'(Shown, Named, All),
|
|
57
|
+
maplist('$nb_pair'(All), Shown, Parts),
|
|
58
|
+
% An ATOM, not a string: swipl-wasm hands an atom to JavaScript as a
|
|
59
|
+
% plain string and a Prolog string as a wrapper object, and one
|
|
60
|
+
% representation crossing the boundary is one fewer thing to unwrap.
|
|
61
|
+
atomic_list_concat(Parts, ', ', Text)
|
|
62
|
+
).
|
|
63
|
+
|
|
64
|
+
% Each unbound variable takes the LAST name bound to it.
|
|
65
|
+
'$nb_names'([], _, []).
|
|
66
|
+
'$nb_names'([Name=Value|T], All, Named) :-
|
|
67
|
+
( var(Value),
|
|
68
|
+
'$nb_last_name'(All, Value, Name)
|
|
69
|
+
-> Named = [Name=Value|Rest]
|
|
70
|
+
; Named = Rest
|
|
71
|
+
),
|
|
72
|
+
'$nb_names'(T, All, Rest).
|
|
73
|
+
|
|
74
|
+
'$nb_last_name'(All, Var, Name) :-
|
|
75
|
+
findall(N, (member(N=V, All), V == Var), Names),
|
|
76
|
+
last(Names, Name).
|
|
77
|
+
|
|
78
|
+
% A binding that only says "this variable is called what it is called".
|
|
79
|
+
'$nb_named_itself'(Named, Name=Value) :-
|
|
80
|
+
var(Value),
|
|
81
|
+
member(N=V, Named),
|
|
82
|
+
V == Value,
|
|
83
|
+
N == Name.
|
|
84
|
+
|
|
85
|
+
'$nb_anonymous'(Shown, Named, All) :-
|
|
86
|
+
term_variables(Shown, Vars),
|
|
87
|
+
exclude('$nb_has_name'(Named), Vars, Unnamed),
|
|
88
|
+
findall(A, ( between(0'A, 0'Z, C), char_code(Ch, C), atom_concat('_', Ch, A) ), Alphabet),
|
|
89
|
+
'$nb_zip'(Unnamed, Alphabet, Extra),
|
|
90
|
+
append(Named, Extra, All).
|
|
91
|
+
|
|
92
|
+
'$nb_has_name'(Named, Var) :- member(_=V, Named), V == Var.
|
|
93
|
+
|
|
94
|
+
'$nb_zip'([], _, []).
|
|
95
|
+
'$nb_zip'([V|Vs], [N|Ns], [N=V|T]) :- '$nb_zip'(Vs, Ns, T).
|
|
96
|
+
|
|
97
|
+
'$nb_pair'(Names, Name=Value, Part) :-
|
|
98
|
+
with_output_to(string(S),
|
|
99
|
+
write_term(Value, [ quoted(true), portray(true), numbervars(true),
|
|
100
|
+
spacing(next_argument), variable_names(Names) ])),
|
|
101
|
+
format(atom(Part), '~w = ~w', [Name, S]).
|
|
22
102
|
`;
|
|
23
103
|
|
|
24
104
|
export class PrologSession {
|
|
@@ -31,7 +111,7 @@ export class PrologSession {
|
|
|
31
111
|
const session = new PrologSession(module);
|
|
32
112
|
// No `$` in the path: SWI expands $var in file names like a shell does, so
|
|
33
113
|
// /$nb-hook.pl resolves to nothing and the consult fails silently.
|
|
34
|
-
module.FS.writeFile('/nb-hook.pl',
|
|
114
|
+
module.FS.writeFile('/nb-hook.pl', HOOK);
|
|
35
115
|
module.prolog.query("user:consult('/nb-hook.pl')").once();
|
|
36
116
|
return session;
|
|
37
117
|
}
|
|
@@ -77,10 +157,16 @@ export class PrologSession {
|
|
|
77
157
|
* @returns {PrologQuery}
|
|
78
158
|
*/
|
|
79
159
|
query(goal) {
|
|
80
|
-
//
|
|
81
|
-
//
|
|
160
|
+
// The goal is BOUND, not interpolated: `'$nb_answer'` reads it in the `user`
|
|
161
|
+
// module (where cells consult) and renders each solution there, so operators,
|
|
162
|
+
// quoting and shared variables are SWI's own work rather than ours. It also
|
|
163
|
+
// means a goal containing quotes or brackets needs no escaping at any point.
|
|
82
164
|
const cleaned = goal.trim().replace(/\.$/, '');
|
|
83
|
-
|
|
165
|
+
const handle = this.module.prolog.query(
|
|
166
|
+
"user:'$nb_answer'(G, Text, Names, Values)",
|
|
167
|
+
{ G: cleaned }
|
|
168
|
+
);
|
|
169
|
+
return new PrologQuery(handle, this);
|
|
84
170
|
}
|
|
85
171
|
|
|
86
172
|
/**
|
|
@@ -131,9 +217,38 @@ export class PrologQuery {
|
|
|
131
217
|
this.handle = handle;
|
|
132
218
|
this.session = session;
|
|
133
219
|
this.exhausted = false;
|
|
220
|
+
// Ended by something other than its own search — see close(). Kept apart from
|
|
221
|
+
// `exhausted` because "the search finished" and "we stopped it" are different
|
|
222
|
+
// facts, and only the first may ever be written down as `false.` (format §6).
|
|
223
|
+
this.superseded = false;
|
|
134
224
|
this.count = 0;
|
|
135
225
|
}
|
|
136
226
|
|
|
227
|
+
/**
|
|
228
|
+
* Give the query frame back to the engine.
|
|
229
|
+
*
|
|
230
|
+
* SWI KEEPS OPEN QUERIES ON A STACK, and swipl-wasm enforces it: both
|
|
231
|
+
* `next()` and `close()` call `__must_be_innermost_query`, which throws
|
|
232
|
+
* "Attempt to access not innermost query". An abandoned query is therefore not
|
|
233
|
+
* merely untidy — it is a frame every later query has to nest inside, and the
|
|
234
|
+
* abandoned one can never be stepped again (869epzqpc).
|
|
235
|
+
*
|
|
236
|
+
* Nothing else releases it. Running the search to `done` does, because
|
|
237
|
+
* swipl-wasm closes the query itself at that point, which is why a drained
|
|
238
|
+
* sequence costs nothing. Everything else must come through here.
|
|
239
|
+
*
|
|
240
|
+
* @param {{superseded?: boolean}} [options] `superseded` when the session
|
|
241
|
+
* closed this to make room for another query rather than the caller being
|
|
242
|
+
* finished with it. It changes what next() reports, and it must: a caller
|
|
243
|
+
* that reads plain `done` concludes the search was exhausted.
|
|
244
|
+
*/
|
|
245
|
+
close({ superseded = false } = {}) {
|
|
246
|
+
if (superseded) this.superseded = true;
|
|
247
|
+
if (this.exhausted) return;
|
|
248
|
+
this.exhausted = true;
|
|
249
|
+
this.handle.close();
|
|
250
|
+
}
|
|
251
|
+
|
|
137
252
|
/**
|
|
138
253
|
* Pull the next solution.
|
|
139
254
|
*
|
|
@@ -144,17 +259,20 @@ export class PrologQuery {
|
|
|
144
259
|
* @returns {{done: boolean, solution?: object, text?: string, error?: string}}
|
|
145
260
|
*/
|
|
146
261
|
next() {
|
|
147
|
-
|
|
262
|
+
// `superseded` travels with the done, because a closed query and an exhausted
|
|
263
|
+
// one are indistinguishable from `{done: true}` alone — and the difference is
|
|
264
|
+
// whether the caller may write `false.` under it.
|
|
265
|
+
if (this.exhausted) return this.superseded ? { done: true, superseded: true } : { done: true };
|
|
148
266
|
let r;
|
|
149
267
|
try {
|
|
150
268
|
r = this.handle.next();
|
|
151
269
|
} catch (e) {
|
|
152
270
|
this.exhausted = true;
|
|
153
|
-
return { done: true, error: e.message };
|
|
271
|
+
return { done: true, error: readableError(e.message) };
|
|
154
272
|
}
|
|
155
273
|
if (r.error) {
|
|
156
274
|
this.exhausted = true;
|
|
157
|
-
return { done: true, error: r.message };
|
|
275
|
+
return { done: true, error: readableError(r.message) };
|
|
158
276
|
}
|
|
159
277
|
|
|
160
278
|
// The engine can deliver the final solution *together with* done:true — a
|
|
@@ -163,10 +281,11 @@ export class PrologQuery {
|
|
|
163
281
|
const out = { done: !!r.done };
|
|
164
282
|
if (r.value) {
|
|
165
283
|
this.count += 1;
|
|
166
|
-
|
|
167
|
-
//
|
|
168
|
-
//
|
|
169
|
-
|
|
284
|
+
// `Text` is what a toplevel would print for this solution, rendered inside
|
|
285
|
+
// Prolog (see HOOK). `Names`/`Values` are the same answer as data, for a
|
|
286
|
+
// caller that wants the bindings rather than the line.
|
|
287
|
+
out.text = r.value.Text;
|
|
288
|
+
out.solution = zipBindings(r.value.Names, r.value.Values);
|
|
170
289
|
}
|
|
171
290
|
if (r.done) this.exhausted = true;
|
|
172
291
|
return out;
|
|
@@ -188,7 +307,74 @@ export class PrologQuery {
|
|
|
188
307
|
}
|
|
189
308
|
}
|
|
190
309
|
|
|
310
|
+
/**
|
|
311
|
+
* Drop the frame that is ours rather than the reader's.
|
|
312
|
+
*
|
|
313
|
+
* SWI prefixes an error with the goal it was raised from, which is usually worth
|
|
314
|
+
* keeping — `//2: Arithmetic: evaluation error` names the division. But every
|
|
315
|
+
* goal we run is wrapped for the WASM boundary, so an unknown predicate reads
|
|
316
|
+
* `wasm:wasm_call_string/3: Unknown procedure: is_son/1`. That prefix is our
|
|
317
|
+
* plumbing in the middle of a teaching page: the reader did not write it, cannot
|
|
318
|
+
* act on it, and it is the same words whatever they got wrong.
|
|
319
|
+
*
|
|
320
|
+
* Only that one frame is removed. Any other context is the reader's own code.
|
|
321
|
+
*/
|
|
322
|
+
export function readableError(message) {
|
|
323
|
+
return String(message ?? '')
|
|
324
|
+
.replace(/^wasm:wasm_call_string\/\d+:\s*/, '')
|
|
325
|
+
// Our own wrapper, which the reader did not write and cannot act on. It is
|
|
326
|
+
// the same plumbing argument as the frame above: `'$nb_answer'/4: Unknown
|
|
327
|
+
// procedure: son_a/1` names a predicate of ours in the middle of a teaching
|
|
328
|
+
// page.
|
|
329
|
+
.replace(/^'\$nb_answer'\/\d+:\s*/, '');
|
|
330
|
+
}
|
|
331
|
+
|
|
332
|
+
/**
|
|
333
|
+
* A message about THIS cell, said the way the cell would say it.
|
|
334
|
+
*
|
|
335
|
+
* One cell is one virtual file, so SWI's line numbers are already the cell's own
|
|
336
|
+
* — that half of the problem solved itself. What is left is the path: a reader
|
|
337
|
+
* looking at a syntax error printed on the very cell that caused it does not need
|
|
338
|
+
* to be told which file it was in, and `/p-family.pl` is a filename they never
|
|
339
|
+
* chose and cannot open.
|
|
340
|
+
*
|
|
341
|
+
* /p-family.pl:4:6: Syntax error: Operator expected
|
|
342
|
+
* line 4, column 6: Syntax error: Operator expected
|
|
343
|
+
*
|
|
344
|
+
* ONLY THIS CELL'S OWN PATH IS REMOVED, which is the whole reason the name is a
|
|
345
|
+
* parameter rather than a wildcard. A consult warning naming a DIFFERENT cell —
|
|
346
|
+
* "Redefined static procedure male/1", the one that says another cell's clauses
|
|
347
|
+
* have just been destroyed — is only useful because it names that other file, and
|
|
348
|
+
* a regex that stripped any path would delete exactly the part worth reading.
|
|
349
|
+
*
|
|
350
|
+
* @param {string} message
|
|
351
|
+
* @param {string} name the cell's own consult name
|
|
352
|
+
*/
|
|
353
|
+
export function readableInCell(message, name) {
|
|
354
|
+
const path = `/${String(name ?? '')}.pl:`;
|
|
355
|
+
const text = readableError(message);
|
|
356
|
+
if (!text.startsWith(path)) return text;
|
|
357
|
+
return text
|
|
358
|
+
.slice(path.length)
|
|
359
|
+
.replace(/^(\d+):(\d+):\s*/, 'line $1, column $2: ')
|
|
360
|
+
.replace(/^(\d+):\s*/, 'line $1: ');
|
|
361
|
+
}
|
|
362
|
+
|
|
191
363
|
/** Strip the engine's bookkeeping keys from a solution. */
|
|
364
|
+
/**
|
|
365
|
+
* The query's own variable names, against their values.
|
|
366
|
+
*
|
|
367
|
+
* Two parallel lists rather than a list of `Name=Value` terms, because a term
|
|
368
|
+
* would have to be taken apart on this side and the lists arrive as arrays
|
|
369
|
+
* already. Order is the order the variables appear in the goal, which is the
|
|
370
|
+
* order a toplevel reports them in.
|
|
371
|
+
*/
|
|
372
|
+
export function zipBindings(names = [], values = []) {
|
|
373
|
+
const out = {};
|
|
374
|
+
names.forEach((name, i) => { out[name] = values[i]; });
|
|
375
|
+
return out;
|
|
376
|
+
}
|
|
377
|
+
|
|
192
378
|
export function bindingsOf(value) {
|
|
193
379
|
const out = {};
|
|
194
380
|
for (const [k, v] of Object.entries(value)) {
|
|
@@ -198,6 +384,31 @@ export function bindingsOf(value) {
|
|
|
198
384
|
return out;
|
|
199
385
|
}
|
|
200
386
|
|
|
387
|
+
/**
|
|
388
|
+
* Which SWI-Prolog this is.
|
|
389
|
+
*
|
|
390
|
+
* Worth asking, and not derivable from anything on disk: swipl-wasm 8.0.4 ships
|
|
391
|
+
* SWI-Prolog 10.1.10, and the two numbers have no relationship at all. A
|
|
392
|
+
* chapter's saved answers are only true of the engine that produced them, so
|
|
393
|
+
* the version is part of their attribution rather than a footnote.
|
|
394
|
+
*
|
|
395
|
+
* `version` rather than `version_git`: the integer flag is always present, and
|
|
396
|
+
* its encoding is documented — MAJOR*10000 + MINOR*100 + PATCH.
|
|
397
|
+
*
|
|
398
|
+
* @param {{query: Function}} session any session, in either environment
|
|
399
|
+
* @returns {Promise<string|null>} e.g. "10.1.10", or null if the engine will not say
|
|
400
|
+
*/
|
|
401
|
+
export async function prologVersion(session) {
|
|
402
|
+
try {
|
|
403
|
+
const result = await session.query('current_prolog_flag(version, V)').all(1);
|
|
404
|
+
const encoded = result.solutions?.[0]?.V;
|
|
405
|
+
if (!Number.isInteger(encoded)) return null;
|
|
406
|
+
return `${Math.floor(encoded / 10000)}.${Math.floor(encoded / 100) % 100}.${encoded % 100}`;
|
|
407
|
+
} catch {
|
|
408
|
+
return null;
|
|
409
|
+
}
|
|
410
|
+
}
|
|
411
|
+
|
|
201
412
|
/** Render a solution the way a Prolog top level would. */
|
|
202
413
|
export function formatSolution(solution) {
|
|
203
414
|
const pairs = Object.entries(solution);
|
package/src/export.js
ADDED
|
@@ -0,0 +1,126 @@
|
|
|
1
|
+
// The reader leaves with a real file.
|
|
2
|
+
//
|
|
3
|
+
// Explore mode lets a reader edit a published chapter and run it, and until now
|
|
4
|
+
// gave them no way to keep the result. That is a trap rather than a feature: work
|
|
5
|
+
// that lives only in a page is one navigation from gone, and telling someone to
|
|
6
|
+
// experiment while quietly discarding what they produce teaches them not to.
|
|
7
|
+
//
|
|
8
|
+
// The answer is FORK BY DOWNLOAD, never save-back (docs/modes.md §3). The
|
|
9
|
+
// published chapter stays canonical; the reader gets their own `.prolog.md`,
|
|
10
|
+
// which they can commit, open in VS Code, or send back as a pull request. That
|
|
11
|
+
// last one is free: the format is markdown in git, so the contribution path
|
|
12
|
+
// already exists.
|
|
13
|
+
//
|
|
14
|
+
// Cheap by construction — serialise() already emits canonical bytes — so all the
|
|
15
|
+
// work here is in being honest about WHOSE ANSWERS ARE WHOSE.
|
|
16
|
+
import { hashFor, serialise } from './format.js';
|
|
17
|
+
|
|
18
|
+
/**
|
|
19
|
+
* The notebook as it now stands on screen.
|
|
20
|
+
*
|
|
21
|
+
* @param {{frontMatter: Map<string, string>, cells: object[]}} notebook the published model
|
|
22
|
+
* @param {Map<string, {source?: string, goal?: string, output?: object|null}>} edits by cell id
|
|
23
|
+
* @returns {{frontMatter: Map<string, string>, cells: object[]}}
|
|
24
|
+
*/
|
|
25
|
+
export function withEdits(notebook, edits) {
|
|
26
|
+
const cells = notebook.cells.map((cell) => {
|
|
27
|
+
const edit = edits.get(cell.id);
|
|
28
|
+
if (!edit) return cell;
|
|
29
|
+
if (cell.kind === 'program' && edit.source !== undefined) {
|
|
30
|
+
return { ...cell, source: edit.source };
|
|
31
|
+
}
|
|
32
|
+
if (cell.kind === 'query') {
|
|
33
|
+
const next = { ...cell };
|
|
34
|
+
if (edit.goal !== undefined) next.goal = edit.goal;
|
|
35
|
+
// `output: null` is a deliberate erasure, not a missing key: it is how a
|
|
36
|
+
// query the reader has half-run says "I have no answers to give you".
|
|
37
|
+
//
|
|
38
|
+
// An output is more than its answers — it carries the fence's language and
|
|
39
|
+
// whatever attributes the author wrote on it, and the serialiser needs
|
|
40
|
+
// every one of them. So the reader's answers are laid OVER the author's
|
|
41
|
+
// output rather than replacing the object, and a cell that never had one
|
|
42
|
+
// gets the defaults the parser would have produced.
|
|
43
|
+
if ('output' in edit) {
|
|
44
|
+
next.output = edit.output && { ...blankOutput(), ...cell.output, ...edit.output };
|
|
45
|
+
}
|
|
46
|
+
return next;
|
|
47
|
+
}
|
|
48
|
+
return cell;
|
|
49
|
+
});
|
|
50
|
+
|
|
51
|
+
const updated = { ...notebook, cells };
|
|
52
|
+
|
|
53
|
+
// THE HASHES ARE THE WHOLE ARGUMENT. A downloaded file has to say, of every
|
|
54
|
+
// output in it, whether it follows from the program above it — and the format
|
|
55
|
+
// already has the spelling for that (format §6).
|
|
56
|
+
//
|
|
57
|
+
// - An answer the READER produced is hashed against the READER's program,
|
|
58
|
+
// because that is what produced it. It opens as current, which it is.
|
|
59
|
+
// - An answer from the CHAPTER keeps the AUTHOR's hash, untouched. If the
|
|
60
|
+
// reader edited a program above it, the hash no longer matches and the file
|
|
61
|
+
// opens with that output marked stale — which is exactly the truth, and
|
|
62
|
+
// exactly what the page they downloaded it from was showing.
|
|
63
|
+
//
|
|
64
|
+
// Rehashing everything would be the tempting one-liner and it would be a
|
|
65
|
+
// forgery: it would certify the author's answers as following from the
|
|
66
|
+
// reader's program. That is the one failure this project may not have.
|
|
67
|
+
for (const cell of cells) {
|
|
68
|
+
if (cell.kind !== 'query' || !cell.output) continue;
|
|
69
|
+
if (edits.get(cell.id)?.output) {
|
|
70
|
+
cell.output = { ...cell.output, inputHash: hashFor(updated, cell) };
|
|
71
|
+
}
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
return updated;
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
/**
|
|
78
|
+
* What the parser would have produced for an output that has none of its own.
|
|
79
|
+
*
|
|
80
|
+
* A query the chapter never ran has no fence for its answers, so there is
|
|
81
|
+
* nothing to inherit language or attributes from, and the serialiser reads both
|
|
82
|
+
* unconditionally — `attrs` in particular is iterated, so a missing one is a
|
|
83
|
+
* TypeError rather than a silently absent attribute.
|
|
84
|
+
*/
|
|
85
|
+
function blankOutput() {
|
|
86
|
+
return { solutions: [], terminator: '', inputHash: null, language: 'text', attrs: new Map() };
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
/**
|
|
90
|
+
* Serialise the notebook as it now stands.
|
|
91
|
+
* @returns {string} canonical `.prolog.md` bytes
|
|
92
|
+
*/
|
|
93
|
+
export function exportSource(notebook, edits) {
|
|
94
|
+
return serialise(withEdits(notebook, edits));
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
/**
|
|
98
|
+
* A filename for the reader's copy.
|
|
99
|
+
*
|
|
100
|
+
* From the SOURCE, never the title: a title can contain anything, including
|
|
101
|
+
* slashes, and the reader recognises the file they came from. The name is not
|
|
102
|
+
* decorated with "my-copy" either — this is their file now, and their filesystem
|
|
103
|
+
* is where the distinction between copies belongs.
|
|
104
|
+
*/
|
|
105
|
+
export function filenameFor(url) {
|
|
106
|
+
const path = String(url ?? '').split(/[?#]/)[0];
|
|
107
|
+
const base = path.slice(path.lastIndexOf('/') + 1);
|
|
108
|
+
return base || 'notebook.prolog.md';
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
/**
|
|
112
|
+
* Hand a file to the reader.
|
|
113
|
+
*
|
|
114
|
+
* An object URL and a synthetic click, revoked on the next turn of the event
|
|
115
|
+
* loop. No network, no server, nothing to own.
|
|
116
|
+
*/
|
|
117
|
+
export function download(filename, text, document_ = document) {
|
|
118
|
+
const url = URL.createObjectURL(new Blob([text], { type: 'text/markdown;charset=utf-8' }));
|
|
119
|
+
const anchor = document_.createElement('a');
|
|
120
|
+
anchor.href = url;
|
|
121
|
+
anchor.download = filename;
|
|
122
|
+
document_.body.appendChild(anchor);
|
|
123
|
+
anchor.click();
|
|
124
|
+
anchor.remove();
|
|
125
|
+
setTimeout(() => URL.revokeObjectURL(url), 0);
|
|
126
|
+
}
|