/* 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
. 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 = `
`;
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 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.
*
* ``, 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 `
${esc(s.title)}${esc(hint)}
${renderMarkdown(s.body)}
`;
}).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(`${esc(s.title)}
`, 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(
'This copy comes from the template\'s own README, so it describes '
+ 'running it locally — Python, a build of the Swan extension, jupyter notebook. '
+ 'None of that is needed here: the engine below is Swan compiled to WebAssembly, and it '
+ 'runs in this tab.
',
);
el.overview.innerHTML = `About this template
${parts.join('\n')}`;
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 = `${esc(expected.title)}
`
+ `${renderMarkdown(expected.body)}`;
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 = `${esc(step.title)}
${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) => `
${esc(t.csv)}
${Number(t.rows || 0).toLocaleString()}
${(t.columns || []).map((c) => `${esc(c)}`).join(' ')}
`).join('');
el.files.innerHTML = `
Data
The template's own CSVs. The notebook reads them as
data/<name>.csv; in this tab they are served out of a DuckDB file built from
exactly these bytes.
File Rows Columns
${rows}
`;
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 = `This template on
docs.relational.ai
— the reference documentation, with the full code walkthrough.
`;
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 = `
${esc(doc.title)}
${esc(doc.blurb || '')}
${chips.map((c) => `${esc(c)}`).join('')}
${esc(doc.note || '')}
The notebook
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.
The model you just built
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.
`;
const el = { root };
for (const node of root.querySelectorAll('[data-el]')) el[node.dataset.el] = node;
return el;
}