1// marimo-book runtime shim. 2// 3// At page load, finds every .marimo-book-anywidget mount emitted by the 4// preprocessor, extracts the widget's ES module (inlined by marimo as a 5// data: URL), and calls module.default.render({model, el}) with a minimal 6// anywidget-compatible model object. 7// 8// No marimo runtime required. No network fetches. Safe to run on every page 9// of a Material for MkDocs (or zensical) site. 10 11(function () { 12 "use strict"; 13 14 /** Build a minimal anywidget-compatible model from an initial-value dict. */ 15 function makeModel(initial) { 16 const state = Object.assign({}, initial || {}); 17 const listeners = {}; 18 return { 19 get(key) { 20 return state[key]; 21 }, 22 set(key, value) { 23 const old = state[key]; 24 state[key] = value; 25 const subs = listeners["change:" + key] || []; 26 for (const cb of subs) { 27 try { cb(value, old); } catch (e) { console.error(e); } 28 } 29 const any = listeners["change"] || []; 30 for (const cb of any) { 31 try { cb(key, value, old); } catch (e) { console.error(e); } 32 } 33 }, 34 on(eventName, cb) { 35 (listeners[eventName] = listeners[eventName] || []).push(cb); 36 }, 37 off(eventName, cb) { 38 const subs = listeners[eventName] || []; 39 const i = subs.indexOf(cb); 40 if (i !== -1) subs.splice(i, 1); 41 }, 42 save_changes() { 43 // No kernel to sync to; widgets should tolerate a no-op here. 44 }, 45 send(content, _callbacks, _buffers) { 46 console.debug("[marimo-book] model.send ignored (static site)", content); 47 }, 48 }; 49 } 50 51 /** Safely decode a marimo data-* attribute. 52 * 53 * Attribute values are encoded as JSON-stringified strings (so the raw 54 * attribute looks like: data-js-url='"data:text/javascript;base64,..."'). 55 * First unescape HTML entities, then try JSON.parse; fall back to the raw 56 * value so malformed attributes don't crash the page. 57 */ 58 function decodeAttr(raw) { 59 if (raw == null) return null; 60 const doc = new DOMParser().parseFromString(raw, "text/html"); 61 const unescaped = doc.documentElement.textContent || raw; 62 try { 63 return JSON.parse(unescaped); 64 } catch (_) { 65 return unescaped; 66 } 67 } 68 69 /** Parse an initial-value dict, handling model_id-only blobs gracefully. */ 70 function parseInitial(raw) { 71 const value = decodeAttr(raw); 72 if (!value || typeof value !== "object") return {}; 73 // Marimo emits {"model_id": "..."} when no literal kwargs are inlined; 74 // that's a reference to the runtime's model registry and meaningless 75 // here. Start from an empty state and let the widget's JS fall back to 76 // its own defaults. 77 if (Object.keys(value).length === 1 && "model_id" in value) return {}; 78 return value; 79 } 80 81 // Marimo's runtime checks `firstElementChild.__type__ === "__custom_marimo_element__"` 82 // before calling `firstElementChild.rerender()` (when a parent <marimo-ui-element> 83 // changes its random-id, signalling that downstream cell HTML should refresh). 84 // We satisfy that contract so: 85 // 1. The runtime stops logging 86 // `[marimo-ui-element] first child must have a rerender method`. 87 // 2. We get a hook to pull live trait values out of the runtime's 88 // UIElementRegistry and propagate them to the widget's local model 89 // via model.set(trait, value), which fires the widget's existing 90 // change:<trait> listeners and the rAF loop's next frame picks up 91 // the new state â no DOM swap, no re-mount, no kernel round-trip. 92 const MARIMO_RERENDER_TYPE = "__custom_marimo_element__"; 93 94 // Lazy-loaded global driver registry keyed by parent 95 // <marimo-ui-element>.object-id. Populated from the build-time 96 // <script type="application/json" class="marimo-book-anywidget-drivers"> 97 // blob the preprocessor injects at the top of <body>. This is the only 98 // location that reliably survives WASM mode's island-content rebuild â 99 // marimo's runtime replaces every descendant of <marimo-island> when 100 // it first paints kernel output, so attributes on the build-time 101 // <marimo-ui-element> and our mount div are both wiped. The fresh 102 // ui-element keeps the same object-id, though, so the global lookup 103 // by object-id stays correct. 104 let _driverRegistry = null; 105 function loadDriverRegistry() { 106 if (_driverRegistry !== null) return _driverRegistry; 107 _driverRegistry = {}; 108 const blob = document.querySelector( 109 "script[type='application/json'].marimo-book-anywidget-drivers" 110 ); 111 if (!blob || !blob.textContent) return _driverRegistry; 112 try { 113 const parsed = JSON.parse(blob.textContent); 114 if (parsed && typeof parsed === "object") _driverRegistry = parsed; 115 } catch (_) {} 116 return _driverRegistry; 117 } 118 119 /** Read `data-driven-by` from `el`, the parent ui-element, or the global registry. 120 * 121 * Three layers of fallback: 122 * 1. The mount div's own attribute (works for static + precompute pages 123 * where the build-time div is never replaced). 124 * 2. The surrounding ui-element's attribute (works in any path where 125 * the ui-element survives but only the inner mount is rewrapped). 126 * 3. The global registry, looked up by the ui-element's object-id â 127 * the only resort in WASM mode after the kernel re-renders the 128 * entire island contents (which strips both the mount and the 129 * parent's `data-driven-by` attributes; only object-id is stable). 130 */ 131 function readDrivenBy(el) { 132 const own = el.getAttribute && el.getAttribute("data-driven-by"); 133 if (own) return own; 134 const parent = el.closest && el.closest("marimo-ui-element"); 135 if (parent) { 136 const parentOwn = parent.getAttribute("data-driven-by"); 137 if (parentOwn) return parentOwn; 138 const objId = parent.getAttribute("object-id"); 139 if (objId) { 140 const registry = loadDriverRegistry(); 141 const entry = registry[objId]; 142 if (entry) return JSON.stringify(entry); 143 } 144 } 145 return null; 146 } 147 148 /** Apply a kernel-side sliderâtrait map to the local model. */ 149 function applyDrivers(el, model) { 150 let drivenBy; 151 try { 152 drivenBy = JSON.parse(readDrivenBy(el) || "{}"); 153 } catch (_) { 154 return; 155 } 156 if (!drivenBy || typeof drivenBy !== "object") return; 157 const reg = window._marimo_private_UIElementRegistry; 158 if (!reg || typeof reg.lookupValue !== "function") return; 159 for (const [trait, objectId] of Object.entries(drivenBy)) { 160 if (typeof objectId !== "string") continue; 161 let value;
162 try { 163 value = reg.lookupValue(objectId); 164 } catch (_) { 165 continue; 166 } 167 // Skip undefined (control not yet hydrated) and {model_id: ...} blobs 168 // (anywidgets reference each other this way; not a primitive trait). 169 if (value === undefined) continue; 170 if ( 171 value && typeof value === "object" && 172 Object.keys(value).length === 1 && "model_id" in value 173 ) continue; 174 model.set(trait, value); 175 } 176 } 177 178 async function hydrateMount(el) { 179 const jsUrl = decodeAttr(el.getAttribute("data-js-url")); 180 if (!jsUrl || typeof jsUrl !== "string") { 181 console.warn("[marimo-book] mount has no data-js-url", el); 182 return; 183 } 184 let mod; 185 try { 186 mod = await import(/* @vite-ignore */ jsUrl); 187 } catch (err) { 188 console.error("[marimo-book] failed to import widget module", err, el); 189 el.textContent = "Failed to load widget."; 190 return; 191 } 192 const widget = mod && (mod.default || mod); 193 if (!widget || typeof widget.render !== "function") { 194 console.warn("[marimo-book] widget module has no .render", el, mod); 195 return; 196 } 197 // Clear placeholder text / stray children before rendering. 198 el.innerHTML = ""; 199 const initial = parseInitial(el.getAttribute("data-initial-value")); 200 const model = makeModel(initial); 201 // Pull initial slider values from the runtime registry (if WASM is up 202 // by the time we hydrate), so the first paint matches the user's 203 // current control state instead of the build-time defaults. 204 applyDrivers(el, model); 205 // Mark element so marimo's runtime treats us as a rerenderable host. 206 el.__type__ = MARIMO_RERENDER_TYPE; 207 el.rerender = function () { 208 // Marimo bumps the parent <marimo-ui-element>'s random-id every time 209 // a dependent cell finishes re-executing. Re-pull driver values so 210 // the widget reflects the new slider position. 211 applyDrivers(el, model); 212 }; 213 try { 214 const cleanup = widget.render({ model, el }); 215 if (typeof cleanup === "function") { 216 el.__marimoBookCleanup = cleanup; 217 } 218 } catch (err) { 219 console.error("[marimo-book] widget render threw", err, el); 220 } 221 } 222 223 function hydrateAll(root) { 224 const scope = root || document; 225 const mounts = scope.querySelectorAll(".marimo-book-anywidget:not([data-mb-hydrated])"); 226 mounts.forEach((el) => { 227 el.setAttribute("data-mb-hydrated", "1"); 228 hydrateMount(el); 229 }); 230 } 231 232 // ---- WASM-mode anywidget intercept -------------------------------------- 233 // 234 // Build-time `rewrite_anywidget_html` rewrites every `<marimo-anywidget>` 235 // marimo emits into our `<div class="marimo-book-anywidget">` mount form, 236 // so static + precompute pages never see a `<marimo-anywidget>` in the DOM. 237 // 238 // WASM-mode pages are different. The build-time rewrite catches the 239 // initial render produced by `MarimoIslandGenerator.build()`, but once 240 // Pyodide boots in the browser and the islands runtime re-executes the 241 // anywidget cells, marimo's React renderer emits FRESH `<marimo-anywidget>` 242 // elements with `data-js-url="data:text/javascript;base64,..."` payloads. 243 // The islands runtime's `WidgetDefRegistry.getModule` then runs an 244 // `isTrustedVirtualFileUrl` check that rejects every data: URL emitted 245 // before the kernel has finished initialising (the trust flag is set by 246 // the kernel's `initialized` message â there's a race on the first batch 247 // of widget cells), throwing 248 // "Refusing to load anywidget module from untrusted URL: data:..." 249 // and leaving the cell's output area empty. 250 // 251 // We intercept those runtime emissions with a MutationObserver on 252 // `document.body`. When a `<marimo-anywidget>` is inserted (anywhere, 253 // any depth), we copy its data-* attributes onto a fresh 254 // `<div class="marimo-book-anywidget">`, replace it, and call the same 255 // `hydrateMount` we use for the static-rewritten mounts â which loads 256 // the data: URL via the host page's `import()` (no trust check on the 257 // host) and wires up the local model. Marimo's React renderer fires
258 // first (and logs the trust warning into a now-doomed render), then 259 // the observer's callback fires and removes the element entirely; the 260 // React tree's disconnectedCallback unmounts cleanly. 261 // 262 // The current-static-shim model is local-only â anywidget state set in 263 // the browser doesn't round-trip to Pyodide. For widgets that take 264 // `mo.ui.*` controls as kwargs (where state flows kernel â widget), 265 // the cell re-execution will emit a new `<marimo-anywidget>` with 266 // updated `data-initial-value` and our intercept re-hydrates with the 267 // new state. For widgets the user mutates client-side (slider in the 268 // widget, button click), the change stays in the local model â same 269 // trade-off as static / precompute pages. 270 function rewrapMarimoAnywidget(node) { 271 if (!(node instanceof Element)) return; 272 if (node.tagName !== "MARIMO-ANYWIDGET") return; 273 if (node.dataset.mbRewrapped) return; 274 node.dataset.mbRewrapped = "1"; 275 const div = document.createElement("div"); 276 div.className = "marimo-book-anywidget"; 277 for (const attr of node.attributes) { 278 if (attr.name === "data-mb-rewrapped") continue; 279 div.setAttribute(attr.name, attr.value); 280 } 281 node.replaceWith(div); 282 div.setAttribute("data-mb-hydrated", "1"); 283 hydrateMount(div); 284 } 285 286 let _anywidgetObserver = null; 287 function installAnywidgetRuntimeIntercept(scope) { 288 scope = scope || document; 289 // Catch elements present at install time (defense if the runtime emitted 290 // some before our observer was attached). 291 scope.querySelectorAll("marimo-anywidget").forEach(rewrapMarimoAnywidget); 292 if (_anywidgetObserver) return; 293 if (typeof MutationObserver === "undefined") return; 294 _anywidgetObserver = new MutationObserver((mutations) => { 295 for (const m of mutations) { 296 for (const node of m.addedNodes) { 297 if (!(node instanceof Element)) continue; 298 if (node.tagName === "MARIMO-ANYWIDGET") { 299 rewrapMarimoAnywidget(node); 300 } else if (node.querySelectorAll) { 301 // Marimo's runtime sometimes inserts a wrapper that contains 302 // the <marimo-anywidget> as a descendant rather than at top 303 // level â scan inside. 304 node.querySelectorAll("marimo-anywidget").forEach(rewrapMarimoAnywidget); 305 } 306 } 307 } 308 }); 309 _anywidgetObserver.observe(document.body, { 310 childList: true, 311 subtree: true, 312 }); 313 } 314 315 // ---- Static reactivity (precompute) ------------------------------------ 316 // 317 // The preprocessor injects three things per page when book.precompute 318 // succeeds for that page: 319 // 320 // 1. <div class="marimo-book-precompute-control"> â empty mount where 321 // we render the input control (range / select / checkbox). 322 // 2. <div class="marimo-book-precompute-cell" data-precompute-cell="N"> 323 // â wraps each cell whose output differs across widget values. 324 // 3. Two <template> blocks: -widget (metadata) and -table (per-value 325 // cell HTML deltas). They were originally <script type="application/json"> 326 // but Material's `navigation.instant` re-creates every <script> 327 // tag on page swap and chokes on JSON's first colon with 328 // "Unexpected token ':'", silently dropping the script â leaving 329 // the shim with no data to read on instant-nav arrivals (forcing 330 // a hard refresh). <template> is inert and survives the swap. 331 // 332 // On input we look up the value's delta and swap the affected cells' 333 // innerHTML; cells absent from the delta restore to their initial 334 // (default-value) HTML, snapshotted at first init. 335 336 // Read the JSON payload from a precompute data container. Accepts both 337 // <template> (current emitter, post-Material-instant-nav-fix) and 338 // <script type="application/json"> (legacy emitter, in case stale build 339 // caches still emit the old shape). Returns null if the element is 340 // missing. 341 function readPrecomputeJson(el) { 342 if (!el) return null; 343 const text = 344 el.tagName === "TEMPLATE" 345 ? (el.content && el.content.textContent) || el.innerHTML || "" 346 : el.textContent || ""; 347 return JSON.parse(text || "{}"); 348 } 349 // Selector helpers: a precompute container can be either a <template> 350 // (current) or a <script> (legacy). Each helper appends an attribute
351 // filter to both branches so a single querySelector finds whichever one 352 // the emitter wrote. 353 function precomputeWidgetSel(filter) { 354 return `template.marimo-book-precompute-widget${filter}, script.marimo-book-precompute-widget${filter}`; 355 } 356 function precomputeTableSel(filter) { 357 return `template.marimo-book-precompute-table${filter}, script.marimo-book-precompute-table${filter}`; 358 } 359 function precomputeGroupSel(filter) { 360 return `template.marimo-book-precompute-group${filter}, script.marimo-book-precompute-group${filter}`; 361 } 362 363 function valueKey(value) { 364 return JSON.stringify(value); 365 } 366 367 function buildSliderControl(widget) { 368 const wrap = document.createElement("div"); 369 wrap.className = "marimo-book-precompute-input marimo-book-precompute-input--slider"; 370 const input = document.createElement("input"); 371 input.type = "range"; 372 input.min = "0"; 373 input.max = String(widget.values.length - 1); 374 input.step = "1"; 375 const defaultIdx = Math.max(0, widget.values.indexOf(widget.default)); 376 input.value = String(defaultIdx); 377 const label = document.createElement("output"); 378 label.className = "marimo-book-precompute-label"; 379 label.textContent = String(widget.values[defaultIdx]); 380 wrap.appendChild(input); 381 wrap.appendChild(label); 382 function getValue() { return widget.values[parseInt(input.value, 10)]; } 383 function syncLabel() { label.textContent = String(getValue()); } 384 return { wrap, input, getValue, syncLabel }; 385 } 386 387 function buildSelectControl(widget) { 388 const wrap = document.createElement("div"); 389 wrap.className = "marimo-book-precompute-input marimo-book-precompute-input--select"; 390 const select = document.createElement("select"); 391 widget.values.forEach((v, i) => { 392 const opt = document.createElement("option"); 393 opt.value = String(i); 394 opt.textContent = String(v); 395 select.appendChild(opt); 396 }); 397 const defaultIdx = Math.max(0, widget.values.indexOf(widget.default)); 398 select.value = String(defaultIdx); 399 wrap.appendChild(select); 400 function getValue() { return widget.values[parseInt(select.value, 10)]; } 401 function syncLabel() {} 402 return { wrap, input: select, getValue, syncLabel }; 403 } 404 405 function buildCheckboxControl(widget) { 406 const wrap = document.createElement("label"); 407 wrap.className = "marimo-book-precompute-input marimo-book-precompute-input--checkbox"; 408 const input = document.createElement("input"); 409 input.type = "checkbox"; 410 input.checked = widget.default === true; 411 wrap.appendChild(input); 412 const span = document.createElement("span"); 413 span.textContent = " " + (widget.var_name || "value"); 414 wrap.appendChild(span); 415 function getValue() { return input.checked; } 416 function syncLabel() {} 417 return { wrap, input, getValue, syncLabel }; 418 } 419 420 function buildControl(widget) { 421 if (widget.kind === "slider") return buildSliderControl(widget); 422 if (widget.kind === "dropdown" || widget.kind === "radio") return buildSelectControl(widget); 423 if (widget.kind === "switch") return buildCheckboxControl(widget); 424 return null; 425 } 426 427 function initPrecomputeForWidget(scope, varName) { 428 const sel = `[data-precompute-widget="${CSS.escape(varName)}"]`; 429 const widgetEl = scope.querySelector(precomputeWidgetSel(sel)); 430 const tableEl = scope.querySelector(precomputeTableSel(sel)); 431 const controlEl = scope.querySelector(".marimo-book-precompute-control" + sel); 432 if (!widgetEl || !tableEl || !controlEl) return; 433 if (controlEl.getAttribute("data-mb-precompute-init")) return; 434 435 let widget, table; 436 try { 437 widget = readPrecomputeJson(widgetEl) || {}; 438 table = readPrecomputeJson(tableEl) || {}; 439 } catch (err) { 440 // bootAll fires twice on Material instant-nav (DOMContentLoaded / 441 // immediate-eval AND document$.subscribe), and the first call 442 // sometimes catches a transient DOM where the template element is 443 // present but its text content hasn't been integrated yet â the 444 // JSON parse rejects on a truncated payload. The second call sees 445 // the complete content and succeeds. Log at debug level so the 446 // recovery is observable in DevTools but doesn't alarm users. 447 console.debug("[marimo-book] precompute JSON parse deferred for " + varName + " (will retry on next bootAll)", err); 448 return; 449 } 450 451 const built = buildControl(widget); 452 if (!built) return; 453 454 // Snapshot the initial (default-value) HTML of cells controlled by THIS 455 // widget. Cells controlled by other widgets are left alone â that's 456 // how independent multi-widget pages avoid stomping on each other. 457 const cells = scope.querySelectorAll( 458 `[data-precompute-cell]${sel}` 459 ); 460 const baseSnapshot = {};
461 cells.forEach((el) => { 462 const idx = el.getAttribute("data-precompute-cell"); 463 baseSnapshot[idx] = el.innerHTML; 464 }); 465 466 function applyValue() { 467 const value = built.getValue(); 468 const key = valueKey(value); 469 const delta = table[key] || {}; 470 cells.forEach((el) => { 471 const idx = el.getAttribute("data-precompute-cell"); 472 const html = Object.prototype.hasOwnProperty.call(delta, idx) 473 ? delta[idx] 474 : baseSnapshot[idx]; 475 if (html !== undefined && el.innerHTML !== html) { 476 el.innerHTML = html; 477 // Cell HTML swapped in is a build-time static snapshot â any 478 // <div class="marimo-book-plotly"> or <div class="marimo-book-anywidget"> 479 // inside is an un-hydrated placeholder. Re-run hydration for 480 // this cell so the plots / widgets render in the new content. 481 // Idempotent via [data-mb-plotly] / [data-mb-hydrated]. 482 hydratePlotly(el); 483 hydrateAll(el); 484 } 485 }); 486 if (typeof built.syncLabel === "function") built.syncLabel(); 487 } 488 489 built.input.addEventListener("input", applyValue); 490 built.input.addEventListener("change", applyValue); 491 controlEl.appendChild(built.wrap); 492 controlEl.setAttribute("data-mb-precompute-init", "1"); 493 } 494 495 function initPrecomputeForGroup(scope, groupId) { 496 const sel = `[data-precompute-group="${CSS.escape(groupId)}"]`; 497 const metaEl = scope.querySelector(precomputeGroupSel(sel)); 498 const tableEl = scope.querySelector(precomputeTableSel(sel)); 499 if (!metaEl || !tableEl) return; 500 501 let meta, table; 502 try { 503 meta = readPrecomputeJson(metaEl) || {}; 504 table = readPrecomputeJson(tableEl) || {}; 505 } catch (err) { 506 // See initPrecomputeForWidget â bootAll's idempotent retry covers 507 // first-pass parse races on instant-nav. 508 console.debug("[marimo-book] precompute group JSON parse deferred for " + groupId + " (will retry on next bootAll)", err); 509 return; 510 } 511 512 // Build a control for each widget in the group; controls all share 513 // an applyValue function that reads every widget's current value 514 // and constructs the combo key. 515 const widgets = meta.widgets || []; 516 const builders = []; 517 for (const widgetMeta of widgets) { 518 const built = buildControl(widgetMeta); 519 if (!built) return; // unsupported widget kind in group; bail entire group 520 builders.push(built); 521 } 522 523 const cells = scope.querySelectorAll( 524 `[data-precompute-cell]${sel}` 525 ); 526 const baseSnapshot = {}; 527 cells.forEach((el) => { 528 const idx = el.getAttribute("data-precompute-cell"); 529 baseSnapshot[idx] = el.innerHTML; 530 }); 531 532 function applyValue() { 533 const values = builders.map((b) => b.getValue()); 534 const key = JSON.stringify(values); 535 const delta = table[key] || {}; 536 cells.forEach((el) => { 537 const idx = el.getAttribute("data-precompute-cell"); 538 const html = Object.prototype.hasOwnProperty.call(delta, idx) 539 ? delta[idx] 540 : baseSnapshot[idx]; 541 if (html !== undefined && el.innerHTML !== html) { 542 el.innerHTML = html; 543 // See applyValue in initPrecomputeForWidget â same re-hydration 544 // concern for both plotly and anywidget mounts. 545 hydratePlotly(el); 546 hydrateAll(el); 547 } 548 }); 549 builders.forEach((b) => { 550 if (typeof b.syncLabel === "function") b.syncLabel(); 551 }); 552 } 553 554 // Mount each control in its own .marimo-book-precompute-control div 555 // (matched by group + widget name), and bind events. 556 widgets.forEach((widgetMeta, i) => { 557 const built = builders[i]; 558 const controlEl = scope.querySelector( 559 `.marimo-book-precompute-control${sel}[data-precompute-widget="${CSS.escape(widgetMeta.var_name)}"]` 560 ); 561 if (!controlEl || controlEl.getAttribute("data-mb-precompute-init")) return; 562 built.input.addEventListener("input", applyValue); 563 built.input.addEventListener("change", applyValue); 564 controlEl.appendChild(built.wrap); 565 controlEl.setAttribute("data-mb-precompute-init", "1"); 566 }); 567 } 568 569 function initPrecomputeOnce(scope) { 570 // Independent (per-widget) mounts. 571 const widgetMounts = scope.querySelectorAll( 572 ".marimo-book-precompute-control:not([data-mb-precompute-init])[data-precompute-widget]:not([data-precompute-group])" 573 );
574 widgetMounts.forEach((el) => { 575 initPrecomputeForWidget(scope, el.getAttribute("data-precompute-widget")); 576 }); 577 578 // Joint-group mounts. Init each group once even though multiple 579 // controls share its group ID. 580 const seen = new Set(); 581 const groupMounts = scope.querySelectorAll( 582 ".marimo-book-precompute-control:not([data-mb-precompute-init])[data-precompute-group]" 583 ); 584 groupMounts.forEach((el) => { 585 const gid = el.getAttribute("data-precompute-group"); 586 if (seen.has(gid)) return; 587 seen.add(gid); 588 initPrecomputeForGroup(scope, gid); 589 }); 590 } 591 592 /** Move launch buttons into Material's header bar. 593 * 594 * The preprocessor renders the row server-side as 595 * `<div class="marimo-book-buttons" data-placement="header">â¦</div>`. 596 * In header-mode, that source row is hidden via CSS and we mount a 597 * cloned copy as a child of `.md-header__inner` so the buttons sit 598 * in the global top bar. The clone gets the modifier class 599 * `marimo-book-buttons--header` which switches the styling to 600 * icon-only with a tighter footprint. 601 * 602 * Also wires `data-marimo-book-print` anchors to window.print() â 603 * users get a per-page PDF via the browser's "Save as PDF" without 604 * any server-side mkdocs-with-pdf config. 605 */ 606 function mountHeaderButtons(scope) { 607 const headerInner = scope.querySelector(".md-header__inner"); 608 // Find the ORIGINAL source row (not a previous clone). The clone 609 // copies all attrs, so we filter on parent: the source lives in the 610 // page main, the clone lives in headerInner. 611 const candidates = scope.querySelectorAll('.marimo-book-buttons[data-placement="header"]'); 612 let source = null; 613 for (const c of candidates) { 614 if (!headerInner || !headerInner.contains(c)) { 615 source = c; 616 break; 617 } 618 } 619 if (!headerInner || !source || source.hasAttribute("data-mb-relocated")) return; 620 source.setAttribute("data-mb-relocated", ""); 621 // Remove any stale clones from a prior page (Material instant-nav 622 // re-renders the header on each navigation, but bootAll runs again 623 // so we'd double-mount without this). 624 headerInner.querySelectorAll(".marimo-book-buttons--header").forEach( 625 (el) => el.remove() 626 ); 627 const clone = source.cloneNode(true); 628 clone.classList.add("marimo-book-buttons--header"); 629 clone.removeAttribute("data-mb-relocated"); 630 // Insert before the search slot if present, else before the repo 631 // link, else just append. This keeps the buttons left of Material's 632 // chrome (search + repo link) so they don't get pushed off-screen. 633 const search = headerInner.querySelector('[data-md-component="search"]'); 634 const repo = headerInner.querySelector(".md-header__source"); 635 const anchor = search || repo; 636 if (anchor) { 637 headerInner.insertBefore(clone, anchor); 638 } else { 639 headerInner.appendChild(clone); 640 } 641 } 642 643 // Plotly hydration. Marimo emits `<marimo-plotly data-figure='{json}'>` 644 // for each figure; we rewrap it as `<div class="marimo-book-plotly">` 645 // server-side. This shim loads Plotly.js once on first encounter, then 646 // calls `Plotly.newPlot` per mount. Idempotent via [data-mb-plotly]. 647 let _plotlyLoading = null; 648 function loadPlotly() { 649 if (window.Plotly) return Promise.resolve(window.Plotly); 650 if (_plotlyLoading) return _plotlyLoading; 651 _plotlyLoading = new Promise((resolve, reject) => { 652 const s = document.createElement("script"); 653 s.src = "https://cdn.jsdelivr.net/npm/[email protected]/plotly.min.js"; 654 s.crossOrigin = "anonymous"; 655 s.onload = () => resolve(window.Plotly); 656 s.onerror = () => reject(new Error("Failed to load Plotly.js")); 657 document.head.appendChild(s); 658 }); 659 return _plotlyLoading; 660 } 661 662 function hydratePlotly(scope) { 663 const mounts = scope.querySelectorAll(".marimo-book-plotly:not([data-mb-plotly])"); 664 if (!mounts.length) return; 665 loadPlotly().then((Plotly) => { 666 mounts.forEach((mount) => { 667 if (mount.hasAttribute("data-mb-plotly")) return; 668 mount.setAttribute("data-mb-plotly", ""); 669 let figure; 670 try { 671 figure = JSON.parse(mount.getAttribute("data-figure") || "{}"); 672 } catch (e) { 673 console.warn("marimo-book: bad plotly data-figure", e); 674 return; 675 } 676 let config = {}; 677 try { 678 config = JSON.parse(mount.getAttribute("data-config") || "{}"); 679 } catch (e) { 680 // ignore â empty config is fine 681 } 682 const layout = figure.layout || {}; 683 const responsive = { responsive: true, displaylogo: false, ...config }; 684 // Register animation frames after the initial plot â without this the 685 // serialized `figure.frames` are dropped, so play/pause buttons and the 686 // slider have nothing to animate (the figure renders only frame 0). 687 Plotly.newPlot(mount, figure.data || [], layout, responsive).then(() => { 688 if (Array.isArray(figure.frames) && figure.frames.length) { 689 Plotly.addFrames(mount, figure.frames); 690 } 691 }); 692 }); 693 }).catch((e) => console.warn("marimo-book: plotly hydration failed", e)); 694 } 695 696 // --- Release-download component ------------------------------------------ 697 // 698 // Placeholders `<div data-mb-release-download data-repo data-app-name
699 // data-platforms>` are hydrated client-side: fetch the repo's latest 700 // GitHub release, match assets to platforms, render OS-aware download 701 // cards. Build stays hermetic; data is always current. Falls back to a 702 // plain releases link on any error (network, rate-limit, private repo). 703 const RD_CACHE_TTL_MS = 60 * 60 * 1000; // 1 hour 704 705 function rdDetectPlatform() { 706 if (typeof navigator === "undefined") return "unknown"; 707 const ua = navigator.userAgent; 708 if (/Mac/.test(ua)) return "mac-arm"; // default Apple Silicon 709 if (/Win/.test(ua)) return "windows"; 710 if (/Linux/.test(ua) && !/Android/.test(ua)) return "linux"; 711 return "unknown"; 712 } 713 714 function rdFormatSize(bytes) { 715 if (!bytes) return ""; 716 return (bytes / (1024 * 1024)).toFixed(1) + " MB"; 717 } 718 719 function rdExtension(name) { 720 if (/\.AppImage$/i.test(name)) return "APPIMAGE"; 721 if (/\.tar\.gz$/i.test(name)) return "TAR.GZ"; 722 const ext = name.split(".").pop() || ""; 723 return ext.toUpperCase(); 724 } 725 726 // Raw read, ignoring TTL. The TTL only decides whether to *revalidate* 727 // (send If-None-Match) â a 304 means our stored payload is still current, 728 // so the 304 path reads it raw rather than re-erroring as "expired". 729 function rdReadRaw(repo) { 730 try { 731 const raw = sessionStorage.getItem("mb-rd-" + repo); 732 if (!raw) return null; 733 const obj = JSON.parse(raw); 734 if (!obj || typeof obj.ts !== "number") return null; 735 return obj; 736 } catch (_e) { 737 return null; 738 } 739 } 740 741 function rdGetCached(repo) { 742 const obj = rdReadRaw(repo); 743 if (!obj) return null; 744 return Date.now() - obj.ts > RD_CACHE_TTL_MS ? null : obj.data; 745 } 746 747 function rdSetCached(repo, data) { 748 try { 749 sessionStorage.setItem( 750 "mb-rd-" + repo, 751 JSON.stringify({ data, ts: Date.now() }) 752 ); 753 } catch (_e) { 754 /* storage full / unavailable â non-fatal */ 755 } 756 } 757 758 // Short-lived negative cache: after a failed fetch (offline, rate-limited, 759 // private repo) don't re-hit the API on every instant-nav / reload for a 760 // while â just show the fallback. Unauthenticated GitHub allows only 761 // 60 req/hr/IP, so a docs site without this could lock itself out. 762 const RD_NEG_TTL_MS = 10 * 60 * 1000; 763 764 function rdNegativeCached(repo) { 765 try { 766 const ts = parseInt(sessionStorage.getItem("mb-rd-neg-" + repo) || "", 10); 767 return !isNaN(ts) && Date.now() - ts < RD_NEG_TTL_MS; 768 } catch (_e) { 769 return false; 770 } 771 } 772 773 function rdSetNegative(repo) { 774 try { 775 sessionStorage.setItem("mb-rd-neg-" + repo, String(Date.now())); 776 } catch (_e) { 777 /* non-fatal */ 778 } 779 } 780 781 function rdMatchAssets(json, platforms) { 782 const assets = Array.isArray(json.assets) ? json.assets : []; 783 const out = []; 784 platforms.forEach((p) => { 785 const needle = (p.match || "").toLowerCase(); 786 const hit = assets.find( 787 (a) => a.name && a.name.toLowerCase().includes(needle) 788 ); 789 if (hit) { 790 out.push({ 791 key: p.key || p.label, 792 label: p.label, 793 url: hit.browser_download_url, 794 name: hit.name, 795 size: hit.size, 796 }); 797 } 798 }); 799 return { version: json.tag_name, assets: out }; 800 } 801 802 // Build elements via the DOM (textContent, not innerHTML) so externally- 803 // influenced strings (release tag names, asset filenames from the GitHub 804 // API) can never inject markup. hrefs are scheme-guarded to http(s). 805 function rdEl(tag, cls, text) { 806 const node = document.createElement(tag); 807 if (cls) node.className = cls; 808 if (text != null) node.textContent = text; 809 return node; 810 } 811 812 function rdSafeHref(url) { 813 try { 814 const u = new URL(url, window.location.href); 815 return u.protocol === "https:" || u.protocol === "http:" ? u.href : "#"; 816 } catch (_e) { 817 return "#"; 818 } 819 } 820 821 function rdClear(el) { 822 while (el.firstChild) el.removeChild(el.firstChild); 823 } 824 825 function rdRenderFallback(el, repo, appName) { 826 rdClear(el); 827 const a = rdEl( 828 "a", 829 "marimo-book-release-download__fallback", 830 "Download the latest " + (appName || "release") + " on GitHub" 831 ); 832 a.href = rdSafeHref("https://github.com/" + repo + "/releases/latest"); 833 a.target = "_blank"; 834 a.rel = "noopener"; 835 el.appendChild(a); 836 } 837 838 function rdRender(el, release, appName) { 839 if (!release || !release.assets || release.assets.length === 0) { 840 rdRenderFallback(el, el.getAttribute("data-repo"), appName); 841 return; 842 } 843 const detected = rdDetectPlatform(); 844 const single = release.assets.length === 1; 845 // UA can't reliably distinguish Apple Silicon from Intel, so when a 846 // release ships BOTH mac builds we can't honestly recommend one â show 847 // neither as "recommended" rather than steering Intel users to arm64. 848 const macCount = release.assets.filter( 849 (a) => a.key === "mac-arm" || a.key === "mac-intel" 850 ).length; 851 const macAmbiguous = detected === "mac-arm" && macCount > 1; 852 853 rdClear(el); 854 855 const head = rdEl("div", "marimo-book-release-download__head"); 856 head.appendChild( 857 rdEl( 858 "span", 859 "marimo-book-release-download__version", 860 "Latest: " + (release.version || "") 861 ) 862 ); 863 el.appendChild(head); 864 865 const grid = rdEl( 866 "div", 867 "marimo-book-release-download__grid" + 868 (single ? " marimo-book-release-download__grid--single" : "") 869 );
870 release.assets.forEach((a) => { 871 const recommended = a.key === detected && !single && !macAmbiguous; 872 const card = rdEl( 873 "a", 874 "marimo-book-release-card" + 875 (recommended ? " marimo-book-release-card--recommended" : "") 876 ); 877 card.href = rdSafeHref(a.url); 878 card.setAttribute( 879 "aria-label", 880 "Download " + (appName || "") + " for " + a.label 881 ); 882 if (recommended) { 883 card.appendChild( 884 rdEl("span", "marimo-book-release-card__rec", "Recommended for you") 885 ); 886 } 887 card.appendChild(rdEl("span", "marimo-book-release-card__plat", a.label)); 888 const meta = rdExtension(a.name) + (a.size ? " · " + rdFormatSize(a.size) : ""); 889 card.appendChild(rdEl("span", "marimo-book-release-card__meta", meta)); 890 grid.appendChild(card); 891 }); 892 el.appendChild(grid); 893 } 894 895 function hydrateReleaseDownloads(scope) { 896 const mounts = scope.querySelectorAll( 897 "[data-mb-release-download]:not([data-mb-rd-init])" 898 ); 899 mounts.forEach((el) => { 900 el.setAttribute("data-mb-rd-init", ""); 901 const repo = el.getAttribute("data-repo"); 902 const appName = el.getAttribute("data-app-name") || ""; 903 if (!repo) { 904 return; 905 } 906 let platforms = [];
907 try { 908 platforms = JSON.parse(el.getAttribute("data-platforms") || "[]"); 909 } catch (_e) { 910 platforms = []; 911 } 912 913 const cached = rdGetCached(repo); 914 if (cached) { 915 rdRender(el, cached, appName); 916 return; 917 } 918 if (rdNegativeCached(repo)) { 919 rdRenderFallback(el, repo, appName); 920 return; 921 } 922 923 const api = "https://api.github.com/repos/" + repo + "/releases/latest"; 924 const headers = { Accept: "application/vnd.github+json" }; 925 let etag = null; 926 try { 927 etag = sessionStorage.getItem("mb-rd-etag-" + repo); 928 } catch (_e) { 929 /* no storage */ 930 } 931 if (etag) headers["If-None-Match"] = etag; 932 933 fetch(api, { headers }) 934 .then((res) => { 935 if (res.status === 304) { 936 // Our stored payload is still current â read it RAW (ignoring 937 // the TTL that triggered this revalidation) and refresh the TTL. 938 const raw = rdReadRaw(repo); 939 if (raw) { 940 rdSetCached(repo, raw.data); 941 return raw.data; 942 } 943 throw new Error("304 without a cached payload"); 944 } 945 if (!res.ok) throw new Error("HTTP " + res.status); 946 const tag = res.headers.get("ETag"); 947 if (tag) { 948 try { 949 sessionStorage.setItem("mb-rd-etag-" + repo, tag); 950 } catch (_e) { 951 /* no storage */ 952 } 953 } 954 return res.json().then((json) => { 955 const data = rdMatchAssets(json, platforms); 956 rdSetCached(repo, data); 957 return data; 958 }); 959 }) 960 .then((data) => rdRender(el, data, appName)) 961 .catch(() => { 962 rdSetNegative(repo); 963 rdRenderFallback(el, repo, appName); 964 }); 965 }); 966 } 967 968 function bootAll(root) { 969 const scope = root || document; 970 hydrateAll(scope); 971 initPrecomputeOnce(scope); 972 mountHeaderButtons(scope); 973 hydratePlotly(scope); 974 hydrateReleaseDownloads(scope); 975 installAnywidgetRuntimeIntercept(document); 976 } 977 978 // Boot chain: belt-and-suspenders so we run on direct page loads AND 979 // on Material's instant-navigation swaps. All boot work is idempotent 980 // (guarded by `:not([data-mb-precompute-init])` and `:not([data-mb-hydrated])` 981 // so multiple calls are safe). This matters because Material's 982 // `document$` is a Subject â subscribers added AFTER its initial 983 // emission miss the initial document. Our `defer` script can race 984 // that emission depending on script-tag ordering, so we always boot 985 // once via DOMContentLoaded / immediate, AND ALSO subscribe to 986 // document$ for instant-nav. 987 if (document.readyState === "loading") { 988 document.addEventListener("DOMContentLoaded", () => bootAll(document)); 989 } else { 990 bootAll(document); 991 } 992 if (typeof document$ !== "undefined" && document$.subscribe) { 993 document$.subscribe(() => bootAll(document)); 994 } 995})();
Line numbers count LF bytes from the start of the resource, as the search results do. Vendor segments are library code the classifier recognised; they are stored but not indexed. Bytes are shown as Latin1 characters, one per byte.