I keep my coding-question notes in a separate repo, my-coding-notes. Every problem is a Jupyter notebook built from the same template. It covers the problem, my thinking, my own solution and why it was slow, the best-practice approaches, pytest tests, a bit of timing, and the pattern to remember. Those notes were only readable on GitHub, which renders notebooks well enough but doesn’t make them easy to browse.
This round of work adds a Notes section to the blog. It lists every notebook with difficulty and topic filters, renders each one as a proper article, and lets you download the original .ipynb, a clean Markdown copy, or open it straight in Google Colab.
Picking how the notes get here
There were three realistic ways to get notebooks from one repo into another:
- A copy script that converts notebooks to Markdown and commits them into the blog repo. Simple, but it’s a manual step for every new note, and I would forget it.
- A git submodule. It works, but every new note then means bumping the submodule pointer, which is exactly the fiddly kind of chore I wanted to avoid.
- Fetch from GitHub at build time. The notes repo is public, so the blog can read the notebooks directly while it builds. The two repos stay independent, and a redeploy is all it takes to pick up new notes.
I went with option 3. Astro 5’s content layer makes it a natural fit, because a collection can be backed by any custom loader, not just files on disk.
The notebook template is the schema
The reason this works at all is that every notebook starts from the same template. The first cell is a title plus a small metadata table:
# Two Sum
| Field | Value |
| ---------- | ----------------------------------------------------- |
| Source | [LeetCode #1](https://leetcode.com/problems/two-sum/) |
| Difficulty | Easy |
| Topics | `array`, `hash map` |
| Date | 2026-10-05 |
After that come the same sections every time: ## 1. Problem, ## 2. Thinking, ## 3. Solution, ## 4. Tests, ## 5. Scratch / Experiments and ## 6. Takeaways. Because the structure is predictable, a small parser can turn each notebook into frontmatter-style data plus a Markdown body. That’s everything a collection entry needs.
A custom content loader
The collection is declared like any other, except it uses loader instead of type: "content":
// src/content/config.ts
const notes = defineCollection({
loader: notesLoader({ repo: "tomstao/my-coding-notes" }),
schema: z.object({
title: z.string(),
number: z.number().optional(),
difficulty: z.enum(["Easy", "Medium", "Hard"]).optional(),
topics: z.array(z.string()),
date: z.coerce.date().optional(),
sourceUrl: z.string().url().optional(),
sourceLabel: z.string().optional(),
summary: z.string(),
file: z.string(),
raw: z.string(),
}),
})
The loader lists the notes/ folder through the GitHub contents API, downloads each notebook from its raw URL, parses it, and writes it to the content store. The neat part is renderMarkdown, which Astro passes into every loader. It runs the body through the same Markdown pipeline as my blog posts, so the notes get Shiki highlighting and heading IDs for free. Pages can then use the usual render(entry) call to get <Content /> and the headings for the table of contents.
// src/content/loaders/notes.ts
load: async ({ store, logger, parseData, renderMarkdown, generateDigest }) => {
// ...list the notebooks (GitHub, or a local folder via NOTES_DIR)
for (const file of files) {
// "0001_two_sum.ipynb" -> "0001-two-sum"
const id = file.name.replace(/\.ipynb$/, "").replace(/_/g, "-")
const raw = await file.read()
const { body, ...fields } = parseNotebook(JSON.parse(raw))
const data = await parseData({ id, data: { ...fields, file: file.name, raw } })
store.set({
id,
data,
body,
rendered: await renderMarkdown(body),
digest: generateDigest(raw),
})
}
}
Two small decisions in there are worth calling out.
Fail loudly in production, softly in dev. If GitHub can’t be reached during a Vercel or CI build, the loader throws, and the build fails. A failed build keeps the previous deployment live, which is much better than silently shipping a site with an empty Notes page. Locally, it just logs a warning and keeps whatever was cached, so working offline doesn’t take the whole dev server down.
try {
files = localDir ? await listLocal(path.resolve(localDir)) : await listGitHub()
} catch (error) {
// Never ship a site with the notes silently missing; locally, keep the cached copy
if (process.env.CI || process.env.VERCEL) throw error
logger.warn(`Could not load notes, keeping cached entries: ${(error as Error).message}`)
return
}
NOTES_DIR for previews. Setting it to a local checkout’s notes/ folder makes the loader read from disk instead of GitHub. That way I can see how a note will look before I push it.
I briefly had a “skip unchanged notebooks” cache keyed on the GitHub blob SHA, then took it out. Astro keeps its data store under node_modules/.astro, and Vercel caches node_modules between builds. That means a change to the parser would never re-render notes whose source hadn’t changed. Re-downloading a dozen 12 KB files is cheap, while stale pages are confusing, so the simpler version won.
Notebook to Markdown
src/lib/notebook.ts is a plain function with no Astro imports, which makes it easy to unit-test with bun test. It does four things:
- Reads the metadata table. Title, problem number (from
LeetCode #1), difficulty, topics (every backticked word in the Topics row) and date all come from that first cell. An unfilled template value likeEasy / Medium / Hardis ignored rather than trusted. - Drops the test harness. On the site, the
ipytestsetup cell and the whole Tests section are noise. They matter when I’m running the notebook, not when someone’s reading it. - Fixes the section numbers. Once Tests is gone,
## 3. Solutionfollowed by## 5. Scratchwould look like a mistake, so the numbers are stripped. - Picks a summary. Every Takeaways section has a
**Pattern:**bullet, the one-line lesson I most want to remember. That makes a much better card summary than the first line of the problem statement, which is only the fallback.
for (const cell of cells) {
if (cell === titleCell) continue
if (cell.cell_type === "markdown") {
// The Tests section is only harness code; skip until the next "## " section
if (/^##\s/.test(cell.src.trimStart())) inTests = isTestsHeading(cell.src)
if (inTests) continue
// "## 3. Solution" -> "## Solution": numbering has gaps once Tests is dropped
blocks.push(cell.src.replace(/^##\s+\d+\.\s+/gm, "## ").trim())
} else if (cell.cell_type === "code") {
if (inTests || isSetupCell(cell.src) || isTestCell(cell.src) || !cell.src.trim()) continue
blocks.push("```python\n" + cell.src.trimEnd() + "\n```")
const outputs = renderOutputs(cell.outputs)
if (outputs) blocks.push(outputs)
}
}
Code cells become fenced Python blocks, and any saved outputs are kept: stream text and plain-text results as text blocks, plots as inline PNGs. My notebooks are currently committed without outputs, so the timing results don’t show yet, but the plumbing is there.
Math
My notes are full of complexity notes like $O(n)$ and $O(n \log n)$. Rendering those properly took two plugins, remark-math and rehype-katex, plus the KaTeX stylesheet on note pages. Because the plugins sit in the global markdown config, they apply to blog posts too. I checked that no existing post had a stray $ outside code before turning it on, since a sentence like costs $5 and $10 written as plain text would otherwise render as math.
Keeping the styling consistent
I didn’t want Notes to look like a bolted-on section. The list page uses the same layout as Blog and Projects: a sticky sidebar filter with the square checkboxes (split into Difficulty and Topics groups), the same “SHOWING X OF Y” counter, the same card markup with the animated arrow, and the same empty state. The note page reuses the article header: the animated back link, the calendar meta row, the outlined link buttons from project pages, the table of contents, and the Prev/Next cards at the bottom, ordered by problem number.
I also kept it monochrome. My first pass had green, amber and red difficulty labels, which looked fine on their own but out of place next to the rest of the site.
Downloads, and why they’re served from the blog
I wanted readers to be able to take a note with them. The obvious approach, a download link pointing at the raw GitHub URL, doesn’t work, for two reasons:
raw.githubusercontent.comserves notebooks astext/plainwith noContent-Dispositionheader, so the browser just shows the JSON.- Browsers ignore the
downloadattribute on cross-origin links, so you can’t force it from the blog either.
The fix is to serve the files from the blog itself. The loader already has the raw notebook, so it stores it on the entry, and a static endpoint writes it out for every note at build time:
// src/pages/notes/[slug].ipynb.ts
export async function getStaticPaths() {
const notes = await getCollection("notes")
return notes.map((note) => ({ params: { slug: note.id }, props: note }))
}
export const GET: APIRoute = ({ props }) => {
const note = props as CollectionEntry<"notes">
return new Response(note.data.raw, {
headers: { "Content-Type": "application/x-ipynb+json; charset=utf-8" },
})
}
The .ipynb file is byte-for-byte the notebook on GitHub, tests included, so it runs as-is. A second endpoint, [slug].md.ts, serves the cleaned-up version as standalone Markdown. It adds a header with the title, source link, difficulty, topics and the pattern summary, plus a link back to the page. Since both files come from my own domain, <a download="0001_two_sum.ipynb"> saves them as files with the right names.
One small trap on the way: the blog’s icons live in a single /ui.svg sprite, and vercel.json caches every .svg as immutable for a year. Adding a download icon to the sprite would have meant returning visitors never see it, because their browser would never fetch the new sprite. The new icons are inlined in the page instead.
Open in Colab
Downloads are good for keeping a note; Colab is better for poking at one. Colab can open any notebook in a public GitHub repo by URL, so the button is just a link:
const colabUrl = `https://colab.research.google.com/github/tomstao/my-coding-notes/blob/main/notes/${file}`
That surfaced an issue in the notebooks themselves. The first code cell imports ipytest, which Colab doesn’t have installed. The obvious fix, %pip install ipytest, would have broken things locally instead: my notes run in a uv environment, and uv environments don’t include pip. So the setup cell now only installs when the import fails:
try:
import ipytest
except ImportError: # Colab and other fresh kernels don't ship ipytest
%pip install -q ipytest
import ipytest
import pytest
ipytest.autoconfig()
IPython happily transforms the indented %pip line inside the except block. That change went into the notes template and all ten existing notebooks.
What’s next
- Redeploy on push. Right now a new note appears the next time the blog builds. The plan is a small GitHub Action in the notes repo that calls a Vercel deploy hook whenever
notes/**changes, so publishing a note is just agit push. - Notes in search and tags. The search modal and
/tagsonly know about posts and projects. Notes have topics that would fit straight in.
Key takeaways
- Astro 5 content loaders can wrap any source. A function that fills the store, plus the
renderMarkdownhelper, is enough to turn a different repo’s notebooks into first-class collection entries. - A consistent template is what makes content portable. The parser is short because every notebook has the same metadata table and sections.
- Fail the production build when content can’t load. A failed deploy keeps the last good version live; a silently empty section doesn’t.
- Serve downloadable files from your own origin. The
downloadattribute is ignored cross-origin, and raw GitHub URLs serve notebooks as plain text. - Watch out for immutable caching on shared assets. Changing a sprite cached for a year means existing visitors may never see the change.