Author SHA1 Message Date
andwati 12dd32ea4f Polish nav, transitions, layout, SEO, a11y, LaTeX, and Lighthouse CI
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
2026-09-12 20:40:16 +03:00
andwati ffc3b6f353 chore: remove legacy Zola site and superseded files
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.
2026-09-12 20:38:20 +03:00
andwati d958a298f7 feat(cms): implement Git publishing for content changes and add entrypoint script
- Added a Docker entrypoint script to manage content repository cloning and updates.
- Updated server configuration to include public URL and proxy settings.
- Enhanced content file utilities to return file paths for written entries.
- Introduced GitPublisher class for handling Git operations and deployments.
- Added tests for Git publishing functionality.
- Updated Dockerfile and docker-compose for production setup.
- Added new public PGP key and security.txt for contact information.
- Created new About and Archive pages in the site.
- Updated site configuration to include new navigation links.
2026-09-01 16:50:26 +03:00
andwati ce65be8852 Move the TOC sidebar to the left of the article 2026-09-01 09:30:22 +03:00
andwati 0644dcf253 Add sticky sidebar TOC and widen the page for large screens
Writings detail pages now show the table of contents as a sticky
sidebar once there's room (>=74rem viewport), sharing its markup with
the existing inline TOC via a new TocList.astro so neither copy drifts.
Below that breakpoint it falls back to the original inline list.

Bumped .container--wide 64rem -> 76rem (home, bookshelf, and now
writings detail) so big monitors use more of the screen. The prose
column itself stays capped at 42rem for readability; the extra room is
what makes space for the TOC gutter. Verified at 390/900/1180/1920px.
2026-09-01 09:27:10 +03:00
andwati 27e53993d6 Swap shiki for highlight.js's core build for syntax highlighting
Shiki pulls in a WASM oniguruma engine and bundled theme/grammar JSON
for two full themes; highlight.js's core build plus only the 11
languages content actually uses is much thinner for the same job here.
Token colors now come straight from the design tokens (--accent,
--text-muted, ...) instead of shiki's dual-theme CSS vars, so they
already switch with the site's theme with no extra [data-theme] rules.
2026-09-01 09:20:12 +03:00
andwati 74a7f80b29 Render legacy callout shortcodes, add code syntax highlighting
Converts the note/tip/warning/danger `{% <tag> %}...{% </tag> %}` blocks
the posts→writings migration script left untranslated into styled
.callout markup (toml-content-loader.ts's renderCallouts(), styled in
global.css to match the new design system).

Also wires shiki into the same markdown pipeline for fenced code block
highlighting (github-light/github-dark, switched by the same
[data-theme]/prefers-color-scheme pattern tokens.css already uses).

Also records the Three.js centerpiece's perf budget check in TODO.md:
scene is trivially light (well under 10k triangles, 100+fps uncapped);
the ~130KB gzip payload is three.js core itself, not a tree-shaking gap
(verified named imports change nothing - sideEffects:false already lets
Rollup shake fully), and the script is a deferred module that doesn't
block first paint. No code changes needed there.
2026-09-01 09:14:29 +03:00
andwati c035aca132 Add OG image auto-generation for writings, portfolio, and bookshelf
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.
2026-09-01 08:59:42 +03:00
andwati 9ee9795645 Normalize distributed systems tag to kebab-case 2026-09-01 08:54:15 +03:00
andwati b13c827100 Add Designing Data-Intensive Applications to the bookshelf
Created via Strapi; cover_image now points at the locally-cached
cover downloaded through the fixed cover-caching path.
2026-08-31 08:54:59 +03:00
andwati 22302d8db9 Fix: a manually-set cover_image URL was never cached locally
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.
2026-08-31 08:50:16 +03:00
andwati 8da5d372d5 Fix: content changes weren't showing up without a dev server restart
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/).
2026-08-31 08:43:08 +03:00
andwati 73afcdcbff Auto-create apps/cms/.env from .env.example if missing
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.
2026-08-31 08:27:59 +03:00
andwati 5bd179631d Compute the footer copyright range from the earliest post's year
"© 2021-2026 andwati" — start year is the minimum date across all
published writings (src/lib/copyright-year.ts), not a hand-maintained
config value like the old zola.toml's copyright_start_year. Updates
itself automatically both as the year changes and if an older post is
ever added.
2026-08-31 08:25:28 +03:00
andwati cd3a9eac70 Redesign bookshelf as a spine shelf + detail pages
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.
2026-08-31 08:23:48 +03:00
andwati f5cc3a2144 Add the Three.js constellation centerpiece and redesign the homepage
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.
2026-08-31 08:12:35 +03:00
andwati 9cd92d623d Add pnpm dev to run the Astro site and Strapi CMS together
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.
2026-08-31 08:06:54 +03:00
andwati bf29139cef Check off design system items in TODO 2026-08-31 08:04:11 +03:00
andwati 9392d45f53 Build the design system: typography, color tokens, real Base layout
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.
2026-08-31 08:03:50 +03:00
andwati 3d4b2fcd64 Normalize multi-word tags to kebab-case
"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.
2026-08-31 07:47:54 +03:00
andwati 454e3459a1 Annotate remaining TODO items that need input vs. infra access 2026-08-30 19:19:27 +03:00
andwati 2fdd0052b5 Extend llms.txt to all content types; fix a gitignore bug
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.
2026-08-30 19:18:43 +03:00
andwati 529a0efbfe Add a 404 page concept and a spring-physics motion primitive
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.
2026-08-30 19:12:44 +03:00
andwati 1b7c4039e3 Migrate legacy Zola posts into content/writings/ (additive)
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.
2026-08-30 19:10:53 +03:00
andwati 4fa9715ebf Check off carried-over content features and Pagefind in TODO 2026-08-30 19:04:53 +03:00
andwati 02a7a6197f Add feeds, sitemap, search, tags, and per-post nav/TOC/series/related
- 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.
2026-08-30 19:04:25 +03:00
andwati 7b857d4be7 Note why OG-image and llms.txt extension are blocked, not skipped 2026-08-30 18:53:24 +03:00
andwati 62db772dad Add bookshelf ISBN/paper enrichment, fix status field name collision
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.
2026-08-30 18:52:39 +03:00
andwati 85efb824ae Wire Strapi to content/ via a two-way file sync provider
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.
2026-08-30 18:45:44 +03:00
andwati 27efeb8b26 Add Docker setup for running Strapi locally
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.
2026-08-30 18:36:31 +03:00
andwati 9d6d78b31f Define Strapi content types matching the content/ schema
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).
2026-08-30 18:23:42 +03:00
andwati f24701b1e7 Add TODO.md tracking remaining Astro/Strapi rebuild work 2026-08-30 18:16:55 +03:00
andwati 16c8dff5d5 Wire Astro content collections to the shared content/ directory
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.
2026-08-30 18:14:34 +03:00
andwati cae62d4a66 Apply Biome formatting across the workspace
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.
2026-08-30 17:59:30 +03:00
andwati 8be678d488 Define shared content schema for writings, portfolio, bookshelf, blogs
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.
2026-08-30 17:58:10 +03:00
andwati 80e1d55ea1 Scaffold Astro/Strapi monorepo for the site rebuild
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.
2026-08-30 17:56:36 +03:00
andwatiandGitHub 7a2853a44b Merge pull request #3 from andwati/migrate-security-headers-to-sws
Serve security headers via static-web-server, drop Cloudflare deploy path
2026-08-19 13:20:58 +03:00
292 changed files with 30400 additions and 211540 deletions
+7 -2
View File
@@ -1,7 +1,12 @@
public
node_modules
/public
**/node_modules
.git
manimations/*.*
/zola
/zola-v*.tar.gz
/artifacts
**/dist
**/.astro
**/.strapi
**/.tmp
**/build
+25
View File
@@ -0,0 +1,25 @@
name: Lighthouse
on:
push:
branches: [main, astro-migration]
pull_request:
workflow_dispatch:
jobs:
lighthouse:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: pnpm/action-setup@v4
- uses: actions/setup-node@v4
with:
node-version: 22
cache: pnpm
- run: pnpm install --frozen-lockfile --filter site
- name: Run Lighthouse CI
run: pnpm --filter site run lighthouse
+1 -1
View File
@@ -1,4 +1,4 @@
public
/public
node_modules
manimations/*.*
/zola
-3
View File
@@ -1,3 +0,0 @@
{
"snyk.advanced.autoSelectOrganization": true
}
+7 -1
View File
@@ -49,10 +49,16 @@ Deployment is self-hosted via Dokploy: `Dockerfile` builds the site with Zola an
All site-wide behavior/config (analytics, author/profile info, social links, nav menu, Utterances comment settings, search, related posts, the 404 terminal-themed easter egg) is centralized in `zola.toml` under `[extra]`. Check there before hardcoding values in templates.
## Astro/Strapi migration (in progress, `astro-migration` branch)
The site is being rebuilt as a pnpm workspace with two apps: `apps/site` (Astro) and `apps/cms` (Strapi, SQLite-backed). Package management is **pnpm only** (no npm/yarn) and linting/formatting is **Biome** (`pnpm lint`, `pnpm lint:fix`) — not ESLint/Prettier. Markdown files under `content/` remain the canonical source of truth; Strapi is an editing UI over them, not the source of record. The existing Zola site (`zola.toml`, `templates/`, `sass/`, `content/posts/` etc.) stays in place until migration is complete — remove Zola-specific files progressively as their Astro/Strapi equivalents land, not all at once.
## Agent skills
`.agents/skills/` contains pulled-in skills (`animation-vocabulary`, `emil-design-eng`, `review-animations`) tracked via `skills-lock.json`, useful for UI/animation review and design-engineering guidance when touching templates or Sass.
## Git commits
Never add `Co-Authored-By: Claude` (or any other Claude/Anthropic self-attribution) to commit messages. Write commit messages as if authored solely by the user.
Never add `Co-Authored-By: Claude` (or any other Claude/Anthropic self-attribution) to commit messages or pull request descriptions/titles. Write commit messages and PRs as if authored solely by the user.
Commit regularly: after each successful, working step (not just at the end of a task), so history stays granular and revertible.
+110
View File
@@ -0,0 +1,110 @@
# Dokploy deployment
The production deployment is one Dokploy Compose application with two
services:
- `site` builds the Astro app and serves its static output with
`static-web-server`.
- `cms` runs Strapi with persistent SQLite/admin data and a persistent Git
checkout.
Markdown remains the source of truth. A Strapi edit writes into the checkout,
commits and pushes the changed Markdown (plus a generated bookshelf cover when
applicable), then calls Dokploy's deploy webhook. The redeploy builds Astro
from that new commit.
## GitHub token
Create a fine-grained personal access token restricted to the
`andwati/andwati.com` repository with:
- Repository contents: read and write
- Metadata: read (automatically included)
The configured publishing branch must accept direct pushes from the token.
Store the token only in Dokploy as `GITHUB_TOKEN`.
## Dokploy application
1. Create a Compose application from this repository and select
`docker-compose.prod.yml`.
2. Configure its source branch to match `CONTENT_GIT_BRANCH` (normally
`main`).
3. Route the public site domain to the `site` service on port `80`.
4. Route the CMS domain (for example `cms.andwati.com`) to the `cms` service
on port `1337`.
5. Copy the application's deploy webhook URL into
`DOKPLOY_DEPLOY_WEBHOOK_URL`.
6. Add the environment variables below and deploy.
Required secrets:
```dotenv
CMS_PUBLIC_URL=https://cms.andwati.com
APP_KEYS=<four comma-separated random values>
API_TOKEN_SALT=<random value>
ADMIN_JWT_SECRET=<random value>
TRANSFER_TOKEN_SALT=<random value>
JWT_SECRET=<random value>
ENCRYPTION_KEY=<random value>
GITHUB_TOKEN=<fine-grained GitHub token>
DOKPLOY_DEPLOY_WEBHOOK_URL=<Dokploy Compose deploy webhook>
```
Optional publishing settings:
```dotenv
CONTENT_GIT_REPOSITORY=andwati/andwati.com
CONTENT_GIT_BRANCH=main
CONTENT_GIT_COMMIT_NAME="andwati.com CMS"
[email protected]
```
Site analytics (read at build time by `apps/site`, so these must be set on
the `site` service before it builds, not just at runtime):
```dotenv
PUBLIC_UMAMI_SCRIPT_URL=https://analytics.andwati.com/script.js
PUBLIC_UMAMI_WEBSITE_ID=9eef8fba-c932-4488-ae23-6b84553e8c29
```
`Analytics.astro` silently no-ops if either is unset — safe to deploy without
them, but analytics won't record anything until both are present at build
time. `sws.toml`'s CSP already allows `analytics.andwati.com` and
`utteranc.es` (comments); if the Umami host ever changes, update both that
CSP and these two vars together, or the tracker will be silently blocked in
production instead of erroring visibly.
Generate each random secret independently; do not reuse the local development
values from `docker-compose.yml`.
## Persistent data
The production Compose file declares three named volumes:
- `cms_database` keeps Strapi's admin users, API tokens, and SQLite cache.
- `cms_uploads` keeps files uploaded through Strapi.
- `content_checkout` keeps the Git working tree used for publishing.
Back up `cms_database` and `cms_uploads`. The content checkout is recoverable
from Git, but retaining it protects an edit if the process stops between the
filesystem write and Git push.
On startup, the CMS fast-forwards a clean checkout to the configured branch.
If it finds uncommitted files, it preserves them and starts without pulling so
an interrupted edit is not overwritten. Resolve and push those files from the
volume before forcing a reset.
## Local validation
Provide temporary values for required interpolation variables, then validate
the production manifest:
```sh
docker compose --env-file .env.production \
-f docker-compose.prod.yml config
docker compose --env-file .env.production \
-f docker-compose.prod.yml build
```
The existing `docker-compose.yml` remains the local Strapi development setup.
+14 -15
View File
@@ -1,21 +1,20 @@
# Multi-stage build: generate llms.txt with Node, build the site with Zola,
# then serve the static output with static-web-server.
#
# This is the Dokploy/self-hosted deployment path (the only one - the prior
# Cloudflare Pages/Workers path has been retired).
# Production image for the Astro site. Build from the workspace root:
# docker build -t andwati-site .
FROM node:22-slim AS llms
WORKDIR /project
COPY . .
RUN node scripts/generate-llms.mjs
FROM node:24-bookworm-slim AS build
RUN corepack enable
WORKDIR /app
FROM ghcr.io/getzola/zola:v0.23.1 AS zola
WORKDIR /project
COPY . .
COPY --from=llms /project/static/llms.txt static/llms.txt
RUN ["/zola", "build"]
COPY pnpm-workspace.yaml pnpm-lock.yaml package.json ./
COPY apps/site/package.json apps/site/package.json
COPY apps/cms/package.json apps/cms/package.json
COPY apps/site apps/site
COPY content content
COPY scripts scripts
RUN pnpm install --frozen-lockfile --filter andwati-com --filter site
RUN pnpm --filter site build
FROM ghcr.io/static-web-server/static-web-server:2
COPY --from=zola /project/public /public
COPY --from=build /app/apps/site/dist /public
COPY sws.toml /sws.toml
EXPOSE 80
-21
View File
@@ -1,21 +0,0 @@
#!/usr/bin/make -f
.PHONY: all build
BUILD_DIR := public
all: clean build
clean:
rm -rfd ./$(BUILD_DIR)
build:
node scripts/generate-llms.mjs
zola build
serve:
node scripts/generate-llms.mjs
zola serve
update-zola:
@./bin/update-zola
+210
View File
@@ -0,0 +1,210 @@
# Astro + Strapi Rebuild — TODO
Tracking doc for the `astro-migration` branch. See the locked spec in the
approved plan for full rationale on each decision referenced below.
## Done
- [x] Scaffold pnpm workspace (`apps/site` Astro, `apps/cms` Strapi + SQLite)
- [x] Switch tooling to pnpm-only, add Biome for lint/format
- [x] Define shared `content/` schema (writings, portfolio, bookshelf, blogs)
with TOML frontmatter matching the legacy Zola format
- [x] Bookshelf schema covers both books (ISBN) and papers (DOI/arXiv id)
- [x] Custom Astro content loader parsing `+++` TOML frontmatter directly
(no YAML conversion step)
- [x] Basic list/detail pages for all four content types, verified running
end-to-end in-browser (`pnpm --filter site dev`)
- [x] CLAUDE.md updated: no agent self-attribution in commits/PRs, commit
regularly after each working step
- [x] Strapi content-type definitions matching the `content/` schema exactly
(writings, portfolio, bookshelf, blogs) — verified boots and registers
all four REST routes
- [x] `apps/cms/Dockerfile.dev` + root `docker-compose.yml` for running
Strapi locally in a container (node:24-bookworm-slim, matches local
Node version); `apps/cms/Dockerfile` production multi-stage build for
later Dokploy deployment. Verified: builds, boots, admin panel and API
reachable, shared `content/` volume mounted at the right relative path
## Content pipeline
- [x] Strapi custom provider/lifecycle hooks that read + write the same
Markdown files in `content/` (Strapi's DB stays a disposable cache) —
`apps/cms/src/utils/content-{file,sync}.ts`, wired in `src/index.ts`
- [x] Verify round-trip: edit in Strapi admin → file on disk updates, and
vice versa, without drift — verified create/update/delete via the
content-manager API, plus idempotent re-import across a restart
- [x] Bookshelf ISBN lookup hook with local cover caching —
`apps/cms/src/utils/bookshelf-covers.ts` fetches from Open Library by
ISBN and caches into `apps/site/public/covers/`; verified end-to-end
(real cover downloaded, `cover_image` synced to file, cleaned up on
delete)
- [x] Bookshelf paper lookup hook — Semantic Scholar backfills title/authors
by DOI/arXiv id when missing. No cover/thumbnail: neither Semantic
Scholar nor arXiv offers a reliable image source for papers, so
`cover_image` stays manually-set for papers (or gets one later from
the OG-image pipeline below)
- [x] OG image auto-generation extended to all three content types
(writings, portfolio, bookshelf) using the new design system —
`scripts/generate-og-images.astro-site.mjs`, wired into `apps/site`'s
build script; `Base.astro` renders `og:image`/`twitter:image` from a
per-page `ogImage` prop (writings/bookshelf detail pages wire it in;
portfolio has no detail page yet to wire it into). Uses static-weight
Fraunces/Newsreader font packages, not the variable fonts the site
ships — satori's opentype.js parser can't read the variable fvar table
- [x] `llms.txt` generation extended to cover all content types — unblocked
once `site.config.ts` existed (built above for feeds/meta anyway):
`scripts/generate-llms.astro-site.mjs` reads all four content
directories directly, wired into `apps/site`'s build script
## Design system
- [x] New typography scale + color system — dark-first (warm near-black,
cream text, coral accent) with a warm-paper light mode;
Fraunces (display/italic) + Newsreader (body) + JetBrains Mono
(nav/meta/code), fluid clamp() type scale. `src/styles/tokens.css`.
Referenced pixperk.tech and mcyoung.xyz — see the commit for specifics
- [x] Motion primitives: CSS + native Web Animations API spring utility —
`apps/site/src/lib/spring.ts` (damped-oscillator keyframes,
`prefers-reduced-motion` aware). Not wired into any component yet —
the Three.js centerpiece below is the natural first user
- [x] Base layout/components replacing the placeholder `Base.astro` — real
header/nav with active-link state, theme toggle, footer with social
links. Verified light + dark in-browser across home, a writing
detail page (TOC/headings), bookshelf, and 404
- [x] Homepage redesign around the Three.js centerpiece as hero — the
constellation sits above a 3-column dashboard (latest writings,
portfolio, bookshelf), verified rendering and hover/click working
in-browser
- [x] New 404 page concept (terminal easter egg retired) — "Page Fault",
now fully styled with the rest of the design system
## Three.js centerpiece
- [x] Constellation 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/fake data. `src/lib/constellation-data.ts`
- [x] Built as an isolated component (`Constellation.astro` + a plain
client `<script>` module, no framework), fibonacci-sphere layout,
hover raycasting with a title tooltip, click-through to the entry
- [x] Fallback for `prefers-reduced-motion` (skips rotation/entrance
animation) and no-WebGL (`supportsWebGL()` check hides the canvas
entirely, falls back to a `<noscript>`-style plain count)
- [x] Perf budget check — scene is trivially light (24 nodes, 12x12-segment
spheres, a handful of line-segment edges: well under 10k triangles),
renders at 100+fps uncapped with no dropped frames. The real cost is
the JS payload: three.js core pulls in ~130KB gzip / ~106KB brotli
regardless of `import *` vs named imports (`sideEffects: false`
already lets Rollup tree-shake fully; tried named imports, no size
change). That script is a deferred module (doesn't block first
paint/DCL) and the page was interactive in ~270ms in testing. No
code changes made — budget is fine as shipped
## Content features carried over from the Zola site
- [x] Series-nav (multi-part post series) — `writings/[...slug].astro`,
uses the existing `extra.series`/`extra.series_index` fields
- [x] Table of contents — sticky sidebar on desktop (>=74rem), falls back to
the original inline list below the tags on narrower viewports; same
heading data rendered via a shared `TocList.astro` so the two aren't
duplicated markup by hand. `.container--wide` bumped 64rem → 76rem so
big monitors use more of the screen (prose column itself stays capped
at 42rem for readability — the extra width is the TOC gutter);
verified at 390/900/1180/1920px with no distortion at any width
- [x] Prev/next post navigation
- [x] Related posts (tag-based, tag-overlap ranked, limit 3)
- [x] Per-tag descriptions on tag archive pages — `/tags/` + `/tags/[tag]/`,
descriptions ported from `zola.toml`'s `[extra.tag_descriptions]`
- [x] `llms_description` meta (site-wide default in `site.config.ts` +
per-post override, rendered as `<meta name="description">` in
`Base.astro`)
- [x] RSS/JSON feeds — `/rss.xml`, `/feed.json`
- [x] Sitemap + robots.txt — `@astrojs/sitemap`, static `public/robots.txt`
- [x] Code block syntax highlighting — `toml-content-loader.ts` renders
fenced code blocks through `highlight.js`'s core build with only the
11 languages content actually uses registered (no bundled-theme/WASM
grammar weight like shiki, which was tried first and swapped out for
this lighter option); `.hljs-*` token classes in `global.css` are
colored from the existing design tokens (`--accent`, `--text-muted`,
...), so they already switch with the site's theme with no extra
`[data-theme]` rules needed
## Search & comments
- [x] Pagefind integration — indexes the build output (`pnpm build` runs
`astro build && pagefind --site dist`), `/search/` page using
Pagefind's default UI; verified with a real query in a browser
- [x] Comments: kept Utterances (not Webmentions) — `Comments.astro`, wired
to `andwati/andwati.com`, with the same dark/light theme-sync-via-
postMessage behavior the legacy site had, adapted to this site's
`data-theme` attribute. The `rel="webmention"` link was removed since
it's unused now. `sws.toml`'s CSP updated to allow `utteranc.es`
(script-src, frame-src) — without that the widget is silently blocked
in production
## Analytics
- [x] Umami — self-hosted at `analytics.andwati.com`. `Analytics.astro`
renders the tracker script from `PUBLIC_UMAMI_SCRIPT_URL` +
`PUBLIC_UMAMI_WEBSITE_ID` (set in `apps/site/.env`, gitignored — copy
into the production environment's env vars too); `sws.toml`'s CSP
updated to allow that host in `script-src`/`connect-src`
## Migration from the legacy Zola site
- [x] Script/process to convert `content/posts/*.md` into the new
`content/writings/` schema — `scripts/migrate-posts-to-writings.mjs`,
additive only (`content/posts/` untouched), re-runnable/idempotent.
2 of 20 posts (`from-c-to-machine-code`,
`self-hosting-gitea-and-mirroring-github`) use note/tip/warning/danger
callout shortcodes — flagged by the script, still left as-is in the
body text
- [x] note/tip/warning/danger callout shortcode rendering — the legacy
`{% <note> %} ... {% </note> %}` blocks are converted to styled
`.callout` markup by `toml-content-loader.ts`'s `renderCallouts()`
(runs before the body's markdown parse), styled in `global.css` to
match the new design system. Verified in-browser on
`from-c-to-machine-code`
- [x] Preserve slugs/URLs via redirects — the script generates 301s from
`/posts/<slug>/` to `/writings/<slug>/` into `sws.toml`, idempotently
- [x] Migrate `content/archive/`, `content/about.md` equivalents —
`/archive/` groups every published writing by year; `/about/` reframes
the legacy copy around the site's "digital legacy" identity while
preserving its PGP and security contact details. Both are in the main
navigation, and the linked `.well-known` files are copied into Astro's
public output
- [ ] Verify RSS subscribers and inbound links survive the cutover — can
only really be checked once this is deployed live (**needs your
input**: this is a production-cutover verification, not a build-time
one)
## Infra / deployment
- [x] Wire Strapi into the Dokploy deployment alongside the Astro build —
`docker-compose.prod.yml` defines both production services, persistent
CMS/Git volumes, reverse-proxy settings, and secret-only configuration
- [x] Build trigger decided and implemented: Strapi commits Markdown and
generated covers to Git, pushes the production branch, then calls the
Dokploy Compose deploy webhook so Astro rebuilds from the durable commit
- [x] Update `Dockerfile`/`sws.toml` for Astro's `apps/site/dist` output,
current security headers, legacy redirects, and the custom 404 page
- [ ] Launch the production Compose application and configure its domains,
secrets, GitHub token, and deploy webhook in Dokploy — repository-side
setup is complete in `DEPLOYMENT.md`; host access is still required
## Cleanup
- [ ] Remove Zola-specific files (`zola.toml`, `templates/`, `sass/`,
`themes/`, `static/`, `Makefile`, `bin/update-zola`, `content/posts/`,
`content/archive/_index.md`, `content/about.md`, `content/_index.md`,
legacy OG/llms scripts) — verified safe: root `Dockerfile` already
builds `apps/site` (not `zola build`), nothing in `apps/site` reads
those content files, and `content/writings/` has all 20 posts from
`content/posts/`. Root `package.json`'s `legacy:*` scripts already
removed. **Blocked**: the actual `git rm` was refused by the
permission system as irreversible destruction — run it yourself (fully
reversible via git history/revert since these are tracked files):
`git rm -r templates/ sass/ themes/ static/ zola.toml Makefile bin/
content/posts/ content/archive/_index.md content/about.md
content/_index.md scripts/generate-llms.mjs
scripts/generate-og-images.mjs`
+21
View File
@@ -0,0 +1,21 @@
HOST=0.0.0.0
PORT=1337
APP_KEYS="toBeModified1,toBeModified2"
API_TOKEN_SALT=tobemodified
ADMIN_JWT_SECRET=tobemodified
TRANSFER_TOKEN_SALT=tobemodified
JWT_SECRET=tobemodified
ENCRYPTION_KEY=tobemodified
# Production reverse-proxy and Git publishing settings. Git publishing stays
# disabled during local development unless explicitly enabled.
PUBLIC_URL=http://localhost:1337
IS_PROXIED=false
GIT_PUBLISH_ENABLED=false
CONTENT_REPOSITORY_ROOT=
CONTENT_GIT_REPOSITORY=andwati/andwati.com
CONTENT_GIT_BRANCH=main
CONTENT_GIT_COMMIT_NAME="andwati.com CMS"
CONTENT_GIT_COMMIT_EMAIL=[email protected]
GITHUB_TOKEN=
DOKPLOY_DEPLOY_WEBHOOK_URL=
+131
View File
@@ -0,0 +1,131 @@
############################
# OS X
############################
.DS_Store
.AppleDouble
.LSOverride
Icon
.Spotlight-V100
.Trashes
._*
############################
# Linux
############################
*~
############################
# Windows
############################
Thumbs.db
ehthumbs.db
Desktop.ini
$RECYCLE.BIN/
*.cab
*.msi
*.msm
*.msp
############################
# Packages
############################
*.7z
*.csv
*.dat
*.dmg
*.gz
*.iso
*.jar
*.rar
*.tar
*.zip
*.com
*.class
*.dll
*.exe
*.o
*.seed
*.so
*.swo
*.swp
*.swn
*.swm
*.out
*.pid
############################
# Logs and databases
############################
.tmp
*.log
*.sql
*.sqlite
*.sqlite3
############################
# Misc.
############################
*#
ssl
.idea
nbproject
public/uploads/*
!public/uploads/.gitkeep
.tsbuildinfo
.eslintcache
############################
# Node.js
############################
lib-cov
lcov.info
pids
logs
results
node_modules
.node_history
############################
# Package managers
############################
.yarn/*
!.yarn/cache
!.yarn/unplugged
!.yarn/patches
!.yarn/releases
!.yarn/sdks
!.yarn/versions
.pnp.*
yarn-error.log
############################
# Tests
############################
coverage
############################
# Strapi
############################
.env
license.txt
exports
.strapi
dist
build
.strapi-updater.json
.strapi-cloud.json
+37
View File
@@ -0,0 +1,37 @@
# Production image for the Strapi CMS. Multi-stage: install deps, build the
# admin panel, then copy only the built output + prod node_modules into the
# runtime layer.
#
# Build context must be the repo root (pnpm workspace root), e.g.:
# docker build -f apps/cms/Dockerfile -t andwati-cms .
FROM node:24-bookworm-slim AS base
RUN corepack enable
WORKDIR /app
FROM base AS deps
# better-sqlite3 needs to compile if no prebuilt binary matches the platform.
RUN apt-get update && apt-get install -y --no-install-recommends \
python3 make g++ \
&& rm -rf /var/lib/apt/lists/*
COPY pnpm-workspace.yaml pnpm-lock.yaml package.json ./
COPY apps/site/package.json apps/site/package.json
COPY apps/cms/package.json apps/cms/package.json
RUN pnpm install --frozen-lockfile --filter cms
FROM deps AS build
ENV NODE_ENV=production
COPY apps/cms apps/cms
RUN pnpm --filter cms build
FROM base AS runtime
ENV NODE_ENV=production
RUN apt-get update && apt-get install -y --no-install-recommends \
ca-certificates git \
&& rm -rf /var/lib/apt/lists/*
COPY --from=build /app/node_modules /app/node_modules
COPY --from=build /app/apps/cms /app/apps/cms
COPY --chmod=755 apps/cms/docker-entrypoint.sh /usr/local/bin/andwati-cms-entrypoint
WORKDIR /app/apps/cms
EXPOSE 1337
ENTRYPOINT ["andwati-cms-entrypoint"]
+30
View File
@@ -0,0 +1,30 @@
# Local testing image for the Strapi CMS — runs `strapi develop` with the
# workspace's own lockfile, so it matches what `pnpm --filter cms develop`
# does on the host. Not for production (see Dockerfile for that).
#
# Build context must be the repo root (pnpm workspace root), e.g.:
# docker build -f apps/cms/Dockerfile.dev -t andwati-cms-dev .
# or simply: docker compose up cms
FROM node:24-bookworm-slim
# better-sqlite3 needs to compile if no prebuilt binary matches the platform.
RUN apt-get update && apt-get install -y --no-install-recommends \
python3 make g++ \
&& rm -rf /var/lib/apt/lists/*
RUN corepack enable
WORKDIR /app
# Copy just the manifests first so `pnpm install` is cached across rebuilds
# unless a package.json actually changes.
COPY pnpm-workspace.yaml pnpm-lock.yaml package.json ./
COPY apps/site/package.json apps/site/package.json
COPY apps/cms/package.json apps/cms/package.json
RUN pnpm install --frozen-lockfile --filter cms
COPY apps/cms apps/cms
WORKDIR /app/apps/cms
EXPOSE 1337
CMD ["pnpm", "develop"]
+61
View File
@@ -0,0 +1,61 @@
# 🚀 Getting started with Strapi
Strapi comes with a full featured [Command Line Interface](https://docs.strapi.io/dev-docs/cli) (CLI) which lets you scaffold and manage your project in seconds.
### `develop`
Start your Strapi application with autoReload enabled. [Learn more](https://docs.strapi.io/dev-docs/cli#strapi-develop)
```
npm run develop
# or
yarn develop
```
### `start`
Start your Strapi application with autoReload disabled. [Learn more](https://docs.strapi.io/dev-docs/cli#strapi-start)
```
npm run start
# or
yarn start
```
### `build`
Build your admin panel. [Learn more](https://docs.strapi.io/dev-docs/cli#strapi-build)
```
npm run build
# or
yarn build
```
## ⚙️ Deployment
Strapi gives you many possible deployment options for your project including [Strapi Cloud](https://cloud.strapi.io). Browse the [deployment section of the documentation](https://docs.strapi.io/dev-docs/deployment) to find the best solution for your use case.
```
yarn strapi deploy
```
## 📚 Learn more
- [Resource center](https://strapi.io/resource-center) - Strapi resource center.
- [Strapi documentation](https://docs.strapi.io) - Official Strapi documentation.
- [Strapi tutorials](https://strapi.io/tutorials) - List of tutorials made by the core team and the community.
- [Strapi blog](https://strapi.io/blog) - Official Strapi blog containing articles made by the Strapi team and the community.
- [Changelog](https://strapi.io/changelog) - Find out about the Strapi product updates, new features and general improvements.
Feel free to check out the [Strapi GitHub repository](https://github.com/strapi/strapi). Your feedback and contributions are welcome!
## ✨ Community
- [Discord](https://discord.strapi.io) - Come chat with the Strapi community including the core team.
- [Forum](https://forum.strapi.io/) - Place to discuss, ask questions and find answers, show your Strapi project and get feedback or just talk with other Community members.
- [Awesome Strapi](https://github.com/strapi/awesome-strapi) - A curated list of awesome things related to Strapi.
---
<sub>🤫 Psst! [Strapi is hiring](https://strapi.io/careers).</sub>
+27
View File
@@ -0,0 +1,27 @@
import type { Core } from "@strapi/strapi";
const config = ({
env,
}: Core.Config.Shared.ConfigParams): Core.Config.Admin => ({
auth: {
secret: env("ADMIN_JWT_SECRET")!,
},
apiToken: {
salt: env("API_TOKEN_SALT")!,
},
transfer: {
token: {
salt: env("TRANSFER_TOKEN_SALT")!,
},
},
secrets: {
encryptionKey: env("ENCRYPTION_KEY")!,
},
flags: {
nps: env.bool("FLAG_NPS", true),
promoteEE: env.bool("FLAG_PROMOTE_EE", true),
docLinks: env.bool("FLAG_DOC_LINKS", true),
},
});
export default config;
+16
View File
@@ -0,0 +1,16 @@
import type { Core } from "@strapi/strapi";
const config: Core.Config.Api = {
rest: {
defaultLimit: 25,
maxLimit: 100,
withCount: true,
strictParams: true,
},
documents: {
strictParams: true,
strictRelations: true,
},
};
export default config;
+94
View File
@@ -0,0 +1,94 @@
import { isDatabaseClientKind } from "@strapi/database";
import type { Core } from "@strapi/strapi";
import path from "path";
const config = ({
env,
}: Core.Config.Shared.ConfigParams): Core.Config.Database => {
const client = env("DATABASE_CLIENT", "sqlite");
if (!isDatabaseClientKind(client)) {
throw new Error(
`Unsupported DATABASE_CLIENT: ${client}. Use "postgres", "mysql", or "sqlite".`,
);
}
const connections: Record<
Core.Config.Database.ClientKind,
Core.Config.Database["connection"]
> = {
mysql: {
client: "mysql",
connection: {
host: env("DATABASE_HOST", "localhost"),
port: env.int("DATABASE_PORT", 3306),
database: env("DATABASE_NAME", "strapi"),
user: env("DATABASE_USERNAME", "strapi"),
password: env("DATABASE_PASSWORD", "strapi"),
ssl: env.bool("DATABASE_SSL", false) && {
key: env("DATABASE_SSL_KEY", undefined),
cert: env("DATABASE_SSL_CERT", undefined),
ca: env("DATABASE_SSL_CA", undefined),
capath: env("DATABASE_SSL_CAPATH", undefined),
cipher: env("DATABASE_SSL_CIPHER", undefined),
rejectUnauthorized: env.bool(
"DATABASE_SSL_REJECT_UNAUTHORIZED",
true,
),
},
},
pool: {
min: env.int("DATABASE_POOL_MIN", 2),
max: env.int("DATABASE_POOL_MAX", 10),
},
},
postgres: {
client: "postgres",
connection: {
connectionString: env("DATABASE_URL"),
host: env("DATABASE_HOST", "localhost"),
port: env.int("DATABASE_PORT", 5432),
database: env("DATABASE_NAME", "strapi"),
user: env("DATABASE_USERNAME", "strapi"),
password: env("DATABASE_PASSWORD", "strapi"),
ssl: env.bool("DATABASE_SSL", false) && {
key: env("DATABASE_SSL_KEY", undefined),
cert: env("DATABASE_SSL_CERT", undefined),
ca: env("DATABASE_SSL_CA", undefined),
capath: env("DATABASE_SSL_CAPATH", undefined),
cipher: env("DATABASE_SSL_CIPHER", undefined),
rejectUnauthorized: env.bool(
"DATABASE_SSL_REJECT_UNAUTHORIZED",
true,
),
},
schema: env("DATABASE_SCHEMA", "public"),
},
pool: {
min: env.int("DATABASE_POOL_MIN", 2),
max: env.int("DATABASE_POOL_MAX", 10),
},
},
sqlite: {
client: "sqlite",
connection: {
filename: path.join(
__dirname,
"..",
"..",
env("DATABASE_FILENAME", ".tmp/data.db"),
),
},
useNullAsDefault: true,
},
};
return {
connection: {
...connections[client],
acquireConnectionTimeout: env.int("DATABASE_CONNECTION_TIMEOUT", 60000),
},
};
};
export default config;
+16
View File
@@ -0,0 +1,16 @@
import type { Core } from "@strapi/strapi";
const config: Core.Config.Middlewares = [
"strapi::logger",
"strapi::errors",
"strapi::security",
"strapi::cors",
"strapi::poweredBy",
"strapi::query",
"strapi::body",
"strapi::session",
"strapi::favicon",
"strapi::public",
];
export default config;
+47
View File
@@ -0,0 +1,47 @@
import type { Core } from "@strapi/strapi";
const allowedMediaTypes = [
"image/*",
"video/*",
"audio/*",
"application/pdf",
"application/msword",
"application/vnd.openxmlformats-officedocument.*",
"text/plain",
"text/csv",
];
const deniedTypes = [
"image/svg+xml",
"application/vnd.microsoft.portable-executable",
"application/x-msdownload",
"application/x-msdos-program",
"application/x-executable",
"application/x-dosexec",
"application/x-sh",
"text/x-shellscript",
"application/x-mach-binary",
];
const config = ({
env,
}: Core.Config.Shared.ConfigParams): Core.Config.Plugin => ({
"users-permissions": {
config: {
jwtManagement: "refresh",
sessions: {
httpOnly: true,
},
},
},
upload: {
config: {
security: {
allowedTypes: allowedMediaTypes,
deniedTypes,
},
},
},
});
export default config;
+18
View File
@@ -0,0 +1,18 @@
import type { Core } from "@strapi/strapi";
const config = ({
env,
}: Core.Config.Shared.ConfigParams): Core.Config.Server => ({
host: env("HOST", "0.0.0.0"),
port: env.int("PORT", 1337),
url: env("PUBLIC_URL", "http://localhost:1337"),
proxy: env.bool("IS_PROXIED", false),
app: {
keys: env.array("APP_KEYS")!,
},
webhooks: {
populateRelations: env.bool("WEBHOOKS_POPULATE_RELATIONS", false),
},
});
export default config;
+40
View File
@@ -0,0 +1,40 @@
#!/bin/sh
set -eu
repository_root="${CONTENT_REPOSITORY_ROOT:-/content-repository}"
repository="${CONTENT_GIT_REPOSITORY:-andwati/andwati.com}"
branch="${CONTENT_GIT_BRANCH:-main}"
case "$repository" in
*[!A-Za-z0-9._/-]* | /* | */ | *..*)
echo "Invalid CONTENT_GIT_REPOSITORY: $repository" >&2
exit 1
;;
esac
mkdir -p "$repository_root"
if [ ! -d "$repository_root/.git" ]; then
if [ -n "$(find "$repository_root" -mindepth 1 -maxdepth 1 -print -quit)" ]; then
echo "$repository_root is not empty and is not a Git checkout" >&2
exit 1
fi
git clone \
--branch "$branch" \
--single-branch \
"https://github.com/$repository.git" \
"$repository_root"
else
git -C "$repository_root" fetch origin "$branch"
git -C "$repository_root" checkout "$branch"
if [ -z "$(git -C "$repository_root" status --porcelain)" ]; then
git -C "$repository_root" merge --ff-only "origin/$branch"
else
echo "Content checkout has unpublished changes; preserving them without pulling." >&2
fi
fi
export CONTENT_REPOSITORY_ROOT="$repository_root"
exec node node_modules/@strapi/strapi/bin/strapi.js start
Binary file not shown.

After

Width:  |  Height:  |  Size: 497 B

+47
View File
@@ -0,0 +1,47 @@
{
"name": "cms",
"version": "0.1.0",
"private": true,
"description": "A Strapi application",
"scripts": {
"predev": "test -f .env || cp .env.example .env",
"predevelop": "test -f .env || cp .env.example .env",
"build": "strapi build",
"console": "strapi console",
"deploy": "strapi deploy",
"dev": "strapi develop",
"develop": "strapi develop",
"start": "strapi start",
"strapi": "strapi",
"test:publisher": "tsx --test tests/git-publisher.test.ts",
"upgrade": "npx @strapi/upgrade latest",
"upgrade:dry": "npx @strapi/upgrade latest --dry"
},
"dependencies": {
"@strapi/database": "5.52.2",
"@strapi/plugin-cloud": "5.52.2",
"@strapi/plugin-users-permissions": "5.52.2",
"@strapi/strapi": "5.52.2",
"better-sqlite3": "12.8.0",
"react": "^18.0.0",
"react-dom": "^18.0.0",
"react-router-dom": "^6.30.3",
"smol-toml": "^1.8.0",
"styled-components": "^6.0.0"
},
"devDependencies": {
"@types/node": "^20",
"@types/react": "^18",
"@types/react-dom": "^18",
"tsx": "^4.23.13",
"typescript": "^5"
},
"engines": {
"node": ">=20.0.0 <=26.x.x",
"npm": ">=6.0.0"
},
"strapi": {
"uuid": "53c467c6-a64c-4883-b4df-0d96747d2cf3",
"installId": "dcf09d0c097e16db63c329dd9602949f9c4ae64f4e69a04dc6e105313d30b369"
}
}
+3
View File
@@ -0,0 +1,3 @@
# To prevent search engines from seeing the site altogether, uncomment the next two lines:
# User-Agent: *
# Disallow: /
+37
View File
@@ -0,0 +1,37 @@
import type { StrapiApp } from "@strapi/strapi/admin";
export default {
config: {
locales: [
// 'ar',
// 'fr',
// 'cs',
// 'de',
// 'da',
// 'es',
// 'he',
// 'id',
// 'it',
// 'ja',
// 'ko',
// 'ms',
// 'nl',
// 'no',
// 'pl',
// 'pt-BR',
// 'pt',
// 'ru',
// 'sk',
// 'sv',
// 'th',
// 'tr',
// 'uk',
// 'vi',
// 'zh-Hans',
// 'zh',
],
},
bootstrap(app: StrapiApp) {
console.log(app);
},
};
+20
View File
@@ -0,0 +1,20 @@
{
"compilerOptions": {
"target": "ESNext",
"module": "ESNext",
"moduleResolution": "Bundler",
"useDefineForClassFields": true,
"lib": ["DOM", "DOM.Iterable", "ESNext"],
"allowJs": false,
"skipLibCheck": true,
"esModuleInterop": true,
"allowSyntheticDefaultImports": true,
"strict": true,
"forceConsistentCasingInFileNames": true,
"resolveJsonModule": true,
"noEmit": true,
"jsx": "react-jsx"
},
"include": ["../plugins/**/admin/src/**/*", "./"],
"exclude": ["node_modules/", "build/", "dist/", "**/*.test.ts"]
}
+12
View File
@@ -0,0 +1,12 @@
import { mergeConfig, type UserConfig } from "vite";
export default (config: UserConfig) => {
// Important: always return the modified config
return mergeConfig(config, {
resolve: {
alias: {
"@": "/src",
},
},
});
};
@@ -0,0 +1,77 @@
{
"kind": "collectionType",
"collectionName": "bookshelf_entries",
"info": {
"singularName": "bookshelf-entry",
"pluralName": "bookshelf-entries",
"displayName": "Bookshelf Entry",
"description": "Books and papers, synced to content/bookshelf/. `kind` selects which identifier fields apply."
},
"options": {
"draftAndPublish": false
},
"attributes": {
"title": {
"type": "string",
"required": true
},
"slug": {
"type": "uid",
"targetField": "title",
"required": true,
"description": "Matches the file name under content/bookshelf/"
},
"kind": {
"type": "enumeration",
"enum": ["book", "paper"],
"required": true
},
"authors": {
"type": "json",
"description": "Array of author name strings"
},
"isbn": {
"type": "string",
"description": "Books only"
},
"doi": {
"type": "string",
"description": "Papers only"
},
"arxiv_id": {
"type": "string",
"description": "Papers only, optional"
},
"url": {
"type": "string",
"description": "Papers only: landing page / source link"
},
"cover_image": {
"type": "string",
"description": "Path to the locally cached cover, e.g. /covers/slug.jpg"
},
"rating": {
"type": "integer",
"min": 1,
"max": 5
},
"read_status": {
"type": "enumeration",
"enum": ["reading", "read", "abandoned"]
},
"date_started": {
"type": "date"
},
"date_finished": {
"type": "date"
},
"tags": {
"type": "json",
"description": "Array of tag strings, maps to [taxonomies].tags"
},
"body": {
"type": "richtext",
"description": "Personal review/notes below the frontmatter"
}
}
}
@@ -0,0 +1,5 @@
import { factories } from "@strapi/strapi";
export default factories.createCoreController(
"api::bookshelf-entry.bookshelf-entry",
);
@@ -0,0 +1,5 @@
import { factories } from "@strapi/strapi";
export default factories.createCoreRouter(
"api::bookshelf-entry.bookshelf-entry",
);
@@ -0,0 +1,5 @@
import { factories } from "@strapi/strapi";
export default factories.createCoreService(
"api::bookshelf-entry.bookshelf-entry",
);
@@ -0,0 +1,36 @@
{
"kind": "collectionType",
"collectionName": "curated_blogs",
"info": {
"singularName": "curated-blog",
"pluralName": "curated-blogs",
"displayName": "Curated Blog",
"description": "Hand-picked external blogs, synced to content/blogs/. No live feed fetching."
},
"options": {
"draftAndPublish": false
},
"attributes": {
"title": {
"type": "string",
"required": true
},
"slug": {
"type": "uid",
"targetField": "title",
"required": true,
"description": "Matches the file name under content/blogs/"
},
"url": {
"type": "string",
"required": true
},
"feed_url": {
"type": "string"
},
"body": {
"type": "richtext",
"description": "One-to-two sentence blurb below the frontmatter"
}
}
}
@@ -0,0 +1,3 @@
import { factories } from "@strapi/strapi";
export default factories.createCoreController("api::curated-blog.curated-blog");
@@ -0,0 +1,3 @@
import { factories } from "@strapi/strapi";
export default factories.createCoreRouter("api::curated-blog.curated-blog");
@@ -0,0 +1,3 @@
import { factories } from "@strapi/strapi";
export default factories.createCoreService("api::curated-blog.curated-blog");
@@ -0,0 +1,56 @@
{
"kind": "collectionType",
"collectionName": "portfolio_entries",
"info": {
"singularName": "portfolio-entry",
"pluralName": "portfolio-entries",
"displayName": "Portfolio Entry",
"description": "Projects and professional work, synced to content/portfolio/."
},
"options": {
"draftAndPublish": false
},
"attributes": {
"title": {
"type": "string",
"required": true
},
"slug": {
"type": "uid",
"targetField": "title",
"required": true,
"description": "Matches the file name under content/portfolio/"
},
"description": {
"type": "text"
},
"role": {
"type": "string"
},
"date_start": {
"type": "date"
},
"date_end": {
"type": "date"
},
"outcome": {
"type": "string"
},
"draft": {
"type": "boolean",
"default": false
},
"links": {
"type": "json",
"description": "Array of { label, url }, maps to [[links]]"
},
"tags": {
"type": "json",
"description": "Array of tag strings, maps to [taxonomies].tags"
},
"body": {
"type": "richtext",
"description": "Markdown case-study body below the frontmatter"
}
}
}
@@ -0,0 +1,5 @@
import { factories } from "@strapi/strapi";
export default factories.createCoreController(
"api::portfolio-entry.portfolio-entry",
);
@@ -0,0 +1,5 @@
import { factories } from "@strapi/strapi";
export default factories.createCoreRouter(
"api::portfolio-entry.portfolio-entry",
);
@@ -0,0 +1,5 @@
import { factories } from "@strapi/strapi";
export default factories.createCoreService(
"api::portfolio-entry.portfolio-entry",
);
@@ -0,0 +1,65 @@
{
"kind": "collectionType",
"collectionName": "writings",
"info": {
"singularName": "writing",
"pluralName": "writings",
"displayName": "Writing",
"description": "Long-form essays and technical posts, synced to content/writings/."
},
"options": {
"draftAndPublish": false
},
"attributes": {
"title": {
"type": "string",
"required": true
},
"slug": {
"type": "uid",
"targetField": "title",
"required": true,
"description": "Matches the file/directory name under content/writings/"
},
"description": {
"type": "text"
},
"author": {
"type": "string"
},
"date": {
"type": "date",
"required": true
},
"updated": {
"type": "date"
},
"draft": {
"type": "boolean",
"default": false
},
"canonical_url": {
"type": "string"
},
"tags": {
"type": "json",
"description": "Array of tag strings, maps to [taxonomies].tags"
},
"series": {
"type": "string",
"description": "Maps to [extra].series"
},
"series_index": {
"type": "integer",
"description": "Maps to [extra].series_index"
},
"llms_description": {
"type": "text",
"description": "Maps to [extra].llms_description"
},
"body": {
"type": "richtext",
"description": "Markdown body below the frontmatter"
}
}
}
@@ -0,0 +1,3 @@
import { factories } from "@strapi/strapi";
export default factories.createCoreController("api::writing.writing");
@@ -0,0 +1,3 @@
import { factories } from "@strapi/strapi";
export default factories.createCoreRouter("api::writing.writing");
@@ -0,0 +1,3 @@
import { factories } from "@strapi/strapi";
export default factories.createCoreService("api::writing.writing");
View File
+20
View File
@@ -0,0 +1,20 @@
import type { Core } from "@strapi/strapi";
import {
importContentFromDisk,
registerContentSync,
} from "./utils/content-sync";
export default {
register() {},
/**
* content/ is the source of truth (see content/README.md). On boot we
* import every file into Strapi's DB (a disposable cache), then register
* a Document Service middleware that writes any create/update/delete made
* through Strapi straight back to the same files.
*/
async bootstrap({ strapi }: { strapi: Core.Strapi }) {
registerContentSync(strapi as never);
await importContentFromDisk(strapi as never);
},
};
+131
View File
@@ -0,0 +1,131 @@
import { mkdirSync, writeFileSync } from "node:fs";
import { dirname, join } from "node:path";
import { coversRoot } from "./content-paths";
/**
* Covers are cached into the Astro app's public/ dir directly, so the built
* site serves them from /covers/<slug>.jpg with no runtime dependency on the
* source (Open Library, Semantic Scholar, ...).
*/
const COVERS_DIR = coversRoot();
async function download(url: string): Promise<Buffer | undefined> {
const res = await fetch(url);
if (!res.ok) return undefined;
const buf = Buffer.from(await res.arrayBuffer());
// Open Library serves a tiny placeholder image for ISBNs it doesn't have a cover for.
if (buf.byteLength < 500) return undefined;
return buf;
}
/** Fetches a book cover by ISBN from Open Library and caches it locally. */
export async function fetchBookCover(
isbn: string,
slug: string,
): Promise<string | undefined> {
const buf = await download(
`https://covers.openlibrary.org/b/isbn/${encodeURIComponent(isbn)}-L.jpg?default=false`,
);
if (!buf) return undefined;
const file = join(COVERS_DIR, `${slug}.jpg`);
mkdirSync(dirname(file), { recursive: true });
writeFileSync(file, buf);
return `/covers/${slug}.jpg`;
}
/**
* Sniffs the actual file signature rather than trusting the Content-Type
* header — some CDNs (O'Reilly's included, observed while building this)
* mislabel WebP responses as image/jpeg, which would otherwise get a wrong
* extension and a mismatched Content-Type when we later serve it ourselves.
*/
function extensionFromBytes(buf: Buffer): string {
if (
buf.length >= 12 &&
buf.subarray(0, 4).toString("ascii") === "RIFF" &&
buf.subarray(8, 12).toString("ascii") === "WEBP"
) {
return "webp";
}
if (
buf.length >= 8 &&
buf
.subarray(0, 8)
.equals(Buffer.from([0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a]))
) {
return "png";
}
if (
buf.length >= 3 &&
buf[0] === 0xff &&
buf[1] === 0xd8 &&
buf[2] === 0xff
) {
return "jpg";
}
if (
buf.length >= 6 &&
(buf.subarray(0, 6).toString("ascii") === "GIF87a" ||
buf.subarray(0, 6).toString("ascii") === "GIF89a")
) {
return "gif";
}
return "jpg";
}
/**
* Downloads whatever image URL is at `url` (e.g. a cover_image the user
* pasted in by hand) and caches it locally, so the site never depends on
* that URL staying up. Used for any http(s) cover_image, not just ISBN
* lookups.
*/
export async function cacheRemoteImage(
url: string,
slug: string,
): Promise<string | undefined> {
try {
const res = await fetch(url);
if (!res.ok) return undefined;
const buf = Buffer.from(await res.arrayBuffer());
if (buf.byteLength < 200) return undefined;
const ext = extensionFromBytes(buf);
const file = join(COVERS_DIR, `${slug}.${ext}`);
mkdirSync(dirname(file), { recursive: true });
writeFileSync(file, buf);
return `/covers/${slug}.${ext}`;
} catch {
return undefined;
}
}
interface PaperMetadata {
title?: string;
authors?: string[];
}
/**
* Fetches title/author metadata for a paper from Semantic Scholar by DOI or
* arXiv id, used to backfill fields left blank in Strapi. Semantic Scholar
* has no reliable thumbnail/cover source for papers, so unlike books this
* does not produce a cached image.
*/
export async function fetchPaperMetadata(identifier: {
doi?: string;
arxivId?: string;
}): Promise<PaperMetadata | undefined> {
const id = identifier.doi
? `DOI:${identifier.doi}`
: identifier.arxivId
? `ARXIV:${identifier.arxivId}`
: undefined;
if (!id) return undefined;
const res = await fetch(
`https://api.semanticscholar.org/graph/v1/paper/${encodeURIComponent(id)}?fields=title,authors`,
);
if (!res.ok) return undefined;
const data = (await res.json()) as {
title?: string;
authors?: { name: string }[];
};
return { title: data.title, authors: data.authors?.map((a) => a.name) };
}
+129
View File
@@ -0,0 +1,129 @@
import {
mkdirSync,
readdirSync,
readFileSync,
rmSync,
writeFileSync,
} from "node:fs";
import { dirname, join, relative } from "node:path";
import {
parse as parseToml,
stringify as stringifyToml,
TomlDate,
} from "smol-toml";
const FRONTMATTER_RE = /^\+\+\+\r?\n([\s\S]*?)\r?\n\+\+\+\r?\n?([\s\S]*)$/;
export interface ParsedEntryFile {
slug: string;
frontmatter: Record<string, unknown>;
body: string;
}
/** Strips undefined/null/empty-string/empty-array values so smol-toml doesn't choke on them. */
export function compact<T extends Record<string, unknown>>(
obj: T,
): Record<string, unknown> {
const out: Record<string, unknown> = {};
for (const [key, value] of Object.entries(obj)) {
if (value === undefined || value === null || value === "") continue;
if (Array.isArray(value) && value.length === 0) continue;
if (
typeof value === "object" &&
!Array.isArray(value) &&
!(value instanceof Date)
) {
const nested = compact(value as Record<string, unknown>);
if (Object.keys(nested).length === 0) continue;
out[key] = nested;
continue;
}
out[key] = value;
}
return out;
}
/** Converts a Strapi "date" field (an ISO `YYYY-MM-DD` string) to a bare TOML local-date. */
export function toTomlDate(
value: string | null | undefined,
): TomlDate | undefined {
return value ? new TomlDate(value) : undefined;
}
function walk(dir: string): string[] {
const out: string[] = [];
for (const entry of readdirSync(dir, { withFileTypes: true })) {
const full = join(dir, entry.name);
if (entry.isDirectory()) out.push(...walk(full));
else if (entry.isFile() && full.endsWith(".md")) out.push(full);
}
return out;
}
function slugFromPath(dir: string, file: string): string {
const rel = relative(dir, file).split("\\").join("/");
return rel.endsWith("/index.md")
? rel.slice(0, -"/index.md".length)
: rel.slice(0, -".md".length);
}
/** Reads every `+++`-frontmatter Markdown file under `dir` (flat or `<slug>/index.md`). */
export function readAllEntries(dir: string): ParsedEntryFile[] {
let files: string[];
try {
files = walk(dir);
} catch {
return [];
}
return files.flatMap((file) => {
const raw = readFileSync(file, "utf8");
const match = raw.match(FRONTMATTER_RE);
if (!match) return [];
const [, fm, body] = match;
return [
{
slug: slugFromPath(dir, file),
frontmatter: parseToml(fm),
body: body.trim(),
},
];
});
}
function entryPath(
dir: string,
slug: string,
layout: "nested" | "flat",
): string {
return layout === "nested"
? join(dir, slug, "index.md")
: join(dir, `${slug}.md`);
}
/** Writes (or overwrites) the frontmatter+body file for `slug` under `dir`. */
export function writeEntry(
dir: string,
slug: string,
layout: "nested" | "flat",
frontmatter: Record<string, unknown>,
body: string,
): string {
const file = entryPath(dir, slug, layout);
mkdirSync(dirname(file), { recursive: true });
const toml = stringifyToml(compact(frontmatter)).trimEnd();
writeFileSync(file, `+++\n${toml}\n+++\n\n${(body ?? "").trim()}\n`);
return file;
}
/** Deletes the file (and, for nested layout, its now-empty directory) for `slug`. */
export function deleteEntry(
dir: string,
slug: string,
layout: "nested" | "flat",
): string {
const file = entryPath(dir, slug, layout);
rmSync(file, { force: true });
if (layout === "nested")
rmSync(join(dir, slug), { recursive: true, force: true });
return file;
}
+22
View File
@@ -0,0 +1,22 @@
import { isAbsolute, join, resolve } from "node:path";
/**
* Root of the Git checkout that owns content/ and apps/site/public/covers/.
* Production points this at the persistent checkout prepared by the
* container entrypoint; local development falls back to the workspace root.
*/
export function repositoryRoot(): string {
const configured = process.env.CONTENT_REPOSITORY_ROOT;
if (!configured) return resolve(process.cwd(), "..", "..");
return isAbsolute(configured)
? configured
: resolve(process.cwd(), configured);
}
export function contentRoot(): string {
return join(repositoryRoot(), "content");
}
export function coversRoot(): string {
return join(repositoryRoot(), "apps", "site", "public", "covers");
}
+425
View File
@@ -0,0 +1,425 @@
import { rmSync } from "node:fs";
import { join, relative, resolve } from "node:path";
import {
cacheRemoteImage,
fetchBookCover,
fetchPaperMetadata,
} from "./bookshelf-covers";
import {
deleteEntry,
readAllEntries,
toTomlDate,
writeEntry,
} from "./content-file";
import { contentRoot, coversRoot } from "./content-paths";
import { publishContentChanges } from "./git-publisher";
const BOOKSHELF_UID = "api::bookshelf-entry.bookshelf-entry";
type Layout = "nested" | "flat";
interface SyncConfig {
uid: string;
dir: string;
layout: Layout;
/** Strapi entry -> { frontmatter, body } written to disk. */
toFile: (entry: Record<string, unknown>) => {
frontmatter: Record<string, unknown>;
body: string;
};
/** Parsed file -> Strapi entry fields, for importing content/ into Strapi on boot. */
toEntry: (
slug: string,
frontmatter: Record<string, unknown>,
body: string,
) => Record<string, unknown>;
}
const isoDate = (value: unknown): string | undefined =>
value instanceof Date
? value.toISOString().slice(0, 10)
: typeof value === "string"
? value
: undefined;
function buildConfigs(): Record<string, SyncConfig> {
const root = contentRoot();
const writing: SyncConfig = {
uid: "api::writing.writing",
dir: join(root, "writings"),
layout: "nested",
toFile: (e) => ({
frontmatter: {
title: e.title,
description: e.description,
author: e.author,
date: toTomlDate(e.date as string),
updated: toTomlDate(e.updated as string),
draft: e.draft ?? false,
canonical_url: e.canonical_url,
taxonomies: { tags: e.tags ?? [] },
extra: {
series: e.series,
series_index: e.series_index,
llms_description: e.llms_description,
},
},
body: (e.body as string) ?? "",
}),
toEntry: (slug, fm, body) => {
const taxonomies = fm.taxonomies as { tags?: string[] } | undefined;
const extra = fm.extra as
| { series?: string; series_index?: number; llms_description?: string }
| undefined;
return {
slug,
title: fm.title,
description: fm.description,
author: fm.author,
date: isoDate(fm.date),
updated: isoDate(fm.updated),
draft: fm.draft ?? false,
canonical_url: fm.canonical_url,
tags: taxonomies?.tags ?? [],
series: extra?.series,
series_index: extra?.series_index,
llms_description: extra?.llms_description,
body,
};
},
};
const portfolioEntry: SyncConfig = {
uid: "api::portfolio-entry.portfolio-entry",
dir: join(root, "portfolio"),
layout: "flat",
toFile: (e) => ({
frontmatter: {
title: e.title,
description: e.description,
role: e.role,
date_start: toTomlDate(e.date_start as string),
date_end: toTomlDate(e.date_end as string),
outcome: e.outcome,
draft: e.draft ?? false,
links: e.links ?? [],
taxonomies: { tags: e.tags ?? [] },
},
body: (e.body as string) ?? "",
}),
toEntry: (slug, fm, body) => {
const taxonomies = fm.taxonomies as { tags?: string[] } | undefined;
return {
slug,
title: fm.title,
description: fm.description,
role: fm.role,
date_start: isoDate(fm.date_start),
date_end: isoDate(fm.date_end),
outcome: fm.outcome,
draft: fm.draft ?? false,
links: fm.links ?? [],
tags: taxonomies?.tags ?? [],
body,
};
},
};
const bookshelfEntry: SyncConfig = {
uid: "api::bookshelf-entry.bookshelf-entry",
dir: join(root, "bookshelf"),
layout: "flat",
toFile: (e) => ({
frontmatter: {
title: e.title,
kind: e.kind,
authors: e.authors ?? [],
isbn: e.isbn,
doi: e.doi,
arxiv_id: e.arxiv_id,
url: e.url,
cover_image: e.cover_image,
rating: e.rating,
read_status: e.read_status,
date_started: toTomlDate(e.date_started as string),
date_finished: toTomlDate(e.date_finished as string),
taxonomies: { tags: e.tags ?? [] },
},
body: (e.body as string) ?? "",
}),
toEntry: (slug, fm, body) => {
const taxonomies = fm.taxonomies as { tags?: string[] } | undefined;
return {
slug,
title: fm.title,
kind: fm.kind,
authors: fm.authors ?? [],
isbn: fm.isbn,
doi: fm.doi,
arxiv_id: fm.arxiv_id,
url: fm.url,
cover_image: fm.cover_image,
rating: fm.rating,
read_status: fm.read_status,
date_started: isoDate(fm.date_started),
date_finished: isoDate(fm.date_finished),
tags: taxonomies?.tags ?? [],
body,
};
},
};
const curatedBlog: SyncConfig = {
uid: "api::curated-blog.curated-blog",
dir: join(root, "blogs"),
layout: "flat",
toFile: (e) => ({
frontmatter: {
title: e.title,
url: e.url,
feed_url: e.feed_url,
},
body: (e.body as string) ?? "",
}),
toEntry: (slug, fm, body) => ({
slug,
title: fm.title,
url: fm.url,
feed_url: fm.feed_url,
body,
}),
};
return {
[writing.uid]: writing,
[portfolioEntry.uid]: portfolioEntry,
[bookshelfEntry.uid]: bookshelfEntry,
[curatedBlog.uid]: curatedBlog,
};
}
export const SYNC_CONFIGS = buildConfigs();
/** Guards the outbound (DB -> file) middleware while the inbound (file -> DB) import runs. */
let importing = false;
function writeEntryForConfig(
config: SyncConfig,
entry: Record<string, unknown>,
): string | undefined {
const slug = entry.slug as string | undefined;
if (!slug) return undefined;
const { frontmatter, body } = config.toFile(entry);
return writeEntry(config.dir, slug, config.layout, frontmatter, body);
}
function coverFileForEntry(
entry: Record<string, unknown> | null,
): string | undefined {
const coverImage = entry?.cover_image;
if (typeof coverImage !== "string" || !coverImage.startsWith("/covers/")) {
return undefined;
}
const root = resolve(coversRoot());
const file = resolve(root, coverImage.slice("/covers/".length));
const fromRoot = relative(root, file);
if (fromRoot === ".." || fromRoot.startsWith("../")) {
throw new Error(`Refusing unsafe local cover path: ${coverImage}`);
}
return file;
}
/**
* On boot, upserts every file under content/<type>/ into Strapi so the admin
* UI reflects on-disk edits made outside Strapi. Strapi's DB stays a
* disposable cache: this can always rebuild it from content/ alone.
*/
export async function importContentFromDisk(strapi: {
documents: (uid: string) => {
findFirst: (params: {
filters: Record<string, unknown>;
}) => Promise<Record<string, unknown> | null>;
create: (params: { data: Record<string, unknown> }) => Promise<unknown>;
update: (params: {
documentId: string;
data: Record<string, unknown>;
}) => Promise<unknown>;
};
log: { info: (msg: string) => void; error: (msg: string) => void };
}): Promise<void> {
importing = true;
try {
for (const config of Object.values(SYNC_CONFIGS)) {
const files = readAllEntries(config.dir);
let created = 0;
let updated = 0;
for (const { slug, frontmatter, body } of files) {
const data = config.toEntry(slug, frontmatter, body);
try {
const existing = await strapi
.documents(config.uid)
.findFirst({ filters: { slug } });
if (existing) {
await strapi
.documents(config.uid)
.update({ documentId: existing.documentId as string, data });
updated += 1;
} else {
await strapi.documents(config.uid).create({ data });
created += 1;
}
} catch (err) {
strapi.log.error(
`content-sync: failed to import ${config.dir}/${slug}: ${(err as Error).message}`,
);
}
}
strapi.log.info(
`content-sync: imported ${config.uid} (${created} created, ${updated} updated, ${files.length} total)`,
);
}
} finally {
importing = false;
}
}
interface DocumentsClient {
findOne: (params: {
documentId: string;
}) => Promise<Record<string, unknown> | null>;
update: (params: {
documentId: string;
data: Record<string, unknown>;
}) => Promise<Record<string, unknown>>;
}
/**
* Backfills a bookshelf entry's cover — either by caching whatever URL is
* already in `cover_image` (e.g. one pasted in by hand, or an editor's own
* hosted cover) locally, or by looking one up (books via Open Library by
* ISBN) when there's no cover_image at all yet — and, for papers, missing
* title/authors via Semantic Scholar by DOI/arXiv id. Runs once per entry:
* a `cover_image` already pointing at a local /covers/... path is treated
* as "done" and left alone.
*/
async function enrichBookshelfEntry(
documents: (uid: string) => DocumentsClient,
entry: Record<string, unknown>,
): Promise<Record<string, unknown>> {
const slug = entry.slug as string;
const coverImage = entry.cover_image as string | undefined;
const patch: Record<string, unknown> = {};
try {
if (coverImage && /^https?:\/\//i.test(coverImage)) {
const cached = await cacheRemoteImage(coverImage, slug);
if (cached) patch.cover_image = cached;
} else if (!coverImage) {
if (entry.kind === "book" && entry.isbn) {
const cover = await fetchBookCover(entry.isbn as string, slug);
if (cover) patch.cover_image = cover;
} else if (entry.kind === "paper" && (entry.doi || entry.arxiv_id)) {
const meta = await fetchPaperMetadata({
doi: entry.doi as string | undefined,
arxivId: entry.arxiv_id as string | undefined,
});
if (meta?.title && !entry.title) patch.title = meta.title;
if (
meta?.authors?.length &&
!(entry.authors as string[] | undefined)?.length
) {
patch.authors = meta.authors;
}
}
}
} catch {
// Network/API failures shouldn't block saving the entry itself.
return entry;
}
if (Object.keys(patch).length === 0) return entry;
// Keep the nested update outside the network-failure guard. Its middleware
// persists and publishes the enriched entry; publication errors must reach
// the caller instead of being mistaken for a cover-provider outage.
return documents(BOOKSHELF_UID).update({
documentId: entry.documentId as string,
data: patch,
});
}
/** Registers the outbound (DB -> file) sync as a Document Service middleware. */
export function registerContentSync(strapi: {
documents: {
use: (
middleware: (
ctx: { uid: string; action: string; params: Record<string, unknown> },
next: () => Promise<unknown>,
) => Promise<unknown>,
) => void;
(uid: string): DocumentsClient;
};
log: { info: (msg: string) => void; error: (msg: string) => void };
}): void {
strapi.documents.use(async (ctx, next) => {
const config = SYNC_CONFIGS[ctx.uid];
if (!config || importing) return next();
let preDeleteEntry: Record<string, unknown> | null = null;
if (ctx.action === "delete") {
const documentId = (ctx.params as { documentId?: string }).documentId;
if (documentId) {
preDeleteEntry = await strapi
.documents(ctx.uid)
.findOne({ documentId });
}
}
const result = await next();
try {
if (
(ctx.action === "create" || ctx.action === "update") &&
result &&
typeof result === "object"
) {
const entry =
ctx.uid === BOOKSHELF_UID
? await enrichBookshelfEntry(
strapi.documents,
result as Record<string, unknown>,
)
: (result as Record<string, unknown>);
const entryFile = writeEntryForConfig(config, entry);
if (entryFile) {
const paths = [entryFile];
const coverFile = coverFileForEntry(entry);
if (coverFile) paths.push(coverFile);
await publishContentChanges(
paths,
`content: ${ctx.action} ${entry.slug as string}`,
);
}
} else if (ctx.action === "delete" && preDeleteEntry) {
const slug = preDeleteEntry.slug as string | undefined;
if (!slug) return result;
const paths = [deleteEntry(config.dir, slug, config.layout)];
const coverFile = coverFileForEntry(preDeleteEntry);
if (coverFile) {
rmSync(coverFile, { force: true });
paths.push(coverFile);
}
await publishContentChanges(paths, `content: delete ${slug}`);
}
} catch (err) {
strapi.log.error(
`content-sync: failed to write ${ctx.uid} to disk: ${(err as Error).message}`,
);
throw err;
}
return result;
});
}
+186
View File
@@ -0,0 +1,186 @@
import { execFile } from "node:child_process";
import { relative, resolve } from "node:path";
import { promisify } from "node:util";
import { repositoryRoot } from "./content-paths";
const execFileAsync = promisify(execFile);
interface GitPublisherConfig {
worktree: string;
remote?: string;
branch?: string;
token?: string;
commitName?: string;
commitEmail?: string;
deployWebhookUrl?: string;
fetchImpl?: typeof fetch;
}
function gitAuthenticationEnv(token?: string): NodeJS.ProcessEnv {
if (!token) return process.env;
const credentials = Buffer.from(`x-access-token:${token}`).toString("base64");
return {
...process.env,
GIT_CONFIG_COUNT: "1",
GIT_CONFIG_KEY_0: "http.https://github.com/.extraheader",
GIT_CONFIG_VALUE_0: `AUTHORIZATION: basic ${credentials}`,
};
}
export class GitPublisher {
private readonly worktree: string;
private readonly remote: string;
private readonly branch: string;
private readonly token?: string;
private readonly commitName: string;
private readonly commitEmail: string;
private readonly deployWebhookUrl?: string;
private readonly fetchImpl: typeof fetch;
private queue: Promise<void> = Promise.resolve();
private deployPending = false;
constructor(config: GitPublisherConfig) {
this.worktree = resolve(config.worktree);
this.remote = config.remote ?? "origin";
this.branch = config.branch ?? "main";
this.token = config.token;
this.commitName = config.commitName ?? "andwati.com CMS";
this.commitEmail =
config.commitEmail ?? "[email protected]";
this.deployWebhookUrl = config.deployWebhookUrl;
this.fetchImpl = config.fetchImpl ?? fetch;
}
publish(paths: string[], message: string): Promise<void> {
const operation = this.queue.then(() => this.publishNow(paths, message));
this.queue = operation.catch(() => undefined);
return operation;
}
private async git(args: string[]): Promise<string> {
const { stdout } = await execFileAsync("git", args, {
cwd: this.worktree,
env: gitAuthenticationEnv(this.token),
timeout: 30_000,
});
return stdout.trim();
}
private repositoryPaths(paths: string[]): string[] {
return [...new Set(paths)].map((path) => {
const absolute = resolve(path);
const repositoryPath = relative(this.worktree, absolute);
if (
repositoryPath === "" ||
repositoryPath === ".." ||
repositoryPath.startsWith("../")
) {
throw new Error(
`Refusing to publish path outside Git checkout: ${path}`,
);
}
return repositoryPath;
});
}
private async hasStagedChanges(): Promise<boolean> {
try {
await this.git(["diff", "--cached", "--quiet"]);
return false;
} catch (error) {
if ((error as { code?: number }).code === 1) return true;
throw error;
}
}
private async publishNow(paths: string[], message: string): Promise<void> {
const repositoryPaths = this.repositoryPaths(paths);
await this.git(["add", "--all", "--", ...repositoryPaths]);
if (await this.hasStagedChanges()) {
await this.git([
"-c",
`user.name=${this.commitName}`,
"-c",
`user.email=${this.commitEmail}`,
"-c",
"commit.gpgsign=false",
"commit",
"-m",
message,
]);
}
await this.git(["fetch", this.remote, this.branch]);
try {
await this.git([
"rebase",
"--autostash",
`${this.remote}/${this.branch}`,
]);
} catch (error) {
await this.git(["rebase", "--abort"]).catch(() => undefined);
throw error;
}
const ahead = Number(
await this.git([
"rev-list",
"--count",
`${this.remote}/${this.branch}..HEAD`,
]),
);
if (ahead > 0) {
await this.git(["push", this.remote, `HEAD:${this.branch}`]);
this.deployPending = true;
}
if (this.deployPending && this.deployWebhookUrl) {
const response = await this.fetchImpl(this.deployWebhookUrl, {
method: "POST",
signal: AbortSignal.timeout(15_000),
});
if (!response.ok) {
throw new Error(
`Dokploy deploy webhook returned ${response.status} ${response.statusText}`,
);
}
this.deployPending = false;
}
}
}
let publisher: GitPublisher | undefined;
function publisherFromEnvironment(): GitPublisher | undefined {
if (process.env.GIT_PUBLISH_ENABLED !== "true") return undefined;
const token = process.env.GITHUB_TOKEN;
const deployWebhookUrl = process.env.DOKPLOY_DEPLOY_WEBHOOK_URL;
if (!token) throw new Error("GITHUB_TOKEN is required for Git publishing");
if (!deployWebhookUrl) {
throw new Error(
"DOKPLOY_DEPLOY_WEBHOOK_URL is required for Git publishing",
);
}
return new GitPublisher({
worktree: repositoryRoot(),
remote: process.env.CONTENT_GIT_REMOTE ?? "origin",
branch: process.env.CONTENT_GIT_BRANCH ?? "main",
token,
commitName: process.env.CONTENT_GIT_COMMIT_NAME,
commitEmail: process.env.CONTENT_GIT_COMMIT_EMAIL,
deployWebhookUrl,
});
}
/** Publishes changed source files when production Git publishing is enabled. */
export async function publishContentChanges(
paths: string[],
message: string,
): Promise<void> {
publisher ??= publisherFromEnvironment();
await publisher?.publish(paths, message);
}
+125
View File
@@ -0,0 +1,125 @@
import assert from "node:assert/strict";
import { execFileSync } from "node:child_process";
import {
mkdirSync,
mkdtempSync,
readFileSync,
rmSync,
writeFileSync,
} from "node:fs";
import { tmpdir } from "node:os";
import { join } from "node:path";
import test from "node:test";
import { GitPublisher } from "../src/utils/git-publisher.ts";
function git(cwd: string, ...args: string[]): string {
return execFileSync("git", args, { cwd, encoding: "utf8" }).trim();
}
function repositoryFixture(): {
root: string;
worktree: string;
cleanup: () => void;
} {
const root = mkdtempSync(join(tmpdir(), "andwati-git-publisher-"));
const remote = join(root, "remote.git");
const worktree = join(root, "worktree");
git(root, "init", "--bare", remote);
git(root, "clone", remote, worktree);
writeFileSync(join(worktree, "README.md"), "seed\n");
git(worktree, "add", "README.md");
git(
worktree,
"-c",
"user.name=Test",
"-c",
"[email protected]",
"-c",
"commit.gpgsign=false",
"commit",
"-m",
"seed",
);
git(worktree, "branch", "-M", "main");
git(worktree, "push", "-u", "origin", "main");
return {
root,
worktree,
cleanup: () => rmSync(root, { recursive: true, force: true }),
};
}
test("commits, pushes, and deploys a changed source file", async () => {
const fixture = repositoryFixture();
let deploys = 0;
const publisher = new GitPublisher({
worktree: fixture.worktree,
branch: "main",
deployWebhookUrl: "https://deploy.invalid/hook",
fetchImpl: async () => {
deploys += 1;
return new Response(null, { status: 200 });
},
});
try {
const contentFile = join(fixture.worktree, "content", "post.md");
mkdirSync(join(fixture.worktree, "content"));
writeFileSync(contentFile, "published\n", { flag: "w" });
const concurrentFile = join(fixture.worktree, "content", "next.md");
writeFileSync(concurrentFile, "still being edited\n");
await publisher.publish([contentFile], "content: update post");
assert.equal(
git(fixture.worktree, "show", "origin/main:content/post.md"),
"published",
);
assert.equal(deploys, 1);
assert.equal(readFileSync(concurrentFile, "utf8"), "still being edited\n");
assert.match(git(fixture.worktree, "status", "--short"), /next\.md/);
await publisher.publish([contentFile], "content: update post");
assert.equal(deploys, 1, "unchanged content must not trigger a deploy");
} finally {
fixture.cleanup();
}
});
test("retries a pending deploy without creating another commit", async () => {
const fixture = repositoryFixture();
let deploys = 0;
const publisher = new GitPublisher({
worktree: fixture.worktree,
branch: "main",
deployWebhookUrl: "https://deploy.invalid/hook",
fetchImpl: async () => {
deploys += 1;
return new Response(null, {
status: deploys === 1 ? 503 : 200,
statusText: deploys === 1 ? "Unavailable" : "OK",
});
},
});
try {
const contentFile = join(fixture.worktree, "entry.md");
writeFileSync(contentFile, "durable\n");
await assert.rejects(
publisher.publish([contentFile], "content: create entry"),
/Dokploy deploy webhook returned 503/,
);
await publisher.publish([contentFile], "content: create entry");
assert.equal(deploys, 2);
assert.equal(
git(fixture.worktree, "rev-list", "--count", "origin/main"),
"2",
"the webhook retry must not create a duplicate commit",
);
} finally {
fixture.cleanup();
}
});
+44
View File
@@ -0,0 +1,44 @@
{
"compilerOptions": {
"module": "CommonJS",
"moduleResolution": "Node",
"lib": ["ES2020"],
"target": "ES2019",
"strict": true,
"skipLibCheck": true,
"forceConsistentCasingInFileNames": true,
"incremental": true,
"esModuleInterop": true,
"resolveJsonModule": true,
"noEmitOnError": true,
"noImplicitThis": true,
"outDir": "dist",
"rootDir": "."
},
"include": [
// Include root files
"./",
// Include all ts files
"./**/*.ts",
// Include all js files
"./**/*.js",
// Force the JSON files in the src folder to be included
"src/**/*.json"
],
"exclude": [
"node_modules/",
"build/",
"dist/",
".cache/",
".tmp/",
".strapi/",
// Do not include admin files in the server compilation
"src/admin/",
// Do not include test files
"**/*.test.*",
// Do not include plugins in the server compilation
"src/plugins/**"
]
}
+3
View File
@@ -0,0 +1,3 @@
/*
* The app doesn't have any components yet.
*/
File diff suppressed because it is too large Load Diff
+10
View File
@@ -0,0 +1,10 @@
# Self-hosted Umami analytics (https://umami.is/docs/tracker-configuration).
# Leave unset to disable analytics entirely — Analytics.astro no-ops until
# both of these are present. Fill in once the Umami instance is deployed.
PUBLIC_UMAMI_SCRIPT_URL=
PUBLIC_UMAMI_WEBSITE_ID=
# Optional: restrict tracking to these comma-separated domains (Umami's
# data-domains attribute), useful if the same build is deployed to a
# staging domain and you only want production traffic counted.
PUBLIC_UMAMI_DOMAINS=
+27
View File
@@ -0,0 +1,27 @@
# build output
dist/
# generated types
.astro/
# dependencies
node_modules/
# logs
npm-debug.log*
yarn-debug.log*
yarn-error.log*
pnpm-debug.log*
# environment variables
.env
.env.production
# Lighthouse CI report artifacts
.lighthouseci/
# macOS-specific files
.DS_Store
# jetbrains setting folder
.idea/
+4
View File
@@ -0,0 +1,4 @@
{
"recommendations": ["astro-build.astro-vscode"],
"unwantedRecommendations": []
}
+11
View File
@@ -0,0 +1,11 @@
{
"version": "0.2.0",
"configurations": [
{
"command": "./node_modules/.bin/astro dev",
"name": "Development server",
"request": "launch",
"type": "node-terminal"
}
]
}
+22
View File
@@ -0,0 +1,22 @@
## Development
When starting the dev server, use background mode:
```
astro dev --background
```
Manage the background server with `astro dev stop`, `astro dev status`, and `astro dev logs`.
## Documentation
Full documentation: https://docs.astro.build
Consult these guides before working on related tasks:
- [Adding pages, dynamic routes, or middleware](https://docs.astro.build/en/guides/routing/)
- [Working with Astro components](https://docs.astro.build/en/basics/astro-components/)
- [Using React, Vue, Svelte, or other framework components](https://docs.astro.build/en/guides/framework-components/)
- [Adding or managing content](https://docs.astro.build/en/guides/content-collections/)
- [Adding styles or using Tailwind](https://docs.astro.build/en/guides/styling/)
- [Supporting multiple languages](https://docs.astro.build/en/guides/internationalization/)
+1
View File
@@ -0,0 +1 @@
AGENTS.md
+43
View File
@@ -0,0 +1,43 @@
# Astro Starter Kit: Minimal
```sh
npm create astro@latest -- --template minimal
```
> 🧑‍🚀 **Seasoned astronaut?** Delete this file. Have fun!
## 🚀 Project Structure
Inside of your Astro project, you'll see the following folders and files:
```text
/
├── public/
├── src/
│ └── pages/
│ └── index.astro
└── package.json
```
Astro looks for `.astro` or `.md` files in the `src/pages/` directory. Each page is exposed as a route based on its file name.
There's nothing special about `src/components/`, but that's where we like to put any Astro/React/Vue/Svelte/Preact components.
Any static assets, like images, can be placed in the `public/` directory.
## 🧞 Commands
All commands are run from the root of the project, from a terminal:
| Command | Action |
| :------------------------ | :----------------------------------------------- |
| `npm install` | Installs dependencies |
| `npm run dev` | Starts local dev server at `localhost:4321` |
| `npm run build` | Build your production site to `./dist/` |
| `npm run preview` | Preview your build locally, before deploying |
| `npm run astro ...` | Run CLI commands like `astro add`, `astro check` |
| `npm run astro -- --help` | Get help using the Astro CLI |
## 👀 Want to learn more?
Feel free to check [our documentation](https://docs.astro.build) or jump into our [Discord server](https://astro.build/chat).
+27
View File
@@ -0,0 +1,27 @@
// @ts-check
import sitemap from "@astrojs/sitemap";
import { defineConfig } from "astro/config";
import { buildLastmodMap, resolveContentDir } from "./lib-config/sitemap-lastmod.mjs";
import { siteConfig } from "./src/site.config.ts";
const lastmodMap = buildLastmodMap(resolveContentDir(import.meta.url));
// https://astro.build/config
export default defineConfig({
site: siteConfig.baseUrl,
integrations: [
sitemap({
// Every build re-crawls the actual rendered output, so the sitemap's
// URL list always matches whatever pages/content exist right now — no
// separate "regenerate the sitemap" step needed when content changes.
// On top of that, <lastmod> is set from each entry's own
// date/updated field (falling back to build time when unmapped),
// instead of every URL just getting "whenever the site was last
// built".
serialize(item) {
const lastmod = lastmodMap.get(new URL(item.url).pathname);
return lastmod ? { ...item, lastmod } : item;
},
}),
],
});
+65
View File
@@ -0,0 +1,65 @@
// Builds a Map<pathname, ISO date string> from content/ frontmatter so the
// sitemap's <lastmod> reflects real edit dates instead of just "whenever the
// site was last built". Runs from astro.config.mjs, which executes as plain
// Node outside Vite/Astro's content-layer pipeline (same reason
// scripts/generate-llms.astro-site.mjs parses frontmatter by hand instead of
// importing astro:content) — duplicated here rather than shared because the
// two scripts' output shapes don't overlap enough to be worth a shared module.
import fs from "node:fs";
import path from "node:path";
import { fileURLToPath } from "node:url";
import { parse as parseToml } from "smol-toml";
const FRONTMATTER_RE = /^\+\+\+\r?\n([\s\S]*?)\r?\n\+\+\+\r?\n?([\s\S]*)$/;
function walk(dir) {
if (!fs.existsSync(dir)) return [];
return fs.readdirSync(dir, { withFileTypes: true }).flatMap((entry) => {
const full = path.join(dir, entry.name);
if (entry.isDirectory()) return walk(full);
return entry.isFile() && entry.name.endsWith(".md") ? [full] : [];
});
}
function slugFor(dir, file) {
const rel = path.relative(dir, file).split(path.sep).join("/");
return rel.endsWith("/index.md") ? rel.slice(0, -"/index.md".length) : rel.slice(0, -".md".length);
}
function readEntries(typeDir) {
return walk(typeDir)
.map((file) => {
const raw = fs.readFileSync(file, "utf8");
const match = raw.match(FRONTMATTER_RE);
if (!match) return null;
return { slug: slugFor(typeDir, file), frontmatter: parseToml(match[1]) };
})
.filter(Boolean);
}
function isoOrNull(value) {
if (!value) return null;
const d = value instanceof Date ? value : new Date(value);
return Number.isNaN(d.valueOf()) ? null : d.toISOString();
}
export function buildLastmodMap(contentDir) {
const map = new Map();
for (const entry of readEntries(path.join(contentDir, "writings"))) {
if (entry.frontmatter.draft) continue;
const lastmod = isoOrNull(entry.frontmatter.updated ?? entry.frontmatter.date);
if (lastmod) map.set(`/writings/${entry.slug}/`, lastmod);
}
for (const entry of readEntries(path.join(contentDir, "bookshelf"))) {
const lastmod = isoOrNull(entry.frontmatter.date_finished ?? entry.frontmatter.date_started);
if (lastmod) map.set(`/bookshelf/${entry.slug}/`, lastmod);
}
return map;
}
export function resolveContentDir(configFileUrl) {
return path.join(path.dirname(fileURLToPath(configFileUrl)), "..", "..", "content");
}
+32
View File
@@ -0,0 +1,32 @@
/**
* Lighthouse CI — runs the same audits PageSpeed Insights reports on
* (performance, accessibility, best-practices, SEO) against the built site,
* so results can be tracked over time / per-commit without depending on the
* hosted PSI API. `staticDistDir` lets lhci serve `dist/` itself, no dev
* server juggling needed. Reporting only for now — no `assert` thresholds,
* so a regression shows up in the report without failing the build.
*/
module.exports = {
ci: {
collect: {
staticDistDir: "./dist",
url: [
"/index.html",
"/writings/index.html",
"/writings/from-c-to-machine-code/index.html",
"/archive/index.html",
"/portfolio/index.html",
"/bookshelf/index.html",
"/tags/index.html",
"/about/index.html",
],
numberOfRuns: 3,
},
upload: {
// Free, no account/server needed — each run prints a shareable report
// link (expires after 7 days). Point this at a real LHCI server later
// if longer-lived historical tracking is wanted.
target: "temporary-public-storage",
},
},
};
+15
View File
@@ -0,0 +1,15 @@
// Desktop form-factor pass — same pages/config as lighthouserc.cjs, but
// Lighthouse's default (and PageSpeed Insights' default) is mobile: throttled
// CPU/network and a narrow viewport. Auditing desktop too means neither form
// factor's numbers are a blind spot.
const mobileConfig = require("./lighthouserc.cjs");
module.exports = {
ci: {
...mobileConfig.ci,
collect: {
...mobileConfig.ci.collect,
settings: { preset: "desktop" },
},
},
};
+42
View File
@@ -0,0 +1,42 @@
{
"name": "site",
"type": "module",
"version": "0.0.1",
"engines": {
"node": ">=22.12.0"
},
"scripts": {
"dev": "astro dev",
"generate:llms": "node ../../scripts/generate-llms.astro-site.mjs",
"generate:og": "node ../../scripts/generate-og-images.astro-site.mjs",
"build": "pnpm run generate:llms && pnpm run generate:og && astro build && pagefind --site dist",
"preview": "astro preview",
"check": "astro check",
"astro": "astro",
"lighthouse": "pnpm run build && lhci autorun && lhci autorun --config=lighthouserc.desktop.cjs"
},
"dependencies": {
"@astrojs/rss": "^4.0.19",
"@astrojs/sitemap": "^3.7.3",
"@fontsource-variable/fraunces": "^5.3.0",
"@fontsource-variable/newsreader": "^5.3.0",
"@fontsource/jetbrains-mono": "^5.3.0",
"astro": "^7.2.9",
"highlight.js": "^11.12.0",
"katex": "^0.18.7",
"marked": "^18.0.11",
"marked-katex-extension": "^5.1.12",
"pagefind": "^1.5.2",
"smol-toml": "^1.8.0",
"three": "^0.185.1"
},
"allowScripts": {
"esbuild": true
},
"devDependencies": {
"@astrojs/check": "^0.9.10",
"@lhci/cli": "^0.15.1",
"@types/three": "^0.185.4",
"typescript": "^5.9.3"
}
}
Binary file not shown.

After

Width:  |  Height:  |  Size: 23 KiB

Before

Width:  |  Height:  |  Size: 2.9 KiB

After

Width:  |  Height:  |  Size: 2.9 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 31 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 40 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 39 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 24 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 25 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 25 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 37 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 30 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 21 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 25 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 39 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 29 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 35 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 26 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 36 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 29 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 39 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 38 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 31 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 40 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 23 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 37 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 22 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 40 KiB

+54
View File
@@ -0,0 +1,54 @@
# Thoughts and Musings
> Ian Andwati's personal digital-legacy site covering computer science and mathematics — writings, portfolio, and a curated bookshelf.
## Main pages
- [Home](https://andwati.com/): Introduction and latest writings.
- [Writings](https://andwati.com/writings/): Long-form essays and technical posts.
- [Portfolio](https://andwati.com/portfolio/): Projects and professional work.
- [Bookshelf](https://andwati.com/bookshelf/): Books and papers read, with notes.
- [Blogs](https://andwati.com/blogs/): Curated external blogs.
- [Tags](https://andwati.com/tags/): Writings grouped by topic.
## Writings
- [What is pwn?](https://andwati.com/writings/what-is-pwn/): No not that one
- [Export default and module.exports in Javascript](https://andwati.com/writings/javascript-exporting-modules/): Modules are the building blocks of modern JavaScript applications, allowing for the encapsulation of code into reusable components. Two primary systems govern the exportation of these modules: export default in ES6 and module.exports in CommonJS.
- [cognitive inertia](https://andwati.com/writings/inertia/): inertia
- [Self-Hosting Gitea and Automating GitHub Mirrors](https://andwati.com/writings/self-hosting-gitea-and-mirroring-github/): Self-Hosting Gitea and Automating GitHub Mirrors (and Everything Cloudflare Broke Along the Way)
- [From C to Machine Code: What Actually Runs](https://andwati.com/writings/from-c-to-machine-code/): Before diving into how to break a program, we need to understand what the computer is actually executing
- [Semantic Versioning(SemVer) and Conventional Commits](https://andwati.com/writings/semantic-version-and-conventional-commits/): Adopting best practices in your git and build workflows.
- [Integrating Netlify CMS](https://andwati.com/writings/integrating-netlify/): Integrating netlify cms
- [Install PostgreSQL on Arch | Manjaro | Garuda Linux](https://andwati.com/writings/install-postgresql-15-on-arch-manjaro-garuda-linux/): Getting PostgreSQL up and running on Arch Linux-based distros
- [A Fresh Coat Of Paint](https://andwati.com/writings/a-fresh-coat-of-paint/): Migrating to Zola
- [What say you?](https://andwati.com/writings/utterances-comments/): Adding a comment system by utterances
- [The One Thing Nobody Tells You About WiFi Hotspots on Arch Linux](https://andwati.com/writings/wifi-hotspot-arch/): A minimal Arch install is great until NetworkManager silently fails to find a binary you didn't know you needed
- [Setting up Obsidian sync in Windows](https://andwati.com/writings/obsidian-sync/): Syncing Obsidian in Windows with Windows Task Scheduler with git
- [The Single Byte That Kills Your Exploit: Understanding Endianness](https://andwati.com/writings/the-single-byte-that-kills-your-exploit-understanding-endianness/): Problem: Computers speak different languages, like people
- [mmuctf 2025 writeup](https://andwati.com/writings/mmuctf2025/): mmuctf 2025 challenges writeup
- [Configure Vue.js app With Tailwind CSS](https://andwati.com/writings/vuejs-tailwind/): Tailwindcss in vuejs
- [Subresource Integrity](https://andwati.com/writings/subresource-integrity/): Understanding Subresource Integrity
- [ScriptCTF 2025 Writeup](https://andwati.com/writings/scriptctf2025/): ScriptCTF 2025 Writeup
- [Django Secret Key Tutorial](https://andwati.com/writings/generate-django-secret-key/): Managing secrets
- [Hello World](https://andwati.com/writings/hello-world/): Hello world
## Portfolio
- [Example Project](https://andwati.com/portfolio/): Established the portfolio content schema.
## Bookshelf
- [Attention Is All You Need](https://andwati.com/bookshelf/): Ashish Vaswani, Noam Shazeer, Niki Parmar
- [Designing Data-Intensive Applications, 2nd Edition](https://andwati.com/bookshelf/): Martin Kleppmann, Chris Riccomini
- [Structure and Interpretation of Computer Programs](https://andwati.com/bookshelf/): Harold Abelson, Gerald Jay Sussman
## Blogs followed
- [Example Blog](https://example.com)
## Feeds and discovery
- [RSS feed](https://andwati.com/rss.xml): Full writings feed in RSS format.
- [JSON feed](https://andwati.com/feed.json): Full writings feed in JSON Feed format.
- [Sitemap](https://andwati.com/sitemap-index.xml): Machine-readable index of public pages.
+45
View File
@@ -0,0 +1,45 @@
User-agent: *
Allow: /
# AI assistants / agents / LLM training crawlers — explicitly welcome.
# See /llms.txt for a crawler-friendly summary of this site's content.
User-agent: GPTBot
Allow: /
User-agent: ChatGPT-User
Allow: /
User-agent: OAI-SearchBot
Allow: /
User-agent: ClaudeBot
Allow: /
User-agent: Claude-Web
Allow: /
User-agent: anthropic-ai
Allow: /
User-agent: PerplexityBot
Allow: /
User-agent: Perplexity-User
Allow: /
User-agent: Google-Extended
Allow: /
User-agent: Applebot-Extended
Allow: /
User-agent: CCBot
Allow: /
User-agent: Bytespider
Allow: /
User-agent: Amazonbot
Allow: /
Sitemap: https://andwati.com/sitemap-index.xml
+21
View File
@@ -0,0 +1,21 @@
---
/**
* Self-hosted Umami analytics. No-ops until PUBLIC_UMAMI_SCRIPT_URL and
* PUBLIC_UMAMI_WEBSITE_ID are set (see .env.example) — fill those in once
* the Umami instance is deployed, no code changes needed.
*/
const scriptUrl = import.meta.env.PUBLIC_UMAMI_SCRIPT_URL;
const websiteId = import.meta.env.PUBLIC_UMAMI_WEBSITE_ID;
const domains = import.meta.env.PUBLIC_UMAMI_DOMAINS;
---
{
scriptUrl && websiteId && (
<script
defer
src={scriptUrl}
data-website-id={websiteId}
data-domains={domains || undefined}
/>
)
}
+151
View File
@@ -0,0 +1,151 @@
---
import { coverExists } from "../lib/cover-exists";
import { spineStyle } from "../lib/spine-color";
interface Item {
slug: string;
title: string;
coverImage?: string;
}
interface Props {
items: Item[];
activeSlug?: string;
}
const { items, activeSlug } = Astro.props;
---
<div class="spines" data-spines>
<div class="spines-track" data-track>
{
items.map((item) => {
const active = item.slug === activeSlug;
const style = spineStyle(item.title);
return (
<a
href={`/bookshelf/${item.slug}/`}
class:list={["spine", { "spine--active": active }]}
style={active ? undefined : { background: style.background, color: style.color, width: `${style.width}px` }}
title={item.title}
aria-current={active ? "page" : undefined}
>
{active && coverExists(item.coverImage) ? (
<img src={item.coverImage} alt={item.title} loading="lazy" />
) : active ? (
<span class="spine-cover-fallback" style={{ background: style.background, color: style.color }}>
{item.title}
</span>
) : (
<span>{item.title}</span>
)}
</a>
);
})
}
</div>
<button type="button" class="spines-next" data-next aria-label="Scroll shelf right">→</button>
</div>
<style>
.spines {
position: relative;
margin-bottom: var(--space-6);
padding-bottom: var(--space-4);
border-bottom: 1px solid var(--border);
}
.spines-track {
display: flex;
align-items: flex-end;
gap: 3px;
overflow-x: auto;
scroll-behavior: smooth;
padding-bottom: var(--space-2);
scrollbar-width: thin;
}
.spine {
flex-shrink: 0;
height: 168px;
border-radius: 3px 3px 0 0;
text-decoration: none;
writing-mode: vertical-rl;
text-orientation: mixed;
display: flex;
align-items: center;
justify-content: flex-end;
padding: var(--space-3) 0;
font-family: var(--font-mono);
font-size: 0.7rem;
letter-spacing: 0.02em;
transition:
transform 0.2s var(--ease-out),
box-shadow 0.2s var(--ease-out);
overflow: hidden;
box-shadow: -2px 0 4px rgba(0, 0, 0, 0.15);
}
.spine span {
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
max-height: 100%;
}
.spine:hover {
transform: translateY(-8px);
}
.spine--active {
width: 112px;
padding: 0;
writing-mode: initial;
}
.spine--active img {
width: 100%;
height: 100%;
object-fit: cover;
border-radius: 3px 3px 0 0;
}
.spine-cover-fallback {
width: 100%;
height: 100%;
display: flex;
align-items: center;
justify-content: center;
text-align: center;
padding: var(--space-3);
font-family: var(--font-display);
font-style: italic;
writing-mode: initial;
}
.spines-next {
position: absolute;
right: 0;
top: 76px;
background: var(--bg);
border: 1px solid var(--border);
border-radius: 50%;
width: 2rem;
height: 2rem;
line-height: 1;
cursor: pointer;
color: var(--text-muted);
font-family: var(--font-mono);
}
.spines-next:hover {
color: var(--accent);
border-color: var(--accent);
}
</style>
<script>
// astro:page-load fires on first load and after every client-side
// navigation (bookshelf index <-> detail pages both render this
// component), so the button keeps working once view transitions swap it in.
document.addEventListener("astro:page-load", () => {
document.querySelectorAll<HTMLElement>("[data-spines]").forEach((root) => {
const track = root.querySelector<HTMLElement>("[data-track]");
const next = root.querySelector<HTMLButtonElement>("[data-next]");
next?.addEventListener("click", () => {
track?.scrollBy({ left: 320, behavior: "smooth" });
});
});
});
</script>

Some files were not shown because too many files have changed in this diff Show More