- Fresh structure: Next.js App Router + Tailwind v4 at the repo root, exported
as static files (output: export) and served by static-web-server
- Remove the Strapi CMS, Astro app, Pagefind search, comments, old scripts,
Lighthouse config and compose files; content in content/ is now plain
markdown with YAML frontmatter
- Pages: About, Writing, Reading (3D bookshelf) and Portfolio, styled after
adammaj.com
- SEO: metadata and canonicals, JSON-LD, generated OG images, sitemap, robots,
RSS and JSON feeds, llms.txt
- Umami via PUBLIC_UMAMI_SCRIPT_URL / PUBLIC_UMAMI_WEBSITE_ID (build args)
- Dockerfile builds the export and serves it with sws.toml (security headers,
clean URLs, .well-known, OG image content type, immutable asset caching)
- Prettier and cspell for content with a husky/lint-staged pre-commit hook
- Old redirect list dropped to start from a clean slate
Nav bar & layout consistency
- Base.astro's `wide` layout now defaults on for every page (was opt-in),
fixing the nav bar wrapping differently depending on page width
- Icon buttons for search and theme toggle (magnifying glass, sun/moon),
replacing the plain "Search" text link and the "◐" glyph
- Search is now a Cmd/Ctrl+K modal over Pagefind (lazy-loaded on first
open) instead of a separate /search/ page, matching the old site
- Fixed the article page's TOC sitting with unwanted left padding relative
to the header (grid was center-justified instead of start-justified)
Smooth navigation
- Added Astro's ClientRouter for animated page transitions
- Every page-local script that only ran once at module load (three.js
hero, minimap, comments, book-spine carousel, scroll-to-top, reading
progress, theme toggle, Pagefind init) now re-initializes via
astro:page-load, with AbortController-based cleanup so window/document
listeners don't accumulate across client-side navigations
Restored from the legacy Zola site
- Reading progress bar (article pages) and scroll-to-top button
- Share buttons (X, Bluesky, LinkedIn, Reddit, HN, email) on writings
- Prev/next post navigation with the old label/title layout
- Utterances comments, wired to andwati/andwati.com with dark/light
theme sync
- YouTube embeds: any plain <iframe src="...youtube-nocookie.com...">
is now auto-wrapped in a responsive 16:9 container, gets
loading="lazy", and emits VideoObject JSON-LD — no shortcode needed
- LaTeX: $inline$ and $$block$$ math renders to static HTML via KaTeX at
build time (no client-side JS shipped for it)
- Tags page redesigned as a centered, alphabetical tag cloud
Removed the related-posts section per request.
Accessibility & contrast
- --accent (#d1502f) measured 4.0:1 on the light background, just under
WCAG AA's 4.5:1 for normal text — darkened to #a84025 (5.7:1), plus
matching light-mode-only fixes for the two hljs syntax colors and the
tip/warning callout colors that had the same problem in light mode
- Fixed heading-order violations (h1 straight to h3 with nothing at h2)
across the homepage, writings/portfolio/bookshelf/blogs lists, and tag
pages — introduced a shared .entry-title class for the h2 list titles
- Fixed inconsistent title-link underline colors: a plain <a> carries its
own default underline (in its own text color) instead of matching its
parent heading's accent-colored one — h1/h2/h3 anchors now explicitly
inherit the heading's decoration
- Bookshelf spine colors are generated per-title from a hash; the
lightness-based text-color heuristic could still produce sub-4.5:1
contrast for some hues (e.g. green) — now computes real WCAG contrast
and adjusts
Performance
- The three.js homepage hero was the single largest main-thread cost
site-wide (dragging that page's Lighthouse performance score to the low
80s while every other page scored 95+): three.js is now dynamically
imported only once the browser is idle (WebGL-support check split into
its own tiny module so unsupported browsers never fetch it at all), the
render loop is capped to ~30fps, and hover raycasting only runs on an
actual pointer move instead of every frame
SEO & structured data
- Self-referential canonical links site-wide (with override support for
posts first published elsewhere)
- Sitewide WebSite/Person JSON-LD, plus per-article BlogPosting,
BreadcrumbList, and (when applicable) VideoObject JSON-LD
- robots.txt explicitly welcomes AI/LLM crawlers (GPTBot, ClaudeBot,
PerplexityBot, Google-Extended, CCBot, etc.) and points at llms.txt
- Added an Atom feed (RSS and JSON Feed already existed)
- Sitemap now sets <lastmod> per entry from its own date/updated
frontmatter instead of leaving every URL at build time
Analytics & comments infra
- Self-hosted Umami wiring via PUBLIC_UMAMI_SCRIPT_URL/WEBSITE_ID
(apps/site/.env.example), no-ops until both are set
- sws.toml's CSP updated to allow utteranc.es and analytics.andwati.com —
without this both were silently blocked in production
Lighthouse CI
- @lhci/cli wired up against the built site (mobile + desktop configs),
`pnpm run lighthouse`, and a GitHub Actions workflow to run it on every
push/PR — reporting only for now, no failing thresholds
The Astro app in apps/site is now the production build (root Dockerfile
already builds it, not `zola build`), so the old Zola templates, Sass,
static assets, and build tooling are dead weight:
- templates/, sass/, static/, themes/, zola.toml, Makefile, bin/update-zola
- content/posts/ (fully migrated to content/writings/) and the legacy
content/archive, content/about.md, content/_index.md pages
- legacy OG-image/llms.txt generation scripts (superseded by the
*.astro-site.mjs versions apps/site's build already uses)
- unrelated stray files (manimations/, build.sh)
Also drops apps/site/src/pages/search.astro, replaced by the search modal,
and swaps in the real favicon.ico.
"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/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.
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 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.
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.
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.
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).