Replaces the legacy Zola script, which was hard-coded to the retired
"CASE FILE" branding, with scripts/generate-og-images.astro-site.mjs
built on the new design system's dark palette + Fraunces/JetBrains Mono.
Base.astro now renders og:image/twitter:image from a per-page ogImage
prop, wired into the writings and bookshelf detail pages.
Uses static-weight Fraunces/Newsreader font packages rather than the
variable fonts the site ships elsewhere, since satori's opentype.js
parser throws on their fvar table.
The enrichment guard was `if (entry.cover_image) return entry;` —
meaning once *any* cover_image was set, including a raw external URL
you pasted into Strapi by hand, it was treated as "already done" and
never downloaded. The site's coverExists() check only looks for local
/covers/... files, so a remote URL silently never rendered.
- content-sync.ts: enrichBookshelfEntry now branches on whether
cover_image is an http(s) URL (cache it via the new
cacheRemoteImage) vs. unset (existing ISBN/DOI lookup) vs. already
a local path (left alone).
- bookshelf-covers.ts: cacheRemoteImage() downloads and caches any
image URL. Also fixes a real bug caught while testing this against
the actual O'Reilly cover URL from the reported entry: their CDN
labels WebP responses as Content-Type: image/jpeg, so trusting the
header would've cached it with the wrong extension. Now sniffs the
real file signature (RIFF/WEBP, PNG, JPEG, GIF magic bytes) instead
of trusting either the header or the URL.
Verified the download+sniff logic directly against the real cover URL
from the reported entry (Designing Data-Intensive Applications) —
correctly detects and saves as .webp. Didn't touch the running dev
Strapi/DB while testing.
toml-content-loader.ts only read content/ once in load(), so a new
entry created through Strapi (or any hand-edit) sat on disk correctly
but never appeared on the site until astro dev was restarted. Astro's
content layer only auto-refreshes for loaders that wire up the
watcher it hands them.
Refactored the read into a loadAll() function called both on initial
load and on every add/change/unlink event from Astro's dev-mode
watcher, scoped to the content directory. Verified live: created a
file mid-session, it appeared within ~2s with no restart; deleting it
disappeared just as fast.
Production builds were never affected (astro build only calls load()
once anyway, and always sees the current state of content/).
pnpm dev was failing with "App keys are required" for anyone whose
apps/cms/.env didn't already exist (it's gitignored, so this hits
every fresh clone, not just a one-off). predev/predevelop now copy
.env.example to .env automatically when it's absent, so `pnpm dev`
and `pnpm --filter cms develop` both just work without a manual setup
step.
Interaction/layout pattern is my own implementation, inspired by
observing (not copying source from) adammaj.com/reading: a horizontal
scrollable shelf of book "spines" above a list, click-through to a
per-book detail page where the shelf re-renders with that book's
spine expanded into a cover.
- src/lib/spine-color.ts: deterministic per-title color + width hash,
so every book gets a distinct-looking spine without needing a
manually-set color field.
- src/lib/cover-exists.ts: checks the referenced cover_image file
actually exists under public/ before rendering an <img> — the ISBN/
DOI cover lookup only runs on live Strapi create/update, so
hand-seeded content (like our two placeholder books) has a
cover_image path with no cached file yet. Falls back to the
colored spine block instead of a broken image.
- src/components/BookSpines.astro: the shelf itself, reused on both
the index and detail pages; a scroll button, hover-lift, and the
active book's spine widens into its cover/fallback.
- Bookshelf index now renders full reviews inline (was previously
just a bare metadata list) with cover thumbnails.
- New src/pages/bookshelf/[...slug].astro: first individual page
route for bookshelf entries (portfolio still only has a list page).
Verified in-browser: shelf renders, hover/click navigation works,
active-book expansion on the detail page, and the cover-exists
fallback correctly avoids broken <img> tags for our two seed books.
Data model: nodes are real content entries (writings, portfolio,
bookshelf), edges are shared-tag connections — the same relationship
"related posts" already uses, drawn instead of listed. No decorative
or fake data.
- src/lib/constellation-data.ts: builds the node/edge graph from the
actual collections at build time.
- src/lib/constellation-scene.ts: vanilla Three.js scene (no
framework) — fibonacci-sphere layout, auto-rotation, pointer
raycasting with a title tooltip, click-through to the entry's page.
Honors prefers-reduced-motion (skips rotation and the spring-eased
entrance, which reuses src/lib/spring.ts) and checks WebGL support,
falling back to a plain count for browsers/settings that lack it.
- src/components/Constellation.astro: wires it up, serializing the
graph as inline JSON for the client script to consume.
- Homepage rebuilt around it: hero constellation above a 3-column
dashboard (latest writings/portfolio/bookshelf), using a new `wide`
layout variant on Base.astro (dashboard needs more than the 42rem
article width).
Also: removes "life and beliefs" from all site copy (site.config.ts,
the llms.txt generator + its output) per request — description is
now "Computer science and mathematics."
Verified in-browser: real WebGL scene renders with correctly
positioned/colored nodes and edges, hover shows the right title via
raycasting, layout holds at the new wide container width.
Root "dev" script uses concurrently to run both apps with labeled,
color-coded output ([site]/[cms]) instead of needing two terminals.
The per-app dev:site/dev:cms scripts stay for when you only want one.
References: pixperk.tech (dark, warm off-white text, serif-italic +
mono mixed typography, personal dashboard modules) and mcyoung.xyz
(meta line with reading time, subtle accent underline on titles).
- src/styles/tokens.css: dark-first color palette with a warm-paper
light mode (data-theme override + prefers-color-scheme, toggle wins
either direction), fluid clamp() type scale, spacing/radius scale.
- Fonts: Fraunces (display/headings, italic), Newsreader (body,
long-form readability), JetBrains Mono (nav/meta/code) — all
self-hosted via @fontsource, continuing the mono choice already
used in the legacy OG-image pipeline.
- Base.astro rewritten: real header/nav with active-link state, a
theme toggle (persisted to localStorage, no-flash inline script),
footer with social links (ported from zola.toml), replacing the
scaffold placeholder.
- Reading time: toml-content-loader.ts computes wordCount/
readingMinutes from the raw body at load time, surfaced in the
writings list and detail meta line.
- Bug fix: the marked heading renderer was using raw token.text for
both the visible heading and the TOC, so a heading like
"**Registers**" rendered literal asterisks instead of bold — now
renders inline tokens properly and strips markdown for the
plain-text TOC label/slug.
Verified in-browser (light + dark) across home, a migrated writing
with TOC/headings, bookshelf, and 404.
"subresource integrity", "task scheduler", and "conventional commits"
had spaces, producing broken-looking /tags/<tag>/ URLs. Also fixes a
silent bug: site.config.ts's tagDescriptions already keyed
"subresource-integrity" with a hyphen, so the per-tag description
never actually matched the un-normalized tag.
Added normalizeTag() to migrate-posts-to-writings.mjs so re-running
the migration script doesn't reintroduce the spaces from the
untouched content/posts/ originals.
scripts/generate-llms.astro-site.mjs covers writings, portfolio,
bookshelf, and blogs (unblocked now that site.config.ts exists from
the feeds/meta work), wired into apps/site's build script.
Also fixes .gitignore: the bare `public` pattern (meant to ignore the
Zola build's root-level output directory) was matching at any depth,
so apps/site/public/ and apps/cms/public/ were being silently
excluded from every commit despite `git add` reporting them as
staged for directory adds (the ignore-file warning only surfaces for
an explicitly named path, not a recursive directory add — easy to
miss). Scoped to `/public` so only the root output dir is ignored.
Picks up robots.txt, the Astro scaffold's favicon, llms.txt, and
Strapi's own public/robots.txt, none of which were actually tracked
until now.
404: "Page Fault" — a SIGSEGV/memory-fault framing distinct from the
retired terminal easter egg, still fitting the CS-flavored identity.
Plain/unstyled for now like the rest of the placeholder layout;
restyle once the design system lands.
src/lib/spring.ts: a small CSS+Web Animations API spring utility
(damped harmonic oscillator -> keyframe progress values), per the
locked "no animation library" decision. Not wired into any component
yet — this is the foundational primitive the design system's motion
work will build on, kept free of any visual/component opinions.
scripts/migrate-posts-to-writings.mjs converts all 20 posts under
content/posts/ into the new writings schema — normalizes dates to
bare TOML local-dates, converts the one unambiguous Zola shortcode in
use (youtube embeds) to a plain <iframe>, and strips the now-meaningless
{% raw %}/{% endraw %} escape tags. content/posts/ itself is untouched;
this only adds content/writings/*.
Also generates 301 redirects from the old /posts/<slug>/ URLs to the
new /writings/<slug>/ ones into sws.toml, inside marker comments so
re-running the script regenerates that block idempotently instead of
duplicating it.
Two posts (from-c-to-machine-code, self-hosting-gitea-and-mirroring-github)
use note/tip/warning/danger callout shortcodes that have no Astro
equivalent yet — flagged by the script rather than guessed at, since
that needs a real component once the design system exists.
Verified: astro check clean, full site builds (80 pages, all real
migrated posts render), redirect generation confirmed idempotent
across repeated runs.
- src/site.config.ts: minimal site metadata (title, description,
base URL, nav, tag descriptions) ported from the legacy zola.toml
as a placeholder pending real design/copy.
- RSS (@astrojs/rss) and JSON Feed endpoints, sitemap
(@astrojs/sitemap), robots.txt.
- Base.astro: meta description (site-wide default or per-page
override via llms_description/description), RSS/JSON feed
<link rel=alternate>, and a webmention endpoint link (plumbing
only — needs the domain registered on webmention.io to do
anything).
- toml-content-loader.ts: marked renderer now assigns heading ids
and collects them into rendered.metadata.headings, so
render(entry) exposes headings the same way a built-in Markdown
loader would.
- Writing detail page: table of contents, prev/next navigation,
series-nav (uses the existing extra.series/series_index fields),
and tag-overlap related posts.
- Tags index + per-tag pages, tag links wired in from writing cards.
- Pagefind: indexes the build output as part of `pnpm build`
(astro build && pagefind --site dist), plus a /search/ page using
Pagefind's default UI.
Verified: astro check clean, full build succeeds (feeds/sitemap/
pagefind all generate correctly), and manually exercised /search/
(real query, highlighted results) and /tags/ in a browser against
the built site.
apps/cms/src/utils/bookshelf-covers.ts fetches book covers from Open
Library by ISBN (real image, cached into apps/site/public/covers/)
and backfills missing title/authors for papers from Semantic Scholar
by DOI/arXiv id. Wired into the sync middleware in content-sync.ts:
runs once per entry (cover_image already set means "done"), and only
on live create/update through Strapi, not during the boot-time
import (so a restart doesn't hammer external APIs).
Also renames the bookshelf `status` field to `read_status`
everywhere (schema, content/README.md, seed content, Astro zod
schema, bookshelf page, sync mapping). Strapi's content-manager API
treats a top-level `status` key as its own reserved draft/publish
parameter even with draftAndPublish disabled, so an attribute
literally named `status` silently rejected every write — caught this
while testing the enrichment hook.
Verified end-to-end: created a book by ISBN with no cover_image,
confirmed a real cover image was downloaded and cached, cover_image
was set on the entry and written to the file, and the cover/file were
both removed on delete.
Adds slug (uid) fields to all four content types so Strapi documents
can be matched to files deterministically, plus:
- apps/cms/src/utils/content-file.ts: read/write/delete the +++ TOML
frontmatter files, using smol-toml's TomlDate so dates round-trip as
bare local dates (matching the legacy Zola format), not quoted
datetime strings.
- apps/cms/src/utils/content-sync.ts: per-type frontmatter <-> Strapi
field mappings, a boot-time importer that upserts every content/
file into Strapi by slug, and a Document Service middleware that
writes any create/update/delete made through Strapi straight back
to the same files.
Strapi's SQLite DB is now genuinely a disposable cache: content/
stays buildable and readable with Strapi entirely absent, and
restarting Strapi rebuilds its DB from content/ with no duplicates
(matched by slug).
Verified end-to-end: fresh boot imports all seed files once; a second
boot re-imports idempotently (0 created, all matched by slug); create/
update/delete through the content-manager API all correctly write to
and remove the corresponding file on disk.
apps/cms/Dockerfile.dev + root docker-compose.yml give a one-command
way to run the CMS in a container for testing (docker compose up
cms), with the shared content/ directory mounted so it matches the
host dev setup. apps/cms/Dockerfile is a production multi-stage build
for later Dokploy deployment. Uses node:24-bookworm-slim to match the
local Node version and get glibc prebuilt binaries for better-sqlite3
without needing a native build toolchain in the final image.
Verified: both images build, the dev container boots Strapi
successfully, admin panel and API routes are reachable, and the
mounted content/ volume resolves at the same relative path the future
sync provider will expect.
Adds writing, portfolio-entry, bookshelf-entry, and curated-blog
collection types with attributes mirroring the TOML frontmatter
fields in content/README.md exactly, so the future sync provider has
a direct field mapping. draftAndPublish is disabled on all four since
our own `draft` boolean (synced to the file) is the source of publish
state, not Strapi's built-in workflow. Verified Strapi boots and
registers all four REST routes (api/writings, api/portfolio-entries,
api/bookshelf-entries, api/curated-blogs).
Adds a custom Astro loader that reads the Zola-style TOML frontmatter
(+++ ... +++) directly, so content/ stays the single source of truth
with no YAML conversion step. Defines writings/portfolio/bookshelf/
blogs collections and basic list/detail pages for each, rendering the
seed entries end-to-end (verified in-browser via `pnpm --filter site
dev`). This is the first runnable MVP: home, writings list + post
detail, portfolio, bookshelf (books and papers), and curated blogs
all render from real content/ files.
Fixes the formatting deltas from the initial Astro/Strapi scaffold
and brings the legacy generate-og-images/generate-llms/validate-post
scripts in line with Biome's style. No behavioral changes.
Adds content/README.md documenting the TOML frontmatter contract each
content type must follow, plus one seed entry per type to lock the
schema in a runnable form. Bookshelf entries use a kind field ("book"
or "paper") so academic papers (DOI/arXiv id) are first-class
alongside ISBN-driven books, not bolted on later.
Set up a pnpm workspace with apps/site (Astro) and apps/cms (Strapi,
SQLite-backed) as the foundation for the new digital-legacy site.
Switches package management to pnpm and adds Biome for linting/
formatting, replacing npm/no-linter setup. Existing Zola site stays
in place for now and will be migrated/removed progressively.
Production (Dokploy/Docker) was sending no security headers at all -
static/_headers and wrangler.toml were Cloudflare Pages/Workers
config that static-web-server never reads. Added sws.toml with the
same CSP/security headers and the two redirects from _redirects,
wired into the Dockerfile. Removed wrangler.toml, static/_headers,
static/_redirects, the wrangler devDependency/deploy script, and
related .gitignore/.dockerignore entries since Cloudflare deploy is
no longer used.
Also fixed a console error in theme.js where the Utterances
postMessage call could fire before the comments iframe navigated to
utteranc.es.
Renders a 1200x630 social card per post (satori + resvg-js, using the
site's own self-hosted fonts) instead of reusing one generic og-image.png
for every post. Wired into npm run build via a new generate:og step;
templates/page.html falls back to the per-slug image unless extra.image
is set, and non-post pages keep the generic fallback.
Adds a multi-stage Dockerfile (Node for llms.txt generation, the
official Zola image to build, static-web-server to serve) for
deploying via Dokploy/Docker instead of Cloudflare Pages. This is an
additional path, not a replacement - wrangler.toml and the Cloudflare
build.sh remain in place as an option.
Also bumps build.sh's pinned ZOLA_VERSION from 0.22.1 to 0.23.1: the
prior commit migrated every template to Tera v2 syntax (components,
spread operator, native slicing), which 0.22.1 can't parse - confirmed
by a live Cloudflare build failure on this branch. Keeping build.sh
and the Dockerfile on the same Zola version avoids maintaining two
template dialects across the two deploy paths.
Verified: `docker build` succeeds, the container serves the homepage,
a post, the youtube embed, and a proper 404; `build.sh` also runs
clean standalone (simulating the Cloudflare build) and produces a
fully populated public/.
bin/update-zola always installs the latest release, which as of
v0.23.0 rewrote the template engine to Tera v2. That broke the whole
site: removed filters (slice, concat, escape), test calls now require
named args, shortcodes are gone in favor of Tera components, and
content itself is now Tera-templated before markdown runs.
- slice(end=n) -> native [:n] slicing; concat(with=x) -> spread [...arr, x]
- escape -> escape_html; starting_with/containing -> named `pat` arg
- get_taxonomy_url `name` -> `term` (old name is deprecated)
- %+ date format no longer recognized; replaced with explicit RFC3339 pattern
- shortcodes (note/tip/warning/danger/youtube) rewritten as components;
content call sites updated to component invocation syntax
- {% include %} no longer inherits for-loop-local variables (only ambient
page/section context) - post-card.html converted to a component that
takes page/index explicitly instead of relying on implicit `page`/`loop`
- two content code blocks with literal Jinja-style {{ }} syntax (Netlify
CMS config, a Flask template snippet) wrapped in {% raw %} so Tera
doesn't try to render them, since content is now templated too
- fixed a real content corruption bug along the way: the youtube
shortcode's multi-line <iframe> tag had a conditional that could
collapse to a whitespace-only line, which prematurely closed the
HTML block under CommonMark rules and got the rest of the tag
syntax-highlighted as code instead of rendered
Verified with a clean `zola build` and `zola serve` on v0.23.1, plus a
spot-check of the rendered HTML for both callouts and the video embed.
`zola check` only reports pre-existing external link rot, unrelated to
this change.
Cloudflare Workers static assets rejects the www->apex redirect rule
with "Only relative URLs are allowed" (Pages allows absolute source
URLs for cross-host redirects, but this project deploys via wrangler's
static assets, not Pages, which doesn't). The www/apex canonicalization
needs to be handled via Cloudflare's dashboard Redirect Rules instead.
15 screenshots in the ScriptCTF writeup had empty alt text. Also removed
two <img> references in the semver post pointing at files that never
existed in the repo (leftover from the old Netlify CMS media setup).
[[extra.menu]] silently swallowed ~20 later extra.* keys (posts_page_title,
search_placeholder, show_rss_link, notfound_*, etc.) into the last menu
entry/tag_descriptions table, since TOML absorbs bare keys into the last
open table until a new header appears. show_rss_link was the only visibly
broken one (RSS link missing from homepage); the rest were masked by
matching template defaults.
Also replaced Tera's default() filter with explicit if/else where values
feed into JSON-LD, since default()'s fallback gets escaped in a way `safe`
can't undo. Added author/dateModified to BlogPosting list entries.
- new paper/ink palette with single red accent, replacing catppuccin
- self-hosted IBM Plex Serif (display) + Source Serif 4 (body), drop Outfit
- rebuilt homepage masthead, numbered post index, navbar with active states
- switch syntax highlighting + utterances to gruvbox, synced via postMessage
- remove terminal/404 easter egg chrome, clean up remaining inline styles
- fix stray syntax.css link causing a 404