← All projects
Live & explorable

Bookshelf

A public, browsable bookshelf that layers a hand-built semantic index — themes, tags, and book-to-book connections — over a 300+ book reading history.

Livereal & public
Soloone knowledge graph
$0/moinfrastructure
Data flow

How a decade of reading becomes a live, connected shelf — Notion → Worker → GitHub → Pages.

The interactive data-flow diagram is built for a larger screen.
Open the full diagram in its own tab ↗

Why it exists

A reading history is a list until something connects it. The purpose was to turn 300+ books into a browsable knowledge graph — themes, a controlled tag vocabulary, and book-to-book connections — so the shelf can be explored by idea rather than scrolled by date, and so the curation work lives somewhere durable instead of in memory.

What it is

Every finished book is enriched once in Notion — a summary, three core ideas, two themes from a locked vocabulary, tags, and a handful of explicit links to books already on the shelf. A checkbox is the only visibility gate. A Cloudflare Worker reads the database on a nightly cron, rebuilds the data file, regenerates the connection graph from scratch, validates the result against ten abort conditions, and commits to GitHub only when something actually changed. Cloudflare Pages then runs a dependency-light static generator over that data and publishes roughly 370 routes: a paginated library with client-side sort and filter, theme pages, a timeline, an analytics page, a connections page with a clickable theme matrix, a force graph and a path-finder between any two books, and one page per read book. Roughly 350 social cards are rendered in the same build from the same data. Goodreads is the source of truth for what was read and when, and nothing else; Notion is the source of truth for everything the site renders. The published data file is a filtered artifact rather than the real thing — unread books lose their summaries, ideas and connections on the way out, because that file is a public URL. Nothing flows backward: the site never writes to Notion, and Notion never writes to Goodreads.

The hard part

A bug that didn't fail. The first Worker read every Notion property through one generic text accessor. That works for every property in the schema except one: a title-type property returns its content under a different key, so the accessor returned an empty string for it and correct values for everything else. The result was a published data file where several hundred titles were blank and all thirteen other fields looked perfect. The site rendered, the sync reported success, the commit went through every night, and it ran that way for weeks. Nothing in the system was built to notice that one field had gone to zero across every row — a diff looks enormous, but so does a diff after a big enrichment pass.

The fix was not the one-line accessor change. It was accepting that a pipeline which overwrites its own output nightly needs to be able to refuse. The property reader now dispatches on the declared type of each property, and a validation gate runs before the commit and aborts with a 422 on ten conditions, several of which describe shapes of catastrophe rather than bugs: a library that suddenly has fewer than 300 public books, any book with an empty title, duplicate IDs, duplicate slugs. It also catches the failure mode I would never have guessed — Notion returns at most 25 related items per query, so a book with 26 connections loses the overflow invisibly and the graph quietly gets thinner. The Worker now detects truncation and refuses rather than publishing a graph that is wrong in a way nobody could see.

The same lesson, a second time, in the copy. In September a QA check went red on the live site: the home page was reproducing an entire book summary, which is exactly what one of my own content rules prohibits. The lead-in logic took the first two sentences, capped at 340 characters. One book's summary happened to be 319 characters of exactly two sentences, so the function returned it unchanged, and it went live the day that book synced in as the newest read. A rule expressed as a maximum can be satisfied by doing nothing. It is now expressed as a requirement — try two sentences, then one, then a hard truncation, and take the first result strictly shorter than the input — so the class of bug is closed by construction instead of by a check happening to fire.

Rendering 350 social cards without a browser. Per-book share images were deferred for months on the assumption that they needed edge rendering. They do not: satori turns a subset of HTML and CSS into SVG, resvg rasterizes it, and sharp handles the covers, all inside the normal build. Getting there took three real dead ends. Line clamping is silently ignored unless the element is display: block, which is exactly what everything else on a flex-laid-out card is not, so the first prototype shipped a five-line subtitle with nothing in the API complaining. Measuring whether text actually overflows required flattening the raster first, because transparent pixels outside the root element read as ink once greyscaled, so every measurement came back as the full canvas height. And the fonts had to be converted from the site's own web fonts to static TTFs rather than downloaded fresh, so the cards cannot drift to a different cut of the typeface than the pages use.

Worth highlighting

Self-hosted book covers

The covers are their own small pipeline. The Worker republishes each cover out of Notion into GitHub, named by Goodreads ID and sent in small batches because Notion's signed file URLs expire — so the images are hosted alongside the site, with no third-party image service and no hotlinking.

Private notes, in Notion

Because the library lives in Notion, every book is also a page I can write on. Notes, highlights, why a book mattered — all of it lives in the Notion page body and never syncs to the public site. The public shelf is the curated face; Notion is the private workshop behind it. One source, two audiences, with a hard line between them.

vibe-coded with ClaudeBuilt solo with Claude — including the refusal logic, which exists because the worst bug never failed. It just published blank titles, quietly, every night for weeks.

More projects

← Back to all projects