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/page.js
ADDED
|
@@ -0,0 +1,85 @@
|
|
|
1
|
+
// A notebook file to a running page: fetch, parse, render, mount.
|
|
2
|
+
//
|
|
3
|
+
// Separate from notebook.js on purpose. This module pulls in the parser and the
|
|
4
|
+
// markdown renderer; notebook.js pulls in neither. A page built by v0.3 already
|
|
5
|
+
// has its HTML and needs only the wiring, so keeping the two apart is what stops
|
|
6
|
+
// a prerendered chapter from downloading 137 KB of markdown-it to render nothing.
|
|
7
|
+
import { parse } from './format.js';
|
|
8
|
+
import { renderNotebook } from './render.js';
|
|
9
|
+
import { mount, offerDownload } from './notebook.js';
|
|
10
|
+
import { exportSource, filenameFor } from './export.js';
|
|
11
|
+
|
|
12
|
+
/**
|
|
13
|
+
* Render notebook source into an element and wire up its cells.
|
|
14
|
+
*
|
|
15
|
+
* @param {string} text notebook markdown
|
|
16
|
+
* @param {Element} root element to fill
|
|
17
|
+
* @param {object} [options] passed through to the session
|
|
18
|
+
* @returns {{frontMatter: Map<string, string>, cells: object[]}} the parsed notebook
|
|
19
|
+
*/
|
|
20
|
+
export function renderInto(text, root, options = {}) {
|
|
21
|
+
const { filename = 'notebook.prolog.md', ...rest } = options;
|
|
22
|
+
const notebook = parse(text);
|
|
23
|
+
root.innerHTML = renderNotebook(notebook);
|
|
24
|
+
// The title is the first H1 in the body, not a front-matter key (format §2):
|
|
25
|
+
// one source of truth, and the GitHub view gets a real heading rather than a
|
|
26
|
+
// heading hidden in metadata.
|
|
27
|
+
const title = root.querySelector('h1')?.textContent;
|
|
28
|
+
if (title) document.title = title;
|
|
29
|
+
const cells = mount(root, rest);
|
|
30
|
+
|
|
31
|
+
// EXPORT LIVES HERE, not in notebook.js, for the same reason the parser does:
|
|
32
|
+
// only this module has the prose. The DOM carries every program and every goal,
|
|
33
|
+
// but a markdown cell has been rendered to HTML and cannot be read back out of
|
|
34
|
+
// it — a chapter exported from the DOM alone would lose its writing.
|
|
35
|
+
offerDownload(root, {
|
|
36
|
+
produce: () => ({ filename, text: exportSource(notebook, edits(cells)) }),
|
|
37
|
+
// THE BYTES THE PAGE WAS GIVEN, not the model written out again. A
|
|
38
|
+
// re-serialisation would be canonical form, which is not necessarily the
|
|
39
|
+
// author's file: a hand-written chapter with no ids, or attributes in
|
|
40
|
+
// another order, would come back subtly reformatted. "The chapter as
|
|
41
|
+
// published" has to mean the chapter as published.
|
|
42
|
+
published: () => ({ filename, text }),
|
|
43
|
+
// The cells are the authority on whether any of this is the reader's, and
|
|
44
|
+
// `on` is how the row hears about one that changed with nobody clicking.
|
|
45
|
+
isEdited: () => cells.programs.some((p) => p.isEdited())
|
|
46
|
+
|| cells.queries.some((q) => q.isEdited()),
|
|
47
|
+
on: cells.on,
|
|
48
|
+
});
|
|
49
|
+
return notebook;
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
/** What the cells now say, keyed by id, for src/export.js to fold into the model. */
|
|
53
|
+
function edits(cells) {
|
|
54
|
+
const map = new Map();
|
|
55
|
+
for (const program of cells.programs) {
|
|
56
|
+
map.set(program.name, { source: program.text() });
|
|
57
|
+
}
|
|
58
|
+
for (const query of cells.queries) {
|
|
59
|
+
const output = query.output();
|
|
60
|
+
map.set(query.id, output === undefined
|
|
61
|
+
? { goal: query.goal() }
|
|
62
|
+
: { goal: query.goal(), output });
|
|
63
|
+
}
|
|
64
|
+
return map;
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
/**
|
|
68
|
+
* Fetch a `.prolog.md` and render it.
|
|
69
|
+
*
|
|
70
|
+
* The response is checked rather than trusted: a static host answers a missing
|
|
71
|
+
* file with an HTML 404 page, which parses perfectly well as markdown and would
|
|
72
|
+
* otherwise render as a chapter about nothing.
|
|
73
|
+
*
|
|
74
|
+
* @param {string|URL} url
|
|
75
|
+
* @param {{root?: Element}} [options]
|
|
76
|
+
* @returns {Promise<{frontMatter: Map<string, string>, cells: object[]}>}
|
|
77
|
+
*/
|
|
78
|
+
export async function load(url, { root = document.querySelector('main'), ...options } = {}) {
|
|
79
|
+
const response = await fetch(url);
|
|
80
|
+
if (!response.ok) throw new Error(`${url}: ${response.status} ${response.statusText}`);
|
|
81
|
+
// The reader's copy is named after the file they came from, not after the
|
|
82
|
+
// title: a title can contain anything, and the filename is how they recognise
|
|
83
|
+
// what they downloaded.
|
|
84
|
+
return renderInto(await response.text(), root, { filename: filenameFor(url), ...options });
|
|
85
|
+
}
|
package/src/render.js
ADDED
|
@@ -0,0 +1,367 @@
|
|
|
1
|
+
// Cell model in, HTML out.
|
|
2
|
+
//
|
|
3
|
+
// Strings, not DOM nodes. Three things fall out of that choice: this is testable
|
|
4
|
+
// under Node with no jsdom, the v0.3 static build reuses it verbatim rather than
|
|
5
|
+
// growing a second emitter, and the browser layer is reduced to assigning
|
|
6
|
+
// innerHTML once.
|
|
7
|
+
//
|
|
8
|
+
// The class vocabulary here is not decoration. src/notebook.css already styles
|
|
9
|
+
// exactly these names, and the look of the spike is a deliverable rather than a
|
|
10
|
+
// placeholder — a generator that invents its own structure would leave the page
|
|
11
|
+
// technically working and visually gone.
|
|
12
|
+
|
|
13
|
+
import MarkdownIt from 'markdown-it';
|
|
14
|
+
import { hashFor } from './format.js';
|
|
15
|
+
|
|
16
|
+
// markdown-it rather than marked, in this order of reasons:
|
|
17
|
+
//
|
|
18
|
+
// 1. SIZE IS NOT A CRITERION HERE, which is the only contest marked was winning
|
|
19
|
+
// (~13KB gzip against ~46KB). Once `build` prerenders a chapter (v0.3) the
|
|
20
|
+
// markdown library runs at build time and never reaches a reader at all; only
|
|
21
|
+
// the development loader pulls it into a browser.
|
|
22
|
+
// 2. GIVEN THAT, MATCHING GITHUB IS WHAT MATTERS. The same file has to read
|
|
23
|
+
// correctly on the repo page and in the built site — that is a promise the
|
|
24
|
+
// format makes, not a nicety. GitHub runs cmark-gfm; markdown-it is tested
|
|
25
|
+
// against the CommonMark suite, marked has known divergences.
|
|
26
|
+
// 3. Safe-by-default HTML handling is the tiebreaker, below.
|
|
27
|
+
//
|
|
28
|
+
// html:false is markdown-it's default, and we keep it: raw HTML in prose is
|
|
29
|
+
// escaped rather than passed through, so a notebook someone else wrote cannot
|
|
30
|
+
// carry script into the page. That removes the need for a sanitiser dependency
|
|
31
|
+
// rather than adding one — DOMPurify would drag a DOM shim into Node with it.
|
|
32
|
+
// marked can be made to do the same by overriding its html renderer, so this is
|
|
33
|
+
// a difference of default rather than of capability; a security property nobody
|
|
34
|
+
// has to remember is worth more than one that silently lapses on an upgrade.
|
|
35
|
+
//
|
|
36
|
+
// THE COST, so it is not discovered by surprise: authors cannot write raw HTML at
|
|
37
|
+
// all. <kbd>, <sup>, <br> and a sized <img> come out as text. Markdown images,
|
|
38
|
+
// GFM tables and strikethrough are unaffected, and the container grammar covers
|
|
39
|
+
// the visual vocabulary that would otherwise reach for HTML. When an author does
|
|
40
|
+
// need more, the answer is a markdown-it plugin or a new container variant —
|
|
41
|
+
// extending the vocabulary — never opening the raw-HTML door.
|
|
42
|
+
//
|
|
43
|
+
// KNOWN DIVERGENCES from GitHub, both GFM extensions needing plugins we have not
|
|
44
|
+
// added: task lists render as literal "[x]", and footnotes do not render.
|
|
45
|
+
//
|
|
46
|
+
// The one construct that genuinely needs raw HTML — the <details> reveal inside a
|
|
47
|
+
// prediction — is part of the container's grammar (format §10), so we emit it
|
|
48
|
+
// ourselves from the model instead of trusting the author's angle brackets.
|
|
49
|
+
//
|
|
50
|
+
// linkify matches GitHub, which autolinks bare URLs. typographer does not: GitHub
|
|
51
|
+
// leaves quotes and dashes alone, and prose that renders differently on the repo
|
|
52
|
+
// page than in the built site defeats the point of the format.
|
|
53
|
+
const md = new MarkdownIt({ html: false, linkify: true, typographer: false });
|
|
54
|
+
|
|
55
|
+
/**
|
|
56
|
+
* Render markdown prose to HTML.
|
|
57
|
+
* @param {string} source
|
|
58
|
+
* @returns {string}
|
|
59
|
+
*/
|
|
60
|
+
export function renderProse(source) {
|
|
61
|
+
return md.render(source).trim();
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
/** Render markdown that should not be wrapped in a paragraph. */
|
|
65
|
+
function renderInline(source) {
|
|
66
|
+
return md.renderInline(source).trim();
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
export function escapeHtml(text) {
|
|
70
|
+
return String(text)
|
|
71
|
+
.replace(/&/g, '&')
|
|
72
|
+
.replace(/</g, '<')
|
|
73
|
+
.replace(/>/g, '>')
|
|
74
|
+
.replace(/"/g, '"');
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
/**
|
|
78
|
+
* The four containers the stylesheet knows, each with its own shape.
|
|
79
|
+
*
|
|
80
|
+
* They are not interchangeable boxes: a margin note is a single rotated line, an
|
|
81
|
+
* aside leads with a bold sentence, a prediction is a heading plus a place to
|
|
82
|
+
* commit an answer plus a reveal, and the bullets block closes a chapter. The
|
|
83
|
+
* markup differs accordingly, and matching it is what keeps notebook.css working.
|
|
84
|
+
*
|
|
85
|
+
* @param {{variant: string, title: string, body: string}} cell
|
|
86
|
+
* @returns {string}
|
|
87
|
+
*/
|
|
88
|
+
export function renderContainer(cell) {
|
|
89
|
+
switch (cell.variant) {
|
|
90
|
+
case 'margin':
|
|
91
|
+
// The whole note lives in the head line — `> [!margin] text with no body` —
|
|
92
|
+
// so for this one variant the title IS the content. Anything in the body is
|
|
93
|
+
// appended rather than dropped.
|
|
94
|
+
return `<p class="note">${[renderInline(cell.title), cell.body && renderInline(cell.body)]
|
|
95
|
+
.filter(Boolean)
|
|
96
|
+
.join(' ')}</p>`;
|
|
97
|
+
|
|
98
|
+
case 'aside':
|
|
99
|
+
// The first **bold** line is the lead-in; .aside strong does the rest.
|
|
100
|
+
return `<div class="aside">\n${renderProse(joinHeadAndBody(cell))}\n</div>`;
|
|
101
|
+
|
|
102
|
+
case 'predict':
|
|
103
|
+
return renderPredict(cell);
|
|
104
|
+
|
|
105
|
+
case 'bullets':
|
|
106
|
+
return `<div class="bullets">\n${cell.title ? `<h2>${renderInline(cell.title)}</h2>\n` : ''}${renderProse(cell.body)}\n</div>`;
|
|
107
|
+
|
|
108
|
+
default:
|
|
109
|
+
throw new Error(`no renderer for container variant "${cell.variant}"`);
|
|
110
|
+
}
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
function joinHeadAndBody(cell) {
|
|
114
|
+
return [cell.title, cell.body].filter(Boolean).join('\n');
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
/**
|
|
118
|
+
* A prediction is the teaching device, so its parts are structural.
|
|
119
|
+
*
|
|
120
|
+
* The textarea is added by the renderer rather than written by the author
|
|
121
|
+
* (format §10) — the author's job is to ask the question, not to remember the
|
|
122
|
+
* markup for a place to answer it. The reveal is a <details> so that it still
|
|
123
|
+
* works, unclicked, on the GitHub page.
|
|
124
|
+
*/
|
|
125
|
+
function renderPredict(cell) {
|
|
126
|
+
const { before, summary, reveal } = splitReveal(cell.body);
|
|
127
|
+
const parts = [];
|
|
128
|
+
if (cell.title) parts.push(`<h3>${renderInline(cell.title)}</h3>`);
|
|
129
|
+
if (before) parts.push(renderProse(before));
|
|
130
|
+
// A blank bordered box reads as decoration. The placeholder is generic because
|
|
131
|
+
// the format has no spelling for a per-prediction one, and inventing an
|
|
132
|
+
// attribute for it would be a format change to save one line of prose — the
|
|
133
|
+
// author's own question is directly above it and says what to write.
|
|
134
|
+
parts.push('<textarea placeholder="your prediction…" spellcheck="false"></textarea>');
|
|
135
|
+
if (reveal !== null) {
|
|
136
|
+
parts.push(`<details>\n<summary>${escapeHtml(summary)}</summary>\n${renderProse(reveal)}\n</details>`);
|
|
137
|
+
}
|
|
138
|
+
return `<div class="predict">\n${parts.join('\n')}\n</div>`;
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
/**
|
|
142
|
+
* Pull the `<details>` reveal out of a prediction body.
|
|
143
|
+
*
|
|
144
|
+
* We look for it literally because that is what the author writes and what GitHub
|
|
145
|
+
* renders natively (format §10). Recognising it here — rather than letting raw
|
|
146
|
+
* HTML through the markdown renderer — is what lets html:false stay on.
|
|
147
|
+
*/
|
|
148
|
+
function splitReveal(body) {
|
|
149
|
+
const open = body.indexOf('<details>');
|
|
150
|
+
const close = body.lastIndexOf('</details>');
|
|
151
|
+
if (open === -1 || close === -1 || close < open) {
|
|
152
|
+
return { before: body, summary: '', reveal: null };
|
|
153
|
+
}
|
|
154
|
+
const inner = body.slice(open + '<details>'.length, close);
|
|
155
|
+
const summaryMatch = /<summary>([\s\S]*?)<\/summary>/.exec(inner);
|
|
156
|
+
return {
|
|
157
|
+
before: body.slice(0, open).trim(),
|
|
158
|
+
summary: summaryMatch ? summaryMatch[1].trim() : 'Reveal',
|
|
159
|
+
reveal: (summaryMatch ? inner.slice(summaryMatch.index + summaryMatch[0].length) : inner).trim(),
|
|
160
|
+
};
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
/**
|
|
164
|
+
* A program cell: the Prolog, and a button that loads it.
|
|
165
|
+
*
|
|
166
|
+
* `data-cell` carries the notebook's own id through to the DOM, and it is not
|
|
167
|
+
* decoration either — src/notebook.js consults each cell into the virtual file
|
|
168
|
+
* named by it (format §8), so SWI's own messages say `/p-family.pl` rather than
|
|
169
|
+
* `/cell-3.pl`. A warning that a cell has destroyed another cell's clauses is
|
|
170
|
+
* only useful if it names a cell the reader can find.
|
|
171
|
+
*
|
|
172
|
+
* @param {{id: string, source: string}} cell
|
|
173
|
+
* @returns {string}
|
|
174
|
+
*/
|
|
175
|
+
export function renderProgram(cell) {
|
|
176
|
+
return `<div class="cell program" data-cell="${escapeHtml(cell.id)}">
|
|
177
|
+
<div class="bar">program<span class="spacer"></span><span class="status"></span>
|
|
178
|
+
<button data-act="reset" disabled>reset</button>
|
|
179
|
+
<button class="primary" data-act="consult">Consult</button></div>
|
|
180
|
+
<textarea spellcheck="false">${escapeHtml(cell.source)}</textarea>
|
|
181
|
+
</div>`;
|
|
182
|
+
}
|
|
183
|
+
|
|
184
|
+
/**
|
|
185
|
+
* A query cell: the goal, the buttons that step it, and the answers the chapter
|
|
186
|
+
* was published with.
|
|
187
|
+
*
|
|
188
|
+
* `; next` is not a convenience beside `all` — a Prolog query yields a stream of
|
|
189
|
+
* proofs, and watching them arrive one at a time is usually the lesson. Both
|
|
190
|
+
* survive into the generated markup for that reason.
|
|
191
|
+
*
|
|
192
|
+
* @param {{id: string, goal: string, output: object|null}} cell
|
|
193
|
+
* @param {{stale?: boolean, rerun?: string|null}} [options]
|
|
194
|
+
* @returns {string}
|
|
195
|
+
*/
|
|
196
|
+
export function renderQuery(cell, options = {}) {
|
|
197
|
+
const { stale = false, rerun = null } = options;
|
|
198
|
+
// The answers go into the page whatever `hold` says. Holding them is a property
|
|
199
|
+
// of the INTERACTIVE rendering, not of the content (format §5): a book cannot
|
|
200
|
+
// withhold anything, and a reader on the GitHub page or a printed chapter has
|
|
201
|
+
// no Run button to press. So the attribute is passed to the runtime as a data-
|
|
202
|
+
// attribute and notebook.js puts them out of sight — the same chrome-not-content
|
|
203
|
+
// rule the hide button already follows.
|
|
204
|
+
const hold = cell.hold ? ` data-hold="${escapeHtml(cell.hold)}"` : '';
|
|
205
|
+
// Only `auto` is announced: manual is what a page does with no runtime at all,
|
|
206
|
+
// so saying it would be markup that changes nothing.
|
|
207
|
+
const auto = rerun === 'auto' ? ' data-rerun="auto"' : '';
|
|
208
|
+
// The staleness verdict, in a form the runtime can read. It is already IN the
|
|
209
|
+
// page as a warning line the reader sees; this is the same fact for the code,
|
|
210
|
+
// and re-deriving it there would mean hashing the whole notebook again in a
|
|
211
|
+
// module that has deliberately never seen the file.
|
|
212
|
+
const wasStale = stale ? ' data-stale="saved"' : '';
|
|
213
|
+
return `<div class="cell query" data-cell="${escapeHtml(cell.id)}"${hold}${auto}${wasStale}>
|
|
214
|
+
<div class="bar">query<span class="spacer"></span><span class="status"></span>
|
|
215
|
+
<button data-act="reset" disabled>reset</button>
|
|
216
|
+
<button class="primary" data-act="run">Run</button>
|
|
217
|
+
<button data-act="next" disabled>; next</button>
|
|
218
|
+
<button data-act="all" disabled>all</button>
|
|
219
|
+
<button data-act="stop" disabled>stop</button></div>
|
|
220
|
+
<div class="prompt"><span>?-</span><input value="${escapeHtml(cell.goal)}" spellcheck="false"></div>
|
|
221
|
+
<div class="out">${renderSavedOutput(cell, { stale })}</div>
|
|
222
|
+
</div>`;
|
|
223
|
+
}
|
|
224
|
+
|
|
225
|
+
/**
|
|
226
|
+
* The answers stored in the file, rendered with no engine anywhere.
|
|
227
|
+
*
|
|
228
|
+
* This is the property the whole project is for. A chapter is readable the
|
|
229
|
+
* instant it loads and stays readable if the 5.9 MB of WebAssembly never
|
|
230
|
+
* arrives at all — on a phone with bad signal, behind a corporate proxy, in ten
|
|
231
|
+
* years. It degrades to a book rather than to a blank page.
|
|
232
|
+
*
|
|
233
|
+
* It is affordable because the format stores the solution SEQUENCE rather than a
|
|
234
|
+
* blob (format §6), so this is a rendering problem rather than an execution one.
|
|
235
|
+
*/
|
|
236
|
+
function renderSavedOutput(cell, { stale = false } = {}) {
|
|
237
|
+
if (!cell.output) return '';
|
|
238
|
+
const lines = [
|
|
239
|
+
// An output is never shown without saying where it came from
|
|
240
|
+
// (docs/modes.md §3). These are the author's answers, not the reader's, and
|
|
241
|
+
// a reader who cannot tell them apart concludes something false about Prolog.
|
|
242
|
+
{ cls: 'from', text: stale ? 'the chapter\u2019s saved answers, from an older version of the program above' : 'the chapter\u2019s saved answers' },
|
|
243
|
+
{ cls: 'echo', text: `?- ${cell.goal}.` },
|
|
244
|
+
...replaySolutions(cell.output),
|
|
245
|
+
];
|
|
246
|
+
if (stale) {
|
|
247
|
+
lines.push({ cls: 'warn', text: 'the program above has changed since these were produced \u2014 press Run to see what it does now' });
|
|
248
|
+
}
|
|
249
|
+
return `\n${lines.map((l) => ` <div class="line ${l.cls}">${escapeHtml(l.text)}</div>`).join('\n')}\n `;
|
|
250
|
+
}
|
|
251
|
+
|
|
252
|
+
/**
|
|
253
|
+
* A saved solution sequence to the lines a reader sees.
|
|
254
|
+
*
|
|
255
|
+
* The stored spelling is SWI's own toplevel — solutions terminated by ` ;`, and a
|
|
256
|
+
* final line ending in `.` which may be `false.`, `true.`, or the last solution's
|
|
257
|
+
* bindings when the query ran deterministically — or NO final line at all, which
|
|
258
|
+
* says the search was never exhausted (format §6). The display spelling
|
|
259
|
+
* is the one the live engine produces, numbered, because a reader pressing Run
|
|
260
|
+
* must not watch the layout change underneath them.
|
|
261
|
+
*
|
|
262
|
+
* @param {{solutions: string[], terminator: string}} output
|
|
263
|
+
* @returns {{cls: string, text: string}[]}
|
|
264
|
+
*/
|
|
265
|
+
export function replaySolutions(output) {
|
|
266
|
+
const lines = [];
|
|
267
|
+
let count = 0;
|
|
268
|
+
const solution = (text) => lines.push({ cls: 'sol', text: `${++count}. ${text}` });
|
|
269
|
+
|
|
270
|
+
for (const text of output.solutions) solution(text);
|
|
271
|
+
|
|
272
|
+
const end = output.terminator ?? '';
|
|
273
|
+
if (end.startsWith('ERROR:')) {
|
|
274
|
+
lines.push({ cls: 'err', text: end.replace(/^ERROR:\s*/, '') });
|
|
275
|
+
return lines;
|
|
276
|
+
}
|
|
277
|
+
// No terminator at all: the sequence was never exhausted (format §6). These
|
|
278
|
+
// answers are the ones somebody stopped after, not all the ones there are, and
|
|
279
|
+
// "no more solutions." under them would be a claim nobody ever made.
|
|
280
|
+
if (end === '') {
|
|
281
|
+
lines.push({ cls: 'done partial', text: 'more solutions may follow.' });
|
|
282
|
+
return lines;
|
|
283
|
+
}
|
|
284
|
+
// `false.` after solutions means the search was exhausted, not that the query
|
|
285
|
+
// failed — the distinction matters, and "false." under six answers reads as a
|
|
286
|
+
// contradiction of them.
|
|
287
|
+
const bindings = end.replace(/\.$/, '');
|
|
288
|
+
if (bindings !== '' && bindings !== 'false') solution(bindings);
|
|
289
|
+
lines.push({
|
|
290
|
+
cls: 'done',
|
|
291
|
+
text: count === 0 ? 'false.' : 'no more solutions.',
|
|
292
|
+
});
|
|
293
|
+
return lines;
|
|
294
|
+
}
|
|
295
|
+
|
|
296
|
+
/**
|
|
297
|
+
* Render one cell.
|
|
298
|
+
*
|
|
299
|
+
* @param {object} cell
|
|
300
|
+
* @param {{stale?: boolean, rerun?: string|null}} [options] for a query cell
|
|
301
|
+
* @returns {string}
|
|
302
|
+
*/
|
|
303
|
+
export function renderCell(cell, options = {}) {
|
|
304
|
+
switch (cell.kind) {
|
|
305
|
+
case 'markdown':
|
|
306
|
+
return renderProse(cell.source);
|
|
307
|
+
case 'container':
|
|
308
|
+
return renderContainer(cell);
|
|
309
|
+
case 'program':
|
|
310
|
+
return renderProgram(cell);
|
|
311
|
+
case 'query':
|
|
312
|
+
return renderQuery(cell, options);
|
|
313
|
+
case 'unknown':
|
|
314
|
+
// A cell kind from a later version. It renders as the ordinary code block
|
|
315
|
+
// it looks like, so a v0.2 page degrades instead of dropping content.
|
|
316
|
+
return renderProse(cell.source);
|
|
317
|
+
default:
|
|
318
|
+
throw new Error(`no renderer for cell kind "${cell.kind}"`);
|
|
319
|
+
}
|
|
320
|
+
}
|
|
321
|
+
|
|
322
|
+
/**
|
|
323
|
+
* A parsed notebook to the HTML that goes inside `<main>`.
|
|
324
|
+
*
|
|
325
|
+
* Throwing on an unrenderable cell rather than skipping it is the whole reason
|
|
326
|
+
* this loop exists as a function: a chapter that silently drops a cell reads as
|
|
327
|
+
* a complete chapter, and the missing step is the one the reader needed.
|
|
328
|
+
*
|
|
329
|
+
* @param {{frontMatter: Map<string, string>, cells: object[]}} notebook
|
|
330
|
+
* @returns {string}
|
|
331
|
+
*/
|
|
332
|
+
export function renderNotebook(notebook) {
|
|
333
|
+
const parts = [];
|
|
334
|
+
const kicker = renderKicker(notebook.frontMatter);
|
|
335
|
+
if (kicker) parts.push(kicker);
|
|
336
|
+
for (const cell of notebook.cells) {
|
|
337
|
+
// Staleness is decided here rather than in renderQuery, because it is a fact
|
|
338
|
+
// about the cell's PLACE in the notebook — the program cells above it — and a
|
|
339
|
+
// query cell on its own cannot know it. Computed before first paint: a 64-bit
|
|
340
|
+
// FNV-1a over text we already have, which is why the hash is not a SHA.
|
|
341
|
+
const stale = cell.kind === 'query' && cell.output?.inputHash
|
|
342
|
+
? hashFor(notebook, cell) !== cell.output.inputHash
|
|
343
|
+
: false;
|
|
344
|
+
// `rerun` is resolved here for the same reason: the cell carries what its
|
|
345
|
+
// author wrote ON IT, and the notebook-wide default (§2) is a fact about the
|
|
346
|
+
// file the cell cannot see. The model keeps them apart so a round-trip does
|
|
347
|
+
// not write the default onto every cell.
|
|
348
|
+
const rerun = cell.kind === 'query'
|
|
349
|
+
? cell.rerun ?? notebook.frontMatter.get('rerun') ?? 'manual'
|
|
350
|
+
: null;
|
|
351
|
+
parts.push(renderCell(cell, { stale, rerun }));
|
|
352
|
+
}
|
|
353
|
+
return `${parts.join('\n\n')}\n`;
|
|
354
|
+
}
|
|
355
|
+
|
|
356
|
+
/**
|
|
357
|
+
* The kicker is front matter, not prose: markdown has no spelling for a line
|
|
358
|
+
* above the title. When a notebook is bound, the binder supplies this instead —
|
|
359
|
+
* see docs/binding.md.
|
|
360
|
+
*
|
|
361
|
+
* @param {Map<string, string>} frontMatter
|
|
362
|
+
* @returns {string}
|
|
363
|
+
*/
|
|
364
|
+
export function renderKicker(frontMatter) {
|
|
365
|
+
const kicker = frontMatter.get('kicker');
|
|
366
|
+
return kicker ? `<div class="kicker">${renderInline(kicker)}</div>` : '';
|
|
367
|
+
}
|
package/src/run.js
ADDED
|
@@ -0,0 +1,128 @@
|
|
|
1
|
+
// Execute a notebook with no browser: consult every program cell, run every
|
|
2
|
+
// query, and hand back the answers in the format's own spelling.
|
|
3
|
+
//
|
|
4
|
+
// This is the other half of "a chapter is readable before the engine arrives"
|
|
5
|
+
// (869ectt0y). That property is worth nothing unless something fills the answers
|
|
6
|
+
// in, and doing it by hand is both tedious and dishonest — a hand-written output
|
|
7
|
+
// block is the author's guess at what SWI prints, published as though it ran
|
|
8
|
+
// (869ectt38, 869ectt3e).
|
|
9
|
+
//
|
|
10
|
+
// NO FILESYSTEM AND NO PROCESS IN HERE. It takes a parsed notebook and a session
|
|
11
|
+
// and returns edits, which is what makes it testable without a CLI, reusable by
|
|
12
|
+
// `--check` (869ectt3n), and portable to a VS Code "run all" that has neither a
|
|
13
|
+
// terminal nor a working directory.
|
|
14
|
+
import { solutionSequence } from './format.js';
|
|
15
|
+
|
|
16
|
+
/**
|
|
17
|
+
* How many solutions to take from one query before stopping.
|
|
18
|
+
*
|
|
19
|
+
* A limit is not optional: `length(L, N)` has infinitely many solutions and a
|
|
20
|
+
* lists chapter will contain something like it on purpose. Stopping is recorded
|
|
21
|
+
* honestly — a sequence with no terminator says the search was never exhausted
|
|
22
|
+
* (format §6) — so a truncated cell tells the truth rather than claiming to be
|
|
23
|
+
* complete.
|
|
24
|
+
*
|
|
25
|
+
* 100 rather than 500: this number ends up IN THE FILE, and a chapter whose
|
|
26
|
+
* saved answers run to hundreds of lines is a chapter nobody reads. The browser's
|
|
27
|
+
* `all` guard is a different question with a different answer.
|
|
28
|
+
*/
|
|
29
|
+
export const DEFAULT_LIMIT = 100;
|
|
30
|
+
|
|
31
|
+
/**
|
|
32
|
+
* Run every cell in document order.
|
|
33
|
+
*
|
|
34
|
+
* Document order is execution order, which is the same rule the page follows: a
|
|
35
|
+
* query runs against the program cells ABOVE it. So there is no dependency graph
|
|
36
|
+
* here either — Prolog has no load-time name binding, and at a few milliseconds
|
|
37
|
+
* a cell there would be nothing to gain from one.
|
|
38
|
+
*
|
|
39
|
+
* @param {{frontMatter: Map<string, string>, cells: object[]}} notebook
|
|
40
|
+
* @param {{consult: Function, query: Function}} session
|
|
41
|
+
* @param {{limit?: number, onCell?: (event: object) => void}} [options]
|
|
42
|
+
* `onCell` hears each cell as it finishes, so a CLI can report progress
|
|
43
|
+
* without this module knowing what a terminal is.
|
|
44
|
+
* @returns {Promise<{edits: Map<string, object>, failures: object[], warnings: object[]}>}
|
|
45
|
+
*/
|
|
46
|
+
export async function runNotebook(notebook, session, options = {}) {
|
|
47
|
+
const { limit = DEFAULT_LIMIT, onCell = () => {} } = options;
|
|
48
|
+
const edits = new Map();
|
|
49
|
+
const failures = [];
|
|
50
|
+
const warnings = [];
|
|
51
|
+
|
|
52
|
+
for (const cell of notebook.cells) {
|
|
53
|
+
if (cell.kind === 'program') {
|
|
54
|
+
const result = await session.consult(cell.source, cell.id);
|
|
55
|
+
for (const message of result.messages ?? []) {
|
|
56
|
+
// Usually one cell has just destroyed another cell's clauses, which is
|
|
57
|
+
// invisible in a file and expensive to discover in a published chapter.
|
|
58
|
+
if (message.kind === 'warning') warnings.push({ id: cell.id, text: message.text });
|
|
59
|
+
}
|
|
60
|
+
if (!result.ok) {
|
|
61
|
+
// A query below a cell that did not load answers a question nobody asked,
|
|
62
|
+
// so this is reported rather than run past. The caller decides whether to
|
|
63
|
+
// stop; nothing is written by this module either way.
|
|
64
|
+
failures.push({ id: cell.id, error: result.error });
|
|
65
|
+
}
|
|
66
|
+
onCell({ kind: 'program', id: cell.id, ok: result.ok, error: result.error ?? null });
|
|
67
|
+
continue;
|
|
68
|
+
}
|
|
69
|
+
if (cell.kind !== 'query') continue;
|
|
70
|
+
|
|
71
|
+
const run = await runQuery(session, cell.goal, limit);
|
|
72
|
+
edits.set(cell.id, { output: solutionSequence(run) });
|
|
73
|
+
onCell({ kind: 'query', id: cell.id, goal: cell.goal, ...run });
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
return { edits, failures, warnings };
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
/**
|
|
80
|
+
* Take up to `limit` solutions from one goal.
|
|
81
|
+
*
|
|
82
|
+
* Solutions are kept as SWI RENDERED THEM — `next().text` comes from the engine's
|
|
83
|
+
* own writer, so operators, quoting and partial lists are right. Reconstructing
|
|
84
|
+
* them from the bindings would produce a file whose answers are subtly not the
|
|
85
|
+
* ones a reader gets when they press Run.
|
|
86
|
+
*
|
|
87
|
+
* @returns {Promise<{solutions: string[], exhausted: boolean, error: string|null, truncated: boolean}>}
|
|
88
|
+
*/
|
|
89
|
+
async function runQuery(session, goal, limit) {
|
|
90
|
+
const query = session.query(goal);
|
|
91
|
+
const solutions = [];
|
|
92
|
+
let exhausted = false;
|
|
93
|
+
let error = null;
|
|
94
|
+
|
|
95
|
+
try {
|
|
96
|
+
while (solutions.length < limit) {
|
|
97
|
+
const result = await query.next();
|
|
98
|
+
// The engine can deliver the last solution TOGETHER with done, so the
|
|
99
|
+
// binding is taken before the ending is acted on.
|
|
100
|
+
if (result.solution) solutions.push(result.text ?? formatBindings(result.solution));
|
|
101
|
+
if (result.error) {
|
|
102
|
+
error = result.error;
|
|
103
|
+
break;
|
|
104
|
+
}
|
|
105
|
+
if (result.done) {
|
|
106
|
+
exhausted = true;
|
|
107
|
+
break;
|
|
108
|
+
}
|
|
109
|
+
}
|
|
110
|
+
} catch (e) {
|
|
111
|
+
error = e.message;
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
// Not tidiness: SWI keeps open queries on a stack, and a query abandoned at the
|
|
115
|
+
// limit would leave a frame that every later cell nests inside (869epzqpc). The
|
|
116
|
+
// session enforces one open query, so this is belt and braces — but the belt is
|
|
117
|
+
// what lets a chapter of fifty cells run at all.
|
|
118
|
+
if (!exhausted && !error) await query.close();
|
|
119
|
+
|
|
120
|
+
return { solutions, exhausted, error, truncated: !exhausted && !error };
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
/** Last resort when a session renders nothing itself. Never used in-process. */
|
|
124
|
+
function formatBindings(solution) {
|
|
125
|
+
const pairs = Object.entries(solution);
|
|
126
|
+
if (!pairs.length) return 'true';
|
|
127
|
+
return pairs.map(([name, value]) => `${name} = ${String(value)}`).join(', ');
|
|
128
|
+
}
|