Merge pull request #4 from andwati/astro-migration

Astro migration: nav/layout polish, SEO, a11y, LaTeX, Lighthouse CI
This commit is contained in:
2026-09-12 20:44:43 +03:00
committed by GitHub
292 changed files with 30400 additions and 211540 deletions
+7 -2
View File
@@ -1,7 +1,12 @@
public /public
node_modules **/node_modules
.git .git
manimations/*.* manimations/*.*
/zola /zola
/zola-v*.tar.gz /zola-v*.tar.gz
/artifacts /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 node_modules
manimations/*.* manimations/*.*
/zola /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. 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 ## 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. `.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 ## 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, # Production image for the Astro site. Build from the workspace root:
# then serve the static output with static-web-server. # docker build -t andwati-site .
#
# This is the Dokploy/self-hosted deployment path (the only one - the prior
# Cloudflare Pages/Workers path has been retired).
FROM node:22-slim AS llms FROM node:24-bookworm-slim AS build
WORKDIR /project RUN corepack enable
COPY . . WORKDIR /app
RUN node scripts/generate-llms.mjs
FROM ghcr.io/getzola/zola:v0.23.1 AS zola COPY pnpm-workspace.yaml pnpm-lock.yaml package.json ./
WORKDIR /project COPY apps/site/package.json apps/site/package.json
COPY . . COPY apps/cms/package.json apps/cms/package.json
COPY --from=llms /project/static/llms.txt static/llms.txt COPY apps/site apps/site
RUN ["/zola", "build"] 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 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 COPY sws.toml /sws.toml
EXPOSE 80 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