PageSourceSearch

https://py-feat.org/javascripts/marimo_book.js?v=0.1.26

js py-feat.org collected 2026-09-26 01:35:41 UTC 39,584 bytes, 995 lines download raw bytes

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.