event-horizon / src /template.js
maxdemarzi's picture
Deploy 4ed0390343adb220188e58f95ab1a8e7e1dd995e (manual: Actions blocked on billing) (part 2)
1b9ed71 verified
Raw
History Blame Contribute Delete
34.9 kB
/* One template, as a notebook you can run.
*
* The prose is the point. These are teaching documents β€” the markdown cells
* explain what is being modelled and why, and the code cells are the payoff.
* So the page renders as a document first and only becomes an engine when
* someone asks it to: the wasm front-end is ~100 MB, and downloading that to
* read an explanation would be the wrong trade.
*
* Cells share one namespace, in order, exactly as in Jupyter. That is why
* "Run" on cell 5 quietly runs 1–4 first if they have not run: a visitor who
* clicks the interesting-looking cell should get its output, not a NameError
* that teaches them nothing about Swan.
*/
import { renderMarkdown } from './markdown.js';
import { esc } from './util.js';
const MAX_ROWS = 50;
/* `can` is whether this page offers to run the template.
*
* slow can: it does work, it just takes a while, and the note says so β€” that is
* a decision for whoever is reading, not one to make for them.
*
* fails cannot: all four were checked natively and fail there identically, so
* the Run button could only ever produce the error already printed in the note.
* Offering it would be a button whose entire function is to disappoint. The
* code and the prose are still worth reading, which is what "read only" means
* on the card. */
const STATE = {
ok: { tone: 'ok', can: true },
slow: { tone: 'warn', can: true },
/* needs-torch cannot run here by construction β€” torch publishes no
* emscripten wheel β€” so offering Run would only produce the same refusal
* every time. The prose and the code are the point on those pages. */
'needs-torch': { tone: 'off', can: false },
fails: { tone: 'off', can: false },
'no-notebook': { tone: 'off', can: false },
unmeasured: { tone: 'off', can: true },
};
/** Letters and digits only, so "Diet Optimization" matches "# Diet Optimisation:". */
const loosely = (s) => s.toLowerCase().replace(/[^a-z0-9]+/g, '');
/**
* Strip a leading heading that only repeats the page title.
*
* Compared loosely, and only against the first heading: a template whose
* opening section genuinely says something else keeps it.
*/
function dropTitleHeading(src, title) {
const m = src.match(/^\s*#{1,3}\s+(.+?)\s*$/m);
if (!m || loosely(m[1]) !== loosely(title)) return src;
return src.replace(m[0], '').replace(/^\s*\n/, '');
}
/**
* Split a template README into its `##` sections.
*
* The READMEs are the copy RelationalAI wrote for these templates β€” what you
* will build, what ships with it, what the output should look like β€” and until
* now this page threw all of it away and showed the notebook alone.
*/
function splitReadme(src) {
const lines = String(src || '').replace(/\r\n/g, '\n').split('\n');
const intro = [];
const sections = [];
let current = null;
let fence = false;
for (const line of lines) {
if (/^\s*```/.test(line)) fence = !fence;
// Only outside a fence: "## Something" inside a code block is a comment.
const h = !fence && line.match(/^##\s+(.+?)\s*$/);
if (h) {
current = { title: h[1], body: [] };
sections.push(current);
continue;
}
if (current) current.body.push(line);
else if (!/^#\s/.test(line)) intro.push(line);
}
return {
intro: intro.join('\n').trim(),
sections: sections.map((s) => ({ title: s.title, body: s.body.join('\n').trim() })),
};
}
/* What to do with each `##` section of a README.
*
* Counted across the corpus rather than guessed from one template β€” the common
* sections are "What this template is for" (38), "Customize this template"
* (37), "Template structure" (36), "How it works" (36), "Quickstart" (33),
* "What you'll build" (30), "Troubleshooting" (30), "What's included" (29).
* Anything not named below is kept, so a section this list has never seen
* still reaches the page.
*
* SKIPPED are the sections about running it somewhere else. Prerequisites says
* to install Python and `make` the Swan extension; Quickstart says to `cd` into
* the template directory and launch Jupyter. Both are true of a local checkout
* and both are precisely what this page removes, so printing them verbatim
* would contradict the thing the page exists to demonstrate. One sentence
* replaces them.
*
* COLLAPSED are long and secondary. The walkthrough narrates the same code the
* page renders live below β€” my first cut dropped it as a duplicate, which was
* wrong for exactly the templates that need it most: three notebooks have one
* markdown cell or none, so the explanation of *why* each step exists is in
* that section and nowhere else. Open by default it would dwarf the notebook;
* gone, a sparsely commented notebook has nothing to read alongside it. */
/* The numbered form matters: ten READMEs promote the Quickstart steps to `##`
* headings, so the section is literally titled "1. Navigate to the template
* directory:" and carries the `cd` and `jupyter notebook` lines with it. Matched
* by name rather than by "starts with a digit", because eleven other numeric
* headings across the corpus are real content β€” "3. Prescriptive Knapsack
* Optimization", "2. Smurf Army Model" β€” and dropping those would be worse than
* the leak. */
const SKIPPED = /^(prerequisites|quickstart|quick start|getting started|installation|setup|running the (template|notebook)|\d+[.)]\s*(navigate|install|activate|create a virtual|run the notebook))/i;
/* Shown open: the sections that answer "what is this and should I care".
*
* Everything else in the README is kept but collapsed. That distinction is the
* difference between a page and a wall β€” telco_network_recovery has fourteen
* sections, and rendering them all open produced a 16,000-pixel page where the
* runnable notebook, the entire point, began below eight thousand pixels of
* prose. Nothing is discarded; the reference material simply starts folded. */
const PRIMARY = /^(what this template is for|who this is for|what you'?ll build|what'?s included|how it works|overview|about)/i;
/* Collapsed next to the notebook rather than up in the overview, because both
* are about the code directly below them. */
const NEAR_CODE = /^((code )?walkthrough|troubleshooting)/i;
const WALKTHROUGH = /^(code )?walkthrough/i;
/** Distinctive identifiers in a chunk of Python, for matching prose to a cell. */
function codeFingerprint(text) {
const tokens = String(text).match(/[A-Za-z_][A-Za-z0-9_]{3,}/g) || [];
// Words that appear in every cell of every template carry no signal.
const common = new Set(['import', 'from', 'model', 'self', 'None', 'True', 'False',
'print', 'return', 'for', 'name', 'data', 'this', 'with']);
return new Set(tokens.filter((t) => !common.has(t)));
}
/** Jaccard overlap, 0..1. */
function similarity(a, b) {
if (!a.size || !b.size) return 0;
let shared = 0;
for (const t of a) if (b.has(t)) shared += 1;
return shared / (a.size + b.size - shared);
}
/**
* Split a Code Walkthrough into its `###` steps, and work out which notebook
* cell each one is describing.
*
* Matched on the code itself rather than on position. The walkthrough is not
* cell-by-cell β€” ad-spend-allocation's has two steps for eight cells, and the
* numbering restarts and skips β€” so pairing them in order would confidently
* caption the wrong cell, which is worse than captioning none.
*
* Each step quotes the code it is talking about, so its fenced block is
* fingerprinted and compared against every cell. A step is only attached where
* one cell is a clear best match; anything unmatched stays in the folded block
* so no prose is lost.
*/
function walkthroughSteps(body, cells) {
const lines = String(body).split('\n');
const steps = [];
let current = null;
let fence = false;
for (const line of lines) {
if (/^\s*```/.test(line)) fence = !fence;
const h = !fence && line.match(/^#{3,4}\s+(.+?)\s*$/);
if (h) {
current = { title: h[1], body: [] };
steps.push(current);
continue;
}
if (current) current.body.push(line);
}
const MIN_SCORE = 0.12;
const taken = new Set();
const fingerprints = cells.map((c) => codeFingerprint(c.original));
for (const step of steps) {
step.body = step.body.join('\n').trim();
// Setup steps ("1. Navigate to the template directory") describe a shell,
// not a cell β€” the same instructions dropped from Quickstart.
if (SKIPPED.test(step.title)) { step.skip = true; continue; }
const fenced = [...step.body.matchAll(/```[a-z]*\n([\s\S]*?)```/g)].map((m) => m[1]).join('\n');
if (!fenced.trim()) continue;
const want = codeFingerprint(fenced);
let best = -1;
let bestScore = 0;
fingerprints.forEach((fp, i) => {
if (taken.has(i)) return;
const score = similarity(want, fp);
if (score > bestScore) { bestScore = score; best = i; }
});
if (best >= 0 && bestScore >= MIN_SCORE) {
step.cell = best;
taken.add(best);
}
}
return steps;
}
const EXPECTED_SECTION = /^expected/i;
export function createTemplate({ doc, engine, root = document.body }) {
const el = build(root, doc);
const codeCells = [];
let prepared = null;
const status = STATE[doc.status] || STATE.unmeasured;
const { nearCode } = renderOverview(el, doc);
renderFiles(el, doc);
renderLinks(el, doc);
/* Render the notebook. Markdown becomes prose; code becomes a cell with its
* own bar, its own Run and its own output. */
let firstProse = true;
for (const cell of doc.cells) {
if (cell.kind === 'markdown') {
const div = document.createElement('div');
div.className = 'tt-prose';
/* Nearly every notebook opens with a heading restating its own title,
* which the page has already shown in larger type directly above. Two
* identical headings one after the other read as a rendering bug. */
div.innerHTML = renderMarkdown(firstProse ? dropTitleHeading(cell.source, doc.title) : cell.source);
firstProse = false;
if (div.textContent.trim()) el.cells.append(div);
continue;
}
const index = codeCells.length;
const box = document.createElement('div');
box.className = 'tt-cell';
/* A textarea, not a <pre>. Reading a template teaches less than changing
* one: raise the budget, drop a constraint, and watch the solver disagree
* with you. The engine already runs whatever string it is handed β€” the
* page was the only thing insisting the string could not change. */
box.innerHTML = `
<div class="tt-cell-bar">
<button class="hz-btn hz-btn-run tt-run" type="button">Run</button>
<span class="tt-cell-n">cell ${index + 1}</span>
<button class="tt-reset" type="button" hidden>Reset</button>
<span class="tt-cell-time"></span>
</div>
<textarea class="tt-code" spellcheck="false" autocomplete="off"
autocapitalize="off" autocorrect="off" wrap="off"></textarea>
<div class="tt-out" hidden></div>`;
const code = box.querySelector('.tt-code');
code.value = cell.source;
const entry = {
index,
original: cell.source,
get source() { return code.value; },
box,
code,
run: box.querySelector('.tt-run'),
reset: box.querySelector('.tt-reset'),
time: box.querySelector('.tt-cell-time'),
out: box.querySelector('.tt-out'),
done: false,
};
/* Grow to fit. A textarea defaults to a fixed number of rows and its own
* scrollbar, which for a 60-line cell means reading code through a letterbox
* β€” worse than the <pre> this replaced. */
const fit = () => {
code.style.height = 'auto';
code.style.height = `${code.scrollHeight}px`;
};
// Not called yet: scrollHeight is 0 for an element that is not in the
// document, so sizing here left every cell one line tall. Called after the
// append below, where the box has a layout.
entry.fit = fit;
code.addEventListener('input', () => {
fit();
const edited = code.value !== entry.original;
entry.reset.hidden = !edited;
box.classList.toggle('is-edited', edited);
/* An edited cell has not been run in its current form, and neither have
* the cells after it β€” they ran against what this one used to define. */
if (edited) for (const c of codeCells.slice(index)) c.done = false;
});
// ⌘/ctrl + enter, the same shortcut the query page's editor uses.
code.addEventListener('keydown', (e) => {
if (e.key === 'Enter' && (e.metaKey || e.ctrlKey)) {
e.preventDefault();
if (!entry.run.disabled) runTo(index);
}
});
entry.reset.addEventListener('click', () => {
code.value = entry.original;
fit();
entry.reset.hidden = true;
box.classList.remove('is-edited');
});
entry.run.title = 'Runs any earlier cells that have not run yet β€” ⌘/ctrl + enter';
entry.run.addEventListener('click', () => runTo(index));
if (!status.can) { entry.run.disabled = true; code.readOnly = true; }
codeCells.push(entry);
el.cells.append(box);
fit();
}
/* Three templates ship no notebook, so there is nothing to render and the
* page would be a title over blank space. Their README still explains what
* the template does, which is most of what a reader came for β€” show it rather
* than leaving a dead page behind a card that promised something. */
if (!codeCells.length && doc.readme) {
const div = document.createElement('div');
div.className = 'tt-prose';
div.innerHTML = renderMarkdown(dropTitleHeading(doc.readme, doc.title));
el.cells.append(div);
}
/* Now that the cells exist, hand the walkthrough its steps.
*
* Deliberately after the loop above: each step is placed against the cell it
* describes, which cannot be worked out before the cells are built. What is
* left over β€” steps that match no cell, and Troubleshooting β€” stays folded
* above the notebook.
*/
placeWalkthrough(el, nearCode, codeCells);
el.notebookHead.hidden = codeCells.length === 0;
el.runAll.disabled = !status.can || codeCells.length === 0;
if (el.runAll.disabled) {
el.runAll.title = doc.note || '';
// A button that can never do anything is noise on a page about reading.
el.runAll.hidden = true;
}
function setBusy(busy, text) {
el.runAll.disabled = busy || !status.can || !codeCells.length;
for (const c of codeCells) c.run.disabled = busy || !status.can;
el.progress.textContent = text || '';
}
/** Boot the engine on first use, and hand the worker this template's data. */
async function prepare() {
if (prepared) return prepared;
prepared = (async () => {
// Pick a backend before asking it for anything: the engine faΓ§ade throws
// "engine not ready" rather than booting on demand.
await engine.ready();
// csv path -> table, both as written and by basename: notebooks reference
// "data/foo.csv" and "foo.csv" interchangeably, sometimes in one file.
const tables = {};
for (const t of doc.tables || []) {
tables[t.csv] = t.table;
tables[t.csv.split('/').pop()] = t.table;
}
await engine.prepareTemplate({
slug: doc.slug,
database: doc.database || null,
tables,
packages: doc.packages || [],
});
})().catch((err) => { prepared = null; throw err; });
return prepared;
}
function show(cell, { stdout, result, error }) {
cell.out.textContent = '';
cell.out.hidden = !(stdout || result || error);
if (stdout) {
const p = document.createElement('pre');
p.className = 'tt-stdout';
p.textContent = stdout.replace(/\s+$/, '');
cell.out.append(p);
}
if (error) {
const p = document.createElement('pre');
p.className = 'tt-error';
p.textContent = error;
cell.out.append(p);
return;
}
if (!result) return;
if (result.kind === 'frame') {
const table = document.createElement('table');
table.className = 'tt-table';
const thead = document.createElement('thead');
const tr = document.createElement('tr');
for (const c of result.columns) {
const th = document.createElement('th');
th.textContent = c;
tr.append(th);
}
thead.append(tr);
table.append(thead);
const tbody = document.createElement('tbody');
for (const row of result.rows) {
const r = document.createElement('tr');
for (const v of row) {
const td = document.createElement('td');
td.textContent = v === null || v === undefined ? '' : String(v);
r.append(td);
}
tbody.append(r);
}
table.append(tbody);
cell.out.append(table);
if (result.total > MAX_ROWS) {
const more = document.createElement('div');
more.className = 'tt-more';
more.textContent = `showing ${MAX_ROWS} of ${result.total.toLocaleString()} rows`;
cell.out.append(more);
}
return;
}
const p = document.createElement('pre');
p.className = 'tt-value';
p.textContent = result.text;
cell.out.append(p);
}
/** Run one cell. Returns true if it succeeded. */
async function runOne(cell) {
cell.box.classList.remove('is-done', 'is-failed');
cell.box.classList.add('is-running');
cell.time.textContent = 'running…';
const started = performance.now();
try {
const res = await engine.runTemplateCell(cell.source);
const ms = performance.now() - started;
show(cell, res);
cell.done = !res.error;
// Something has run, so there may now be a model to read. The section
// only offers the button; nothing is built until it is pressed.
if (!res.error) el.model.hidden = false;
cell.box.classList.toggle('is-done', !res.error);
cell.box.classList.toggle('is-failed', Boolean(res.error));
cell.time.textContent = `${ms >= 1000 ? `${(ms / 1000).toFixed(1)}s` : `${Math.round(ms)}ms`}`;
return !res.error;
} catch (err) {
// A thrown error is the worker or the engine failing, not the cell β€”
// worth distinguishing, because the fix is different.
show(cell, { error: `the engine failed: ${err && err.message ? err.message : err}` });
cell.box.classList.add('is-failed');
cell.time.textContent = '';
return false;
} finally {
cell.box.classList.remove('is-running');
}
}
/** Run everything up to and including `index` that has not run yet. */
async function runTo(index) {
setBusy(true, 'starting Swan…');
try {
await prepare();
} catch (err) {
setBusy(false, '');
const cell = codeCells[index];
show(cell, { error: `could not start: ${err && err.message ? err.message : err}` });
cell.box.classList.add('is-failed');
return;
}
const pending = codeCells.slice(0, index + 1).filter((c) => !c.done);
for (const cell of pending) {
setBusy(true, `running cell ${cell.index + 1} of ${codeCells.length}…`);
cell.box.scrollIntoView({ block: 'nearest', behavior: 'smooth' });
// Stop at the first failure: later cells depend on this one, and a run
// of cascading NameErrors hides the error that actually mattered.
if (!await runOne(cell)) {
setBusy(false, `stopped at cell ${cell.index + 1}`);
return;
}
}
setBusy(false, pending.length ? 'done' : 'already run');
}
el.runAll.addEventListener('click', () => runTo(codeCells.length - 1));
/* ---- the model, once there is one ------------------------------------
*
* Offered only after a cell has run, and built only when asked. Reading the
* model means fetching ~100 KB of Python and walking the whole catalog; a
* reader who came to run the notebook should not pay for it, and before the
* cells run there is nothing to read β€” the namespace is empty and the honest
* answer would be an empty graph, which reads as a broken feature rather
* than as "nothing has happened yet".
*/
let ladder = null;
async function showModel() {
if (ladder) return;
el.modelShow.disabled = true;
el.modelNote.textContent = 'reading the model…';
try {
const { graph, models } = await engine.templateGraph();
if (!graph) {
// Not a failure: plenty of templates query data without declaring a
// model at all, and saying so beats an empty diagram.
el.modelNote.textContent = models
? 'the cells built a model this reader could not read'
: 'this template does not declare a model β€” it works directly against the tables';
el.modelShow.hidden = true;
return;
}
const { createLadder } = await import('./ladder.js');
ladder = await createLadder({
graph, container: el.modelLadder, embedded: true, navHost: el.modelNav,
});
el.modelLadder.hidden = false;
el.modelShow.hidden = true;
el.model.classList.add('is-open');
const counts = (graph.meta && graph.meta.counts) || {};
const bits = ['concepts', 'rules', 'tables']
.filter((k) => counts[k])
.map((k) => `${counts[k]} ${counts[k] === 1 ? k.replace(/s$/, '') : k}`);
/* Most templates in the corpus are optimisation problems and declare no
* rules at all. There is still a model worth seeing β€” concepts and the
* tables they map to β€” but calling that a derivation would be a promise
* the view cannot keep, so it says which one it is showing. */
el.modelNote.textContent = bits.join(' Β· ')
+ (counts.rules ? '' : ' β€” no derived values, so this shows how the concepts map to tables');
} catch (err) {
// Same reasoning as the query page: a panel that will not build must not
// take the notebook with it.
el.modelNote.textContent = `the model could not be read β€” ${err && err.message || err}`;
el.modelShow.disabled = false;
}
}
el.modelShow.addEventListener('click', showModel);
/* Hand the session back when the page goes. The worker is shared and
* outlives this page; without this its namespace, its model and its open
* connection sit there until the session cap evicts them. `pagehide` rather
* than `unload`, which does not fire reliably and blocks the bfcache. */
window.addEventListener('pagehide', () => {
if (prepared) { try { engine.closeTemplate(); } catch { /* going away anyway */ } }
}, { once: true });
return { doc, runTo, cells: codeCells, showModel };
}
/**
* Take the setup talk out of the opening sentence.
*
* The READMEs open with "designed to help you get up and running with **Swan**
* (PyRel over DuckDB) locally, implementing marketing budget optimization…".
* Half of that sentence is about installing a library, and it lands before the
* reader has been told what the template *does* β€” on a page where the thing is
* already running, it is noise in the most valuable position on the page.
*
* Only this one construction, and only its middle: the clause after it is the
* author's own description and is kept word for word.
*/
const SETUP_TALK = /\bdesigned to help you get up and running with\s+\*{0,2}Swan\*{0,2}\s*(?:\([^)]*\))?\s*locally,\s*(?:implementing\s+)?/i;
const plainer = (s) => s.replace(SETUP_TALK, 'designed to help you implement ');
/**
* README sections as folded blocks.
*
* `<details>`, so the open/closed state costs no JavaScript. The hint is built
* before the template literal rather than inlined: an inline ternary there
* reads badly, and check_escaping.mjs cannot distinguish a constant-string
* choice from an unescaped interpolation β€” it flagged one, correctly, on the
* evidence available to it.
*/
function collapsibles(sections, hintFor = () => '') {
return sections.map((s) => {
const hint = hintFor(s.title);
return `
<details class="tt-walk">
<summary>${esc(s.title)}${esc(hint)}</summary>
<div class="tt-prose">${renderMarkdown(s.body)}</div>
</details>`;
}).join('');
}
/** The README's own explanation of the template, minus what this page replaces. */
function renderOverview(el, doc) {
const { intro, sections } = splitReadme(doc.readme);
const usable = sections.filter((s) => !SKIPPED.test(s.title) && !EXPECTED_SECTION.test(s.title));
const nearCode = usable.filter((s) => NEAR_CODE.test(s.title));
/* Open if it is orienting *and* short.
*
* Name alone was not enough. "How it works" is a primary section by any
* reading, and on 27 of the 36 templates that have one it runs past 1,800
* characters β€” telco's is 3,001, where its four sibling sections total 3,100
* between them. Expanding it by name put the notebook 4,200 pixels down the
* page. The other orienting sections are 500-950 characters and belong open,
* so the length is what separates them, not the title. */
const LONG = 1800;
const keep = usable.filter((s) => PRIMARY.test(s.title) && !NEAR_CODE.test(s.title)
&& s.body.length <= LONG);
const rest = usable.filter((s) => !NEAR_CODE.test(s.title) && !keep.includes(s));
const expected = sections.find((s) => EXPECTED_SECTION.test(s.title));
if (intro || keep.length) {
const parts = [];
if (intro) parts.push(renderMarkdown(plainer(intro)));
for (const s of keep) {
parts.push(`<h2>${esc(s.title)}</h2>`, renderMarkdown(s.body));
}
/* Stands in for the Prerequisites section that was dropped. Saying what is
* *not* needed is the clearest statement of what this page is for, and it
* belongs next to the copy that would otherwise have asked for it. */
/* Stands in for the Prerequisites and Quickstart sections that were
* dropped, and covers what is left. A few kept sections still say things
* like "**Start here**: run `jupyter notebook x.ipynb`" mid-paragraph;
* those are RelationalAI's words about their own artifact, and editing
* inside their sentences would be worse than one note explaining that the
* setup they describe is not needed on this page. */
parts.push(
'<p class="tt-noprereq">This copy comes from the template\'s own README, so it describes '
+ 'running it locally β€” Python, a build of the Swan extension, <code>jupyter notebook</code>. '
+ 'None of that is needed here: the engine below is Swan compiled to WebAssembly, and it '
+ 'runs in this tab.</p>',
);
el.overview.innerHTML = `<h2>About this template</h2><div class="tt-prose">${parts.join('\n')}</div>`;
el.overview.hidden = false;
}
// The rest of the README β€” model overview, sample data, how to customise it,
// structure, further reading. Folded, in the order the author wrote them.
if (rest.length) {
el.more.innerHTML = collapsibles(rest);
el.more.hidden = false;
}
return { nearCode };
if (expected) {
el.expected.innerHTML = `<h2>${esc(expected.title)}</h2>`
+ `<div class="tt-prose">${renderMarkdown(expected.body)}</div>`;
el.expected.hidden = false;
}
}
/**
* Put each walkthrough step above the cell it explains.
*
* The point of the exercise: a reader scrolling the notebook should meet the
* explanation of a cell immediately before the cell, not have to hold a block
* of numbered steps in their head from the top of the page.
*/
function placeWalkthrough(el, sections, cells) {
const leftovers = [];
for (const section of sections) {
if (!WALKTHROUGH.test(section.title) || !cells.length) {
leftovers.push(section);
continue;
}
const steps = walkthroughSteps(section.body, cells);
for (const step of steps.filter((s) => s.cell !== undefined)) {
/* If the notebook already explains this cell, leave it alone.
*
* simple-start has both: markdown cells titled "2. Define Semantic
* Schema" and a walkthrough step of the same name, saying the same thing
* in different words. Inserting the step produced the heading twice, one
* above the other, which reads as a rendering fault. The notebook's own
* prose wins β€” it was written next to the cell, and it is what a reader
* of the .ipynb would see. */
const previous = cells[step.cell].box.previousElementSibling;
if (previous && previous.classList.contains('tt-prose')) {
step.cell = undefined;
continue;
}
const note = document.createElement('div');
note.className = 'tt-prose tt-step';
/* The step quotes the cell's code, and the cell is now directly beneath
* it β€” printing the same lines twice only pushes them apart. */
const prose = step.body.replace(/```[a-z]*\n[\s\S]*?```/g, '').trim();
note.innerHTML = `<h3>${esc(step.title)}</h3>${renderMarkdown(prose)}`;
cells[step.cell].box.before(note);
}
const rest = steps.filter((s) => s.cell === undefined && !s.skip && s.body.trim());
if (rest.length) {
leftovers.push({
title: section.title,
body: rest.map((s) => `### ${s.title}\n\n${s.body}`).join('\n\n'),
});
}
}
if (leftovers.length) {
el.walkthrough.innerHTML = collapsibles(leftovers, (title) => (/troubleshoot/i.test(title)
? '' : ' β€” the steps that did not line up with a single cell'));
el.walkthrough.hidden = false;
}
}
/**
* The data the template is grounded in, as files you can open.
*
* The engine never fetches these β€” it loads the .duckdb and writes the rows out
* itself. They are here because "what is actually in channels.csv" is the first
* question anyone reading a semantic model asks, and answering it with a link
* beats answering it with a paragraph.
*/
function renderFiles(el, doc) {
const tables = (doc.tables || []).filter((t) => t.file);
if (!tables.length) return;
const rows = tables.map((t) => `
<tr>
<td><a href="data/templates/${esc(t.file)}" download>${esc(t.csv)}</a></td>
<td class="tt-num">${Number(t.rows || 0).toLocaleString()}</td>
<td class="tt-cols">${(t.columns || []).map((c) => `<code>${esc(c)}</code>`).join(' ')}</td>
</tr>`).join('');
el.files.innerHTML = `
<h2>Data</h2>
<p class="tt-section-note">The template's own CSVs. The notebook reads them as
<code>data/&lt;name&gt;.csv</code>; in this tab they are served out of a DuckDB file built from
exactly these bytes.</p>
<div class="tt-files-wrap">
<table class="tt-files">
<thead><tr><th>File</th><th class="tt-num">Rows</th><th>Columns</th></tr></thead>
<tbody>${rows}</tbody>
</table>
</div>`;
el.files.hidden = false;
}
/** Out to RelationalAI's own page for this template, where one exists. */
function renderLinks(el, doc) {
if (!doc.docs) return;
el.links.innerHTML = `<p class="tt-docs-link">This template on
<a href="${esc(doc.docs)}" target="_blank" rel="noopener noreferrer">docs.relational.ai</a>
β€” the reference documentation, with the full code walkthrough.</p>`;
el.links.hidden = false;
}
function build(root, doc) {
root.classList.add('hz-root', 'tp-root');
const status = STATE[doc.status] || STATE.unmeasured;
const chips = [doc.industry, doc.experience_level, ...(doc.reasoning_types || []), ...(doc.tags || [])]
.filter(Boolean);
root.innerHTML = `
<header class="hz-head">
<a class="hz-brand" href="https://relational.ai" target="_blank" rel="noopener noreferrer">
<img src="web/relationalai-lockup.svg" alt="RelationalAI" width="187" height="28">
</a>
<nav class="tp-nav">
<a class="tt-back" href="templates.html">← All templates</a>
<a href="index.html">Ask the model</a>
</nav>
</header>
<section class="tt-head">
<h1>${esc(doc.title)}</h1>
<p class="tt-blurb">${esc(doc.blurb || '')}</p>
<div class="tt-chips">${chips.map((c) => `<span class="tp-tag">${esc(c)}</span>`).join('')}</div>
</section>
<div class="tt-state tt-state-${status.tone}">
<span class="tt-state-note">${esc(doc.note || '')}</span>
<div class="tt-actions">
<span class="tt-progress" data-el="progress"></span>
<button class="hz-btn hz-btn-run" data-el="runAll" type="button">Run all cells</button>
</div>
</div>
<section class="tt-section" data-el="overview" hidden></section>
<section class="tt-section tt-folds" data-el="more" hidden></section>
<section class="tt-section" data-el="files" hidden></section>
<section class="tt-section tt-notebook" data-el="notebookHead" hidden>
<h2>The notebook</h2>
<p class="tt-section-note">Every cell below is the template's own code, unedited. Run them in
order, or run one and the cells it depends on come with it.</p>
<div data-el="walkthrough" hidden></div>
</section>
<main class="tt-cells" data-el="cells"></main>
<section class="tt-section tt-model" data-el="model" hidden>
<h2>The model you just built</h2>
<p class="tt-section-note">The cells above declare concepts and rules. This reads them back
out of the running model β€” not from anything prepared in advance β€” and shows what each
derived value is built from, down to the columns it started as.</p>
<div class="tt-model-head">
<button class="hz-btn" data-el="modelShow" type="button">Show the model</button>
<span class="tt-model-note" data-el="modelNote"></span>
<div class="tt-model-nav" data-el="modelNav"></div>
</div>
<div class="tt-model-ladder" data-el="modelLadder" hidden></div>
</section>
<section class="tt-section" data-el="expected" hidden></section>
<section class="tt-section tt-more-links" data-el="links" hidden></section>
`;
const el = { root };
for (const node of root.querySelectorAll('[data-el]')) el[node.dataset.el] = node;
return el;
}