Spaces:
Running
Running
Deploy 4ed0390343adb220188e58f95ab1a8e7e1dd995e (manual: Actions blocked on billing) (part 2)
1b9ed71 verified | /* 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/<name>.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; | |
| } | |