Publish opted-in marimo notebooks at /notebooks
Notebooks from the marimo server can now appear in the garden. A notebook
opts in with an HTML comment in one of its markdown cells — invisible when
rendered, greppable in the .py source:
<!-- garden:publish
title: Dream of Spotification
order: 30
-->
Default-deny on purpose: garden.c0smere.net is public and the export bakes
each notebook's executed output into the page, not just its code.
export-notebooks.py also carries a NEVER_PUBLISH list (genome_*, coursework)
so a marker pasted into one of those refuses loudly instead of publishing.
Pipeline: export-notebooks.py runs each marked notebook in a one-shot
marimo container (same image/env/GPU as the live server, so it hits the real
databases) into notebooks-export/ + index.json; copy-notebooks.mjs stages
those to public/nb/; Astro reads index.json to build the pages and the
sidebar section.
Isolation choices worth keeping:
- Own timer and own lock, separate from the 5-min garden build — executing a
notebook takes minutes and must never hold up a build tick.
- Cached on notebook content; both index.json and .cache.json go through
write_if_changed, since auto-build.sh hashes mtimes under notebooks-export/
and an unconditional rewrite would force a full rebuild every 30 minutes.
- Per-notebook timeout; a failure keeps the previous export and continues.
- Runs against a throwaway copy of the notebook dir, so notebooks that write
scratch files don't dirty the notebooks repo.
- Notebooks are NOT injected into the garden collection — they aren't vault
notes, and doing so would move noteCount and the sitemap.
build.format:'file' makes the listing a file (notebooks.html) beside a
directory of detail pages. Verified against the running nginx: /notebooks and
/notebooks/<slug> resolve through the generic try_files but /notebooks/ does
not, so nginx.conf gets an explicit location for the trailing-slash form.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
+63
-2
@@ -5,6 +5,7 @@ import { buildNavTree } from '../lib/nav.mjs';
|
||||
import { COMMIT, BUILT_AT } from '../lib/build-info.mjs';
|
||||
import NavTree from '../components/NavTree.astro';
|
||||
import Toc from '../components/Toc.astro';
|
||||
import { loadNotebooks, notebookUrl } from '../lib/notebooks.mjs';
|
||||
|
||||
interface Props {
|
||||
title: string;
|
||||
@@ -13,12 +14,27 @@ interface Props {
|
||||
// which /og/<slug>.png card this page advertises; synthetic listing
|
||||
// pages fall back to the site card
|
||||
ogSlug?: string;
|
||||
// widen the content column — for embedded notebooks, which are far too
|
||||
// wide for the 44rem prose measure
|
||||
wide?: boolean;
|
||||
}
|
||||
const { title, description, headings = [], ogSlug = 'index' } = Astro.props;
|
||||
const {
|
||||
title,
|
||||
description,
|
||||
headings = [],
|
||||
ogSlug = 'index',
|
||||
wide = false,
|
||||
} = Astro.props;
|
||||
const isHome = Astro.url.pathname === '/';
|
||||
const entries = await getCollection('garden');
|
||||
const navTree = buildNavTree(entries);
|
||||
const noteCount = entries.length;
|
||||
// Notebooks are a separate section, NOT injected into the garden collection:
|
||||
// they aren't vault notes, so they must not move noteCount or the sitemap.
|
||||
const notebooks = loadNotebooks();
|
||||
const navPath = decodeURIComponent(Astro.url.pathname)
|
||||
.replace(/index\.html$/, '')
|
||||
.replace(/\.html$/, '');
|
||||
const showToc = headings.filter((h) => h.depth <= 3).length >= 2;
|
||||
const ogImage = new URL(`/og/${ogSlug}.png`, Astro.site);
|
||||
const pageUrl = new URL(Astro.url.pathname, Astro.site);
|
||||
@@ -60,7 +76,7 @@ const pageUrl = new URL(Astro.url.pathname, Astro.site);
|
||||
src="https://plausible.io/js/script.js"></script>
|
||||
</head>
|
||||
<body class={isHome ? 'home' : undefined}>
|
||||
<div class="layout">
|
||||
<div class:list={['layout', wide && 'layout-wide']}>
|
||||
<div class="content-col">
|
||||
<header>
|
||||
<div class="site-head">
|
||||
@@ -150,6 +166,29 @@ const pageUrl = new URL(Astro.url.pathname, Astro.site);
|
||||
<aside class="sidebar">
|
||||
<p class="sidebar-title"><a href="/">the garden</a></p>
|
||||
<NavTree nodes={navTree} currentPath={Astro.url.pathname} />
|
||||
{
|
||||
notebooks.length > 0 && (
|
||||
<div class="sidebar-section">
|
||||
<p class="sidebar-title">
|
||||
<a href="/notebooks">notebooks</a>
|
||||
</p>
|
||||
<ul class="nav-tree">
|
||||
{notebooks.map((nb) => (
|
||||
<li>
|
||||
<a
|
||||
href={notebookUrl(nb.slug)}
|
||||
aria-current={
|
||||
navPath === notebookUrl(nb.slug) ? 'page' : undefined
|
||||
}
|
||||
>
|
||||
{nb.title}
|
||||
</a>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
</aside>
|
||||
{
|
||||
showToc && (
|
||||
@@ -692,11 +731,24 @@ const pageUrl = new URL(Astro.url.pathname, Astro.site);
|
||||
padding: 1.5rem 0.5rem 1.5rem 0;
|
||||
}
|
||||
}
|
||||
/* notebook pages: the embedded marimo frame needs far more than the
|
||||
44rem prose measure, so the content column takes whatever is left */
|
||||
@media (min-width: 72rem) {
|
||||
.layout-wide {
|
||||
grid-template-columns: 17rem minmax(0, 1fr);
|
||||
max-width: 96rem;
|
||||
}
|
||||
}
|
||||
@media (min-width: 88rem) {
|
||||
.layout {
|
||||
grid-template-columns: 17rem minmax(0, 44rem) 13rem;
|
||||
max-width: 80rem;
|
||||
}
|
||||
/* wide pages keep the two-column shape — no TOC rail */
|
||||
.layout-wide {
|
||||
grid-template-columns: 17rem minmax(0, 1fr);
|
||||
max-width: 96rem;
|
||||
}
|
||||
.toc-col {
|
||||
display: block;
|
||||
position: sticky;
|
||||
@@ -931,6 +983,15 @@ const pageUrl = new URL(Astro.url.pathname, Astro.site);
|
||||
font-weight: 700;
|
||||
color: var(--fg);
|
||||
}
|
||||
/* second sidebar block (notebooks) — hairline off the garden tree */
|
||||
.sidebar-section {
|
||||
margin-top: 1.75rem;
|
||||
padding-top: 1rem;
|
||||
border-top: 1px solid var(--hair);
|
||||
}
|
||||
.sidebar-section > ul {
|
||||
padding-left: 0;
|
||||
}
|
||||
|
||||
/* ---- table of contents ---- */
|
||||
.toc-title {
|
||||
|
||||
@@ -0,0 +1,30 @@
|
||||
// Published marimo notebooks, as recorded by deploy/export-notebooks.py.
|
||||
//
|
||||
// The export script is the only thing that reads the publish markers in the
|
||||
// notebooks themselves; it hands the build this index.json. That keeps the
|
||||
// Astro build free of any dependency on the notebook directory — it isn't
|
||||
// even mounted into the build container — and means a malformed marker can
|
||||
// only ever break an export, never the garden.
|
||||
import fs from 'node:fs';
|
||||
import path from 'node:path';
|
||||
import { fileURLToPath } from 'node:url';
|
||||
|
||||
const INDEX = path.join(
|
||||
path.dirname(fileURLToPath(import.meta.url)),
|
||||
'..',
|
||||
'..',
|
||||
'notebooks-export',
|
||||
'index.json',
|
||||
);
|
||||
|
||||
export function loadNotebooks() {
|
||||
try {
|
||||
const parsed = JSON.parse(fs.readFileSync(INDEX, 'utf8'));
|
||||
return Array.isArray(parsed) ? parsed : [];
|
||||
} catch {
|
||||
return []; // never exported: the section just doesn't render
|
||||
}
|
||||
}
|
||||
|
||||
export const notebookUrl = (slug) => `/notebooks/${slug}`;
|
||||
export const notebookRawUrl = (slug) => `/nb/${slug}.html`;
|
||||
@@ -0,0 +1,74 @@
|
||||
---
|
||||
import Base from '../../layouts/Base.astro';
|
||||
import { loadNotebooks, notebookRawUrl } from '../../lib/notebooks.mjs';
|
||||
|
||||
export function getStaticPaths() {
|
||||
return loadNotebooks().map((nb) => ({ params: { slug: nb.slug }, props: { nb } }));
|
||||
}
|
||||
|
||||
const { nb } = Astro.props;
|
||||
const raw = notebookRawUrl(nb.slug);
|
||||
const ranAt = nb.exportedAt
|
||||
? new Date(nb.exportedAt).toLocaleString('en-US', {
|
||||
year: 'numeric',
|
||||
month: 'short',
|
||||
day: 'numeric',
|
||||
hour: 'numeric',
|
||||
minute: '2-digit',
|
||||
timeZone: 'America/New_York',
|
||||
})
|
||||
: null;
|
||||
---
|
||||
|
||||
<Base title={nb.title} description={nb.description} wide>
|
||||
<h1>{nb.title}</h1>
|
||||
{nb.description && <p>{nb.description}</p>}
|
||||
|
||||
<p class="nb-bar">
|
||||
<a href={raw} target="_blank" rel="noopener">open standalone ↗</a>
|
||||
{ranAt && <span class="nb-when">· last run {ranAt} ET</span>}
|
||||
{nb.stale && (
|
||||
<span class="nb-stale">
|
||||
· the notebook has changed since this run
|
||||
</span>
|
||||
)}
|
||||
</p>
|
||||
|
||||
{/* marimo pages are tall and set their own scrolling, so the frame gets a
|
||||
fixed viewport-relative height rather than trying to autosize */}
|
||||
<iframe
|
||||
class="nb-frame"
|
||||
src={raw}
|
||||
title={`${nb.title} — marimo notebook`}
|
||||
loading="lazy"></iframe>
|
||||
|
||||
<style>
|
||||
.nb-bar {
|
||||
margin: 0.75rem 0 1rem;
|
||||
font-size: 0.8rem;
|
||||
color: var(--muted);
|
||||
}
|
||||
.nb-bar a {
|
||||
font-size: 0.8rem;
|
||||
}
|
||||
.nb-when,
|
||||
.nb-stale {
|
||||
margin-left: 0.25rem;
|
||||
}
|
||||
.nb-stale {
|
||||
color: #d97706;
|
||||
}
|
||||
.nb-frame {
|
||||
display: block;
|
||||
width: 100%;
|
||||
height: min(88vh, 1200px);
|
||||
min-height: 32rem;
|
||||
border: 1px solid var(--hair);
|
||||
border-radius: 4px;
|
||||
/* exports inherit the marimo server's dark theme, so the frame sits
|
||||
flush with the page instead of flashing a white slab while it loads */
|
||||
background: var(--bg);
|
||||
color-scheme: dark;
|
||||
}
|
||||
</style>
|
||||
</Base>
|
||||
@@ -0,0 +1,71 @@
|
||||
---
|
||||
import Base from '../../layouts/Base.astro';
|
||||
import { loadNotebooks, notebookUrl } from '../../lib/notebooks.mjs';
|
||||
|
||||
const notebooks = loadNotebooks();
|
||||
const fmt = (iso) =>
|
||||
iso
|
||||
? new Date(iso).toLocaleDateString('en-US', {
|
||||
year: 'numeric',
|
||||
month: 'short',
|
||||
day: 'numeric',
|
||||
timeZone: 'America/New_York',
|
||||
})
|
||||
: null;
|
||||
---
|
||||
|
||||
<Base
|
||||
title="notebooks"
|
||||
description="Interactive marimo notebooks — data science work from the homelab, executed against live data and snapshotted."
|
||||
>
|
||||
<h1>notebooks</h1>
|
||||
<p>
|
||||
marimo notebooks from the homelab, re-executed against their real data
|
||||
sources on a schedule and snapshotted here. Code is shown alongside its
|
||||
output — these are the working documents, not writeups.
|
||||
</p>
|
||||
|
||||
{
|
||||
notebooks.length === 0 ? (
|
||||
<p class="nb-empty">No notebooks are published right now.</p>
|
||||
) : (
|
||||
<ul class="nb-list">
|
||||
{notebooks.map((nb) => (
|
||||
<li>
|
||||
<a href={notebookUrl(nb.slug)}>{nb.title}</a>
|
||||
{nb.description && <p class="nb-desc">{nb.description}</p>}
|
||||
{fmt(nb.exportedAt) && (
|
||||
<p class="nb-meta">last run {fmt(nb.exportedAt)}</p>
|
||||
)}
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
)
|
||||
}
|
||||
|
||||
<style>
|
||||
.nb-list {
|
||||
list-style: none;
|
||||
margin: 2rem 0 0;
|
||||
padding: 0;
|
||||
}
|
||||
.nb-list li {
|
||||
margin: 0 0 1.6rem;
|
||||
padding-left: 0.9rem;
|
||||
border-left: 2px solid var(--hair);
|
||||
}
|
||||
.nb-list > li > a {
|
||||
font-size: 1.05rem;
|
||||
}
|
||||
.nb-desc {
|
||||
margin: 0.3rem 0 0;
|
||||
color: var(--muted);
|
||||
}
|
||||
.nb-meta,
|
||||
.nb-empty {
|
||||
margin: 0.3rem 0 0;
|
||||
font-size: 0.75rem;
|
||||
color: var(--muted);
|
||||
}
|
||||
</style>
|
||||
</Base>
|
||||
Reference in New Issue
Block a user