This website
How this site is built, and what is in the repository that produces it.
Repository
This site is a static, four-language portfolio built with Astro, plus a Strapi
CMS that feeds it content. Everything you can see is produced from one
repository, and the tree on the left is that repository — generated from git ls-files, so it lists exactly what is committed and nothing that is not.
Two apps, one workspace
It is a pnpm + Turbo monorepo with two applications, three shared config packages and one asset generator:
apps/web— the Astro 7 / React 19 / Tailwind v4 site you are reading.apps/admin— a Strapi 5 CMS (pinned at 5.52.3, on Postgres).packages/typescript-config,packages/eslint-configandpackages/tailwind-config— shared TypeScript, ESLint and design-token foundations.packages/logo-assets— draws the site's brand mark as SVG and PNG. It reads its colours out ofpackages/tailwind-config/theme.cssrather than holding any of its own, and nothing in the build depends on it.
The two apps deliberately share nothing but data. apps/admin runs its own
React 18 admin panel, its own tsconfig.json and its own Prettier config; it
does not import components or types from apps/web, and it does not use web's
$/* path alias. The only contract between them is a generated one: pnpm --filter admin generate reads the schema from a running Strapi and writes a
typed client into apps/web/src/.generated/strapi-client/, which web imports
through the $strapi alias. Change a content type, regenerate, and the type
errors tell you what to fix. Nothing else crosses the line.
Turbo's job is deliberately small. turbo.json declares six tasks — build,
lint, lint:fix, typecheck, test, dev — which mostly just order
themselves behind their workspace dependencies (dependsOn: ["^build"] and
friends). dev is marked cache: false, persistent: true. What each task
hashes is the part with thought in it, and the web app's share of that lives
in apps/web/turbo.json: the environment variables that change its output, and
the "cache": false on astro check. Turbo still knows nothing about Astro or
Strapi; it schedules and caches, and each app owns its own scripts.
Static by construction
apps/web/astro.config.mjs configures no adapter. That single omission is the
decision that shapes everything else: the output is prerendered HTML, synced
to S3 and served through CloudFront. There is no server at request time, so
every data read happens at build time — now through a sync phase that
runs once, before any page renders: content-collection loaders call the
generated client, the client calls Strapi for all four locales, and the
results land in Astro's content store. Pages never fetch anything themselves;
they read that store, and the answer still ends up baked into the HTML.
The consequences are the interesting part. Content edits are not live — they
need a rebuild, which is why the CMS grew a Deploy button. Secrets never reach
a browser, because the only process that holds them is the build. And the
config is careful about that boundary: CDN_URL and IMAGE_DOMAINS are read
through Vite's loadEnv and both are optional, while STRAPI_BASE_URL,
STRAPI_API_TOKEN and SITE_URL are declared in Astro's env.schema under
context: 'server', the token additionally as access: 'secret'. One escape
hatch was closed and another deliberately left open: the config no longer
throws without CDN_URL, so the test suite needs no credentials at all,
but both astro check and a real build still need a reachable CMS, and CI
supplies real credentials from a secret rather than degrading to an empty
content store.
Two things do talk to the CMS from a browser, and they are the exception that
sharpens the rule. POST /api/contact carries the home page's contact form,
and — since sign-in landed — the session endpoints under /api/auth/* plus
/api/users/me answer the /auth/* pages and the header. Neither can hold a
secret, because a statically built page has nowhere to keep one: the contact
route is public and defended by a honeypot and a rate limiter instead of a
token, and the session's credential is an httpOnly cookie on the CMS origin
that no script can read, traded for a ten-minute access token that lives in a
module-scoped variable and is written to no storage at all. So the site now
carries a session no build has ever seen. The header island asks who you are
after the page has loaded, and renders nothing whatsoever when the answer is
nobody — which is the answer for very nearly every visitor.
The CMS also does something no build and no browser is party to: it sends
mail. A contact submission now leaves two messages behind — the operator's
notification, and an acknowledgement to the visitor in whichever of the four
languages they wrote in. Those, a newsletter and the two auth emails are ten
templates in all, and every one of them is written by hand. A generator at
apps/admin/scripts/email-templates/ used to compose all ten from one shell,
so a password reset and an auto-reply would look like the same site; it is
gone, with its committed artifacts, because a generated copy of what the CMS
already holds is a second source of truth and drifts from it. They reach
Strapi by two different paths because they are two different kinds of thing:
the eight localized ones are rows in the Email Designer plugin, written in its
Custom tab, while the two auth emails are not rows at all, and are pasted by
hand into the plugin store through Strapi's own Settings screen — the plugin's
own editor has no way to keep hand-written markup, since saving there always
re-exports the Unlayer document over it.
apps/admin/src/lib/email-templates.ts is what survived, and it is the one
file that now has to be kept in step with the CMS by hand: it holds a
reference id per locale, and a template created in the plugin without its
matching templateReferenceId fails silently — an unknown reference id logs a
line and returns, and the visitor simply never hears back. Guarding that is
also what finally put a test runner in apps/admin: a Vitest suite under
apps/admin/tests/, where there had been none at all.
From push to CDN
flowchart TB renovate["Renovate, Mondays"] --> pr["Pull request"] pr --> checks["Format, lint, typecheck, test, build web"] checks --> push["Push to master"] push --> detect["Detect changed paths"] detect --> cms["Build Strapi, sync, restart"] cms --> web["Build Astro, sync to S3"] web --> cdn["Invalidate the CDN"] widget["Strapi publish widget"] --> web
Checks and deploys are separate workflows, which is the single decision the
rest of the pipeline's simplicity rests on. .github/workflows/ci.yml runs on
pull requests and holds one job, Checks: a repo-tree freshness gate,
format:check, turbo run lint typecheck test --continue, then the web
build. .github/workflows/deploy.yml runs on pushes to master, resolves
deploy targets and calls cd-cms.yml then cd-web.yml. Neither does the
other's work, so each has one trigger, and the aggregate gate, the per-scope
reusable workflows and most of the skip-propagation conditions that used to
live here are gone.
ci.yml has no push trigger on purpose. pull_request checks
refs/pull/N/merge — the head merged into the base — so if the base has not
moved, the tree that lands on master is the tree already checked. Re-running
it there proves nothing, and the guarantee that the base cannot move under a
stale pull request is a branch-protection setting (require branches to be up
to date, or a merge queue), not something the YAML can express. deploy.yml
therefore runs no checks at all.
Only the CMS deploy is path-filtered, through dorny/paths-filter, because it
is a ~1 GB upload and a Strapi restart. The web deploy runs on every push, and
that is not laziness: /about/this-website publishes a tree built from git ls-files at the repository root, so a commit touching only docs/ changes a
published page. Filtering it on apps/web/** silently shipped a stale tree.
The filter has one hole worth knowing about — workflow_dispatch has no diff
base, so it would compare the branch against itself and skip everything,
turning a manual redeploy into a silent no-op. The filter step is therefore
skipped on dispatch and a resolve step forces the CMS target on, which makes
"dispatch Deploy on master" the redeploy-everything button.
CMS goes before web because prerendered pages bake Strapi responses into the
static output: a content change without a web rebuild leaves the live site
stale. The web job waits for the CMS job at success or skipped, and that
is the one skip-propagation condition left in the pipeline — !cancelled() is
what lets a job observe a skipped predecessor instead of being skipped with
it, at the cost of restating the gates it discards. The web deploy then
uploads in three passes — immutable hashed assets, then pages with --delete,
then a prune of stale assets — invalidates CloudFront, and requests two routes
through the distribution, because getting bytes into a bucket is not the same
as the bucket being served.
The last arrow into the web deploy comes from Strapi itself. A local plugin at
apps/admin/src/plugins/web-deploy/ adds a homepage widget and a sidebar page
that dispatch cd-web.yml through the GitHub REST API with a random
dispatch-id. cd-web.yml echoes that id into its run-name, and the plugin
finds the run by that token — deterministic correlation instead of guessing
inside a timestamp window.
A fifth workflow belongs to neither half. renovate.yml runs self-hosted on a
Monday cron and is the only one that writes to the repository rather than
deploying from it, which is why its token sits on a renovate environment
restricted to master rather than among the repository secrets: it holds
Workflows write, so it could rewrite the very files that carry the deploy SSH
key. It opens pull requests, so its output re-enters through ci.yml like any
other change. One convention it imposes reaches every workflow here — a
third-party action pinned to a bare commit SHA is invisible to it, so each pin
carries its version as a trailing # vX.Y.Z comment, and deleting one stops
that action being watched without saying so.
What a build actually does
flowchart TD loader["Content loaders"] --> client["Generated Strapi client"] client --> strapi["Strapi"] loader --> store["Content store"] store --> data["Feature data layer"] page["Astro page"] --> routes["Locale and route resolution"] page --> i18n["Translations"] page --> data page --> html["Prerendered HTML"] html --> islands["Hydrated islands"]
Rendering a page is mostly resolution. The route determines the locale, the
locale selects a translation bundle, and feature modules read whatever content
the page needs from Astro's content store — the fetching already happened,
once per build, in the sync phase above. Where a feature under src/features/
has content to read, it keeps that split explicit, and the file names are the
roles: repository.ts is the fetch layer a content loader calls, the validity
predicate travels with the collection in content.config.ts, and service.ts
reads the result back out through getCollection or getEntry, so a page
never talks to Strapi itself. contact skips the layer entirely; search
keeps a service.ts but needs no repository of its own, because it reads
through four other features.
This page is a mix of both halves. The tree shape comes from a generated JSON
artifact, while its path notes and this overview come from the localized
File Annotation content type in Strapi. The contribution graph below it
reads Strapi too, but not directly at render time:
about/contributions/repository.ts — the only repository in the codebase
that uses a plain fetch rather than the generated client, because its
endpoint is a custom Strapi route and not a content type — runs inside a
loader during the sync phase, and the graph's service.ts reads the result
back out of the content store. That loader is tolerant: an unreachable endpoint or a payload that
fails its schema writes no entry at all, so the graph disappears rather than
failing the build. What falls out the far end is HTML either way.
Interactivity is opt-in. The rule the codebase follows is that Astro
components own layout and anything decidable at build time, and React .tsx
files appear only where the UI is genuinely interactive — the search palette,
the menus, the carousels. Everything else ships as HTML with no JavaScript
attached at all. Behaviour that needs the DOM but no framework — the
carousels, the two page filters, the physics field under the skills — is a
Controller: a class that owns one root element, registers its listeners
through a helper that remembers how to undo them, and is mounted by the
lifecycle manager, so a view transition tears it down and rebuilds it instead
of leaking it. The search palette hydrates with client:idle and does not
even fetch its index until you open it.
Timing used to be the one part that was not pure resolution: getSearchIndex
memoized a promise per locale so that four renders — one per locale — would
not each refetch every collection from Strapi. Now that search reads the
content store instead of fetching, there is nothing left to refetch — the sync
phase already ran once for all four locales before any page rendered — so the
per-locale memoization was deleted outright.
Four layers under src/
apps/web/src/ is four layers, and the direction between them is the point.
core/ is the machinery nothing renders: the lifecycle manager, the
Controller base class, the Strapi client with its paginating repository, and
the content-collection loaders and readers. design-system/ is presentation
with no domain knowledge — primitives/ for the atoms and their styling
recipes, patterns/ for the composed pieces that still do not know what a
project is. shell/ is the chrome every page wears: header, footer, section
layout, loading indicator, SEO and analytics. features/ is everything that
knows the domain.
A feature may reach into any of the other three; none of them may reach back
into a feature, and one feature reaches another only through its service.ts.
Inside a feature the file names are the roles, and that is what stops
placement being a question: a module that fetches from Strapi has exactly one
place to live, and a file that is hard to name is usually a file doing two
things. The tree used to carry components/, utils/, constants/ and
common/ directories that could answer neither question. It no longer has any
of them.
Four languages, one page directory
Localized routing is split deliberately. The manifest in
src/i18n/route-manifest.ts produces Paraglide's translated URL patterns and
feeds the in-repo localized-static-routes Astro integration. During
astro:config:setup that integration walks src/pages/, validates the
manifest, and calls injectRoute for every non-default locale. English stays
unprefixed; the others receive a /{locale} prefix and, where declared, a
translated slug. That is why this page exists at both /about/this-website
and /de/ueber-mich/diese-website.
Paraglide owns URL reversal and message compilation. The typed helpers in
src/i18n/routes.ts delegate to it for localeFromUrl, localizedHref and
alternateHrefs. .astro files, and the .ts modules only they reach, read
messages through useMessages(locale) — a per-locale proxy over the generated
barrel in src/i18n/messages.ts, indexed by the catalogue's own keys.
Anything Astro can ship to the browser — React islands, modules reached only
through an Astro <script> block, and everything those import — still imports
the exact generated message functions it needs and passes the locale
explicitly, so the catalogue stays tree-shaken; a dedicated test fails the
build if a client-reachable module imports the barrel instead. There is no
middleware, provider, mutable build-time locale, or client catalog payload.
There is a footgun here worth stating plainly, because it fails quietly.
localizedHref always prefixes non-default locales, manifest entry or not —
an unmapped route falls straight through to /{locale}{route}. The map entry
is not what gets you the prefix; it is what gets you a translated slug. So
localizedHref('/about', { locale: 'ar' }) without an entry returns
/ar/about: correctly prefixed, a perfectly working link, English slug.
Forgetting an entry does not produce a broken link, it produces a subtly wrong
one, and that is far easier to miss in review.
The 532 ms of light
Dark mode used to be class-based — @custom-variant dark matching a .dark
class that JavaScript applied on DOMContentLoaded. Because the CSS carried
no prefers-color-scheme fallback, a visitor whose system was set to dark was
served the light palette and saw it, for a measured 532 ms, before the class
landed.
The fix was to delete code. the shared theme in packages/tailwind-config/theme.css now defines the light
palette on :root and overrides it inside a prefers-color-scheme: dark
media query — which is exactly what Tailwind v4's built-in dark: variant
resolves to, so there is no @custom-variant override at all and every
dark: utility keeps working unchanged. A media query resolves before first
paint, so the flash cannot happen. :root also declares color-scheme: light dark, which puts native scrollbars, form controls and the pre-paint canvas
colour on the system setting too; the class-based version left all of those
stuck on light regardless.
A related decision sits a few lines above, as a comment where a token used to
be. An --animate-fade-in entrance animation used both fill mode, so every
wrapper it touched held opacity: 0 until its animation started — including
the hero heading and both profile columns. That made the LCP element unstable:
it landed on the nav logo, a paragraph, or the profile image depending on the
run, and cost roughly 100 ms of LCP. The token is gone and the comment
explains why, so nobody adds it back. Content should just be present.
One import for icons
Every component imports Icon from $/design-system/primitives/Icon.astro,
never from
astro-icon/components directly, and that wrapper does exactly one thing: it
forces is:inline.
Without it, astro-icon dedupes sprites by giving the first render of an icon
name the <symbol> definition and every later render a bare <use href="#...">. Document order decides who is first — and in this app, that can
be markup inside an astro-island slot, which Astro ships in an inert
<template data-astro-template> until hydration. The symbol then never enters
the live DOM, and every <use> of that name renders nothing. When it
happened, it took out all 28 arrow-right icons plus the footer's GitHub and
link icons on a desktop viewport. Mixed usage cannot rescue it either, because
inline renders still advance astro-icon's per-name counter, so inlining
everywhere is the only consistent policy. A companion test,
tests/repo-guards.test.ts, verifies that every icon name used actually
exists in the installed icon set.
The endpoint that hides from the router
Search is a static JSON file per locale. src/features/search/ assembles a
SearchDoc[] at build time from projects, blogs, timeline entries, tags and
hand-authored page docs, and the endpoint at
src/pages/search-index/[locale].json.ts emits /search-index/{locale}.json.
That file extension is load-bearing. The static-route loader only walks
.astro files, so a .ts endpoint is invisible to it: the integration never
injects locale-prefixed variants, and the locale paths the endpoint produces
stay the literal strings written in the file. Making it an .astro route
would have handed the integration an endpoint to localize, which is not what a
per-locale JSON index wants.
This page's own tree
The tree on the left is not read from disk when you load the page — there is
no request-time anything. apps/web/scripts/generate-repo-tree.mjs shells out
to git ls-files and folds the result into nested JSON at
src/.generated/repo-tree.json. That file is committed, and not because its
content demands it: it is a pure function of the tracked file list, so the
working tree already determines it completely. It is committed so that
Turborepo hashes it — the tree comes from git ls-files at the repo root
while turbo hashes inputs per package, so a file added under apps/admin/
used to change this page without changing the web build's hash, and a cache
hit then served the stale tree. The gen:tree step runs ahead of dev,
build, test and typecheck, so it is always current by the time anything
reads it, and CI fails the pull request once the committed copy has drifted.
Delegating to git rather than walking the filesystem means .gitignore alone
decides what is public, with no second ignore-matching implementation to keep
in sync. Untracked scratch under draft/ and temp/ simply never appears.
Sorting goes through a fixed Intl.Collator('en') so the output cannot vary
by machine, and the artifact carries nothing but a file count, a directory
count and the nested children. An earlier version also stamped the HEAD SHA
into it, which is exactly the field a generated-and-committed artifact cannot
hold: writing the file advances HEAD, so the stamp is stale the moment it
lands. Dropping the stamp and untracking the artifact were the same fix; the
artifact has since come back under tracking, but the stamp has not, because a
file list converges where a HEAD stamp cannot.
The notes attached to certain paths are localized File Annotation records
in Strapi. Every ordinary path is checked against the generated tree after the
records are fetched; a stale path is skipped with a build warning so one CMS
typo cannot block unrelated deploys. The reserved $$ROOT_ANNOTATION$$ record
supplies this overview instead of pointing at a tree node. It is required in
English, other locales fall back to English, and the two diagram markers above
are replaced only after the CMS markdown has been resolved.
.github/workflows
The workflows and composite actions here are split so that each has one
trigger and one job shape. ci.yml runs on pull requests and holds a single
job, Checks: a repo-tree freshness gate, format:check, turbo run lint typecheck test --continue, then the web build. It is the only check branch
protection requires. It used to be three jobs behind a CI complete aggregate
gate, and each of them ran its own full-workspace install — the web job
installing the whole of Strapi, the admin job the whole of Astro — so install
was paid for three times to run prettier, tsc and Vitest once. Turbo still
runs the tasks concurrently inside the one job, and --continue makes every
task failure surface in a single run.
There is deliberately no push trigger. pull_request does not check the
branch, it checks refs/pull/N/merge — the head merged into the base — so
when the base has not moved, the tree that lands on master is the one
already checked, and checking it again proves nothing. That argument holds
only if the base cannot move underneath a stale pull request, which is a
branch-protection setting rather than anything in the YAML: require branches
to be up to date, or use a merge queue.
deploy.yml runs on pushes to master and carries no checks of its own. It
resolves deploy targets, then calls cd-cms.yml and cd-web.yml in that
order, because prerendered pages bake Strapi responses into the static output.
Only the CMS is path-filtered: it is a ~1 GB upload and a Strapi restart,
where the web deploy is a build and an S3 sync. Filtering the web deploy was
in fact wrong — /about/this-website publishes a tree built from git ls-files at the repository root, so a commit touching only docs/ changes a
published page — and it silently shipped a stale tree. One skip-propagation
condition survives, on the web job: cms legitimately skips, and
!cancelled() is what lets a job observe a skipped predecessor instead of
being skipped with it, at the cost of restating the gates it discards.
WEB_ENV is a repository secret, while the four deploy secrets sit on the
Production environment. The check job needs it, because astro check runs a
content sync through every loader against a live Strapi, and the only way to
read an environment secret is to declare the environment — which on a
pull-request job would register a deployment against Production for every
pull request and hand its contents to any same-repo branch. Tests need no
secrets at all now: astro.config.mjs no longer throws without CDN_URL.
Fork pull requests still cannot typecheck, since GitHub sends them no secrets.
WEB_ENV reaches the check job and the web deploy the same way — written to
apps/web/.env and exported — and that symmetry is load-bearing rather than
tidiness. Turborepo hashes the file through inputs and the exported values
through env, so a job doing only one of the two computes a different build
hash and rebuilds what the other already built. Doing both is what lets the
deploy restore the artifact the pull request produced. Remote cache artifacts
are signed with TURBO_REMOTE_CACHE_SIGNATURE_KEY; unset, turbo warns once
and builds in full.
Four hardening decisions worth not undoing. Third-party actions are pinned to
commit SHAs while first-party ones stay on major tags:
burnett01/rsync-deployments and appleboy/ssh-action both receive
SERVER_SSH_KEY, which is root on the VPS, and a mutable tag is too much to
stake on a single maintainer there. The CMS deploy compares the server's Node
major against the runner's and exits before cp -al if they differ,
because the bundle ships native modules compiled on the runner. A failed
health check rolls back to previous and re-checks, so production serves the
last known-good release even though the job still goes red. And the web upload
is three passes — immutable assets, then pages with --delete, then a prune —
so there is no window where live HTML points at assets that are missing or
already deleted.
renovate.yml is the fifth, and the only one that writes to the repository
rather than deploying from it. It runs self-hosted on a Monday cron — Renovate
acts only while the job runs, so that cron is the whole schedule — and its
token sits on a renovate environment rather than among the repository
secrets, because Workflows write means it could rewrite the very files that
carry SERVER_SSH_KEY.
Most of the prose lives in docs/CI-CD.md, but not all of it: renovate.yml
keeps its reasoning inline, in twenty lines of comment, because what justifies
its environment secret and its dry-run default is about that file rather than
about the pipeline. One comment is load-bearing rather than explanatory —
every third-party SHA pin ends in a # vX.Y.Z, and Renovate disables a bare
SHA it cannot resolve to a tag, so deleting one stops that action being
watched and says nothing.
AGENTS.md
This file used to be 528 lines. It is now 69, and the reasoning behind the cut is more useful than the diff.
The old version cached what the repo already recorded — dependency versions, inventories of every command — so it went stale by sitting still, and the constraints that actually matter were buried under the filler. What is left only routes: what the repo is, the six gate commands, the rules that hold everywhere, and a "where to read next" table.
The detail moved down rather than away. apps/web/AGENTS.md (121 lines) and
apps/admin/AGENTS.md (103) carry the constraints no source file confesses —
the dev server that detaches and answers a 70-byte stub instead of a stack
trace, the git stash that drops Strapi's tables while it is watching — each
compressed to the constraint and its reason in one to three lines. CLAUDE.md
is down to eight lines and keeps only the one trap that bites before this file
is read.
The rest became five skills under .claude/skills/: client-controllers,
feature-modules, design-system, output-neutral-refactor (which bundles
its gate scripts alongside the prose) and cms-annotations. A skill is loaded
on demand — its front-matter description states the situations it applies to —
so the always-loaded part of an agent's context stays small while the depth is
still one read away.
One rule states the principle the whole split encodes: read versions from each
package.json, never from prose. What is written down here is what no file
confesses; anything a file already states is left to the file.
apps/admin/src/api
Fifteen content types in Strapi's standard four-directory shape, all localized
except the two submission types, which store what a visitor typed and have no
translation. about, hero and profile are single types; the rest are
collections, and that split propagates into the generated web client as two
different classes — a SingleTypeAPI exposing only find and update, a
CollectionAPI with the full set — so reaching for a collection method on a
single type is a type error rather than a runtime surprise.
Three of the eighteen directories hold no content type at all, which is
what a purely custom endpoint looks like in Strapi: a router, a controller,
sometimes a service, no content-types/. contribution and
contribution-repository serve this page's contribution graph and repository
timeline out of GitHub's GraphQL API. github-auth is the GitHub sign-in leg,
and the one place this CMS replaces a plugin flow rather than extending it —
Strapi's own /connect/* ends by writing the provider's access_token into
the query string of a redirect, where it lands in browser history, and there is
no hook to change that. Only the state cookie, the code-for-token exchange and
the session cookie are ours.
Thirty-nine of the fifty-two controller, route and service files are seven-line
factories.createCoreX(...) calls: the web app reads through the REST API with
a token and needs no bespoke endpoints. The thirteen that are not belong to the
five custom apis — the two contribution readers, sign-in's two, and the two
public writes.
Those writes are why testimonial-submission is a content type of its own
rather than a route on testimonial. Both submission types take a custom
router instead of createCoreRouter, because the default CRUD set would put
create, update and delete one admin checkbox away from public. Splitting
the testimonial write off goes further: the published testimonial collection
the site reads keeps public permissions that are read-only, and no public
create sits beside it waiting to be switched on. POST /api/testimonials/submit is auth: false — a static page holds no secret to
present — so a honeypot that answers 200 and stores nothing, plus two
IP-bucketed rate-limit tiers, stand in for authentication.
The lifecycles hold the interesting code. tag normalises its aliases JSON
on write, adapting a framework-free module's AliasError into Strapi's
ValidationError so the admin attaches it to the right field; aliases are what
make tag synonyms searchable. Six types export createTagMirror(...), which
copies the tag list from the English document to every other locale, because a
relation row stores a row id and each locale is a separate row — linking a
tag on the English project leaves the French row with none. Neither manyToMany
nor pluginOptions.i18n.localized: false changes that; both were tried.
Nothing here writes to the CMS at startup any more — there are no seeders. What
reads is scripts/cms-snapshot/pull.ts, writing every api:: type to the
git-ignored .cms-snapshot/, all four locales side by side so a drifted
translation is visible by eye; published documents only, contact-submission
skipped. It is derived and read-only. The admin's own form copy and the two
core auth email bodies live in core_store, not in any content type, which
leaves pnpm --filter admin cms:export as the only copy of them and the only
backup of content. It has to actually be run: this repository has already lost
File Annotation rows once.
apps/web/astro.config.mjs
The most consequential thing in this file is absent: there is no adapter.
That is what makes the site fully static — prerendered HTML, no server at
request time, every Strapi read resolved during the build.
Environment handling is split in two, deliberately. CDN_URL and
IMAGE_DOMAINS are pulled through Vite's loadEnv at config-evaluation time,
because they build the image.domains allowlist before Astro's own env
validation would run. Both are optional and additive: each contributes hosts,
an entry may be a bare host or a full URL, and neither is required. The config
used to throw without CDN_URL, which made every Vitest run require
production credentials — vitest.config.ts builds its config through
getViteConfig — and that in turn forced a pull_request job to declare the
Production environment merely to read them. Everything else goes
through env.schema: STRAPI_BASE_URL and SITE_URL as public server
values with defaults and URL validation, STRAPI_API_TOKEN as
access: 'secret' — server context only, so it cannot leak into a client
bundle even by accident.
One line decides where the content-layer store lives, and it is worth knowing
why it is not the default. Astro resolves that store to .astro/ under
astro dev and to cacheDir for every other command. Vitest goes through
getViteConfig, which is the dev branch, so it reads .astro/data-store.json,
while astro sync and astro check write the cacheDir copy — two different
files under the default node_modules/.astro. The content-backed tests then
only passed on a machine where astro dev had happened to leave a store
behind, and never on a fresh CI checkout. Setting cacheDir: '.astro'
collapses all three onto one file.
Four integrations, in order: sondaPost() (a second in-repo integration that
generates a bundle report on astro:build:done),
localizedStaticRoutes(...), react(), icon(). The static-route
integration consumes translatedRoutes from src/i18n/route-manifest.ts;
the same manifest generates Paraglide's urlPatterns for the Vite plugin.
This keeps injected pages, language links and reverse localization aligned.
For example, /about/this-website also exists as
/حول/هذا-الموقع, /a-propos/ce-site and
/ueber-mich/diese-website.
The rest is tuning: prefetch on for every link with a hover default,
build concurrency at 6, the dev toolbar off, Vite sourcemaps on, and three
Google fonts registered through Astro's font provider —
Quicksand for Latin text, Rubik for Arabic, Pixelify Sans for the pixel
display type — each exposed as a CSS variable that the shared theme maps into a
font-* utility.
apps/web/integrations/localized-static-routes
A custom, static-only Astro integration written for this repo. On
astro:config:setup it walks src/pages/ for .astro files, turns each file
path into a route, and calls injectRoute once per non-default locale — three
extra routes per page, for ar, fr and de. English is served unprefixed.
A shared manifest in src/i18n/route-manifest.ts supplies translated slugs to
both this injector and Paraglide's URL compiler, so /about/this-website is also
reachable at /fr/a-propos/ce-site.
The integration deliberately has no virtual runtime module. Before injecting,
it validates locale uniqueness, default-locale membership, mapped and excluded
route existence, leading slashes, dynamic parameter parity, and collisions —
these last against the source pages as well as against each other, because
Astro sees a single route table. So a page at src/pages/ar/about.astro is
rejected: it already sits on the URL the Arabic variant of /about is injected
at. Runtime localization and reversal live in src/i18n/routes.ts and delegate
to Paraglide with an explicit locale. An unmapped page still receives a
localized route with its canonical slug, such as /ar/auth/sign-in; a route
mapped for only some locales warns instead, since it silently falls back.
Route injection happens once, at config time, and Astro re-runs that hook only
for a file it was told to watch. So the route manifest is registered with
addWatchFile, and astro:server:setup — watching src/pages/ for added and
removed .astro files — touches that manifest after a 100 ms debounce rather
than restarting the server itself. The indirection is the point: Vite's own
restart() rebuilds from the config Astro had already resolved, so it would
come back carrying the stale route manifest, printing a reassuring log line
while the new page stayed English-only.
It ships no runtime module but it does generate a type. astro:config:done
writes an ambient LocalizedRoutes.SourceRoute union — one member per page it
found — and localizedHref is constrained to it, so linking a typo or a page
that was renamed away fails to compile instead of 404ing in production. Being
built from the same walk that feeds injectRoute is what keeps the routes you
can link and the routes that exist from drifting apart.
apps/web/src/.generated
Two build inputs produced by tooling rather than written by hand. Both are committed, and the repo's reason for committing them is not the same.
strapi-client/ is a typed Strapi API client — client.ts, types.ts,
index.ts — written by pnpm --filter admin generate, which fetches the
schema from a running Strapi on port 3333. Start the CMS before
regenerating. The output is header-marked @ts-nocheck and "do not edit
manually", and carries the schema hash it was generated from, so a
content-type change nobody regenerated shows up in a diff rather than only at
runtime. It is committed, which is what lets a clean checkout typecheck and
build without a CMS running anywhere.
Thirteen files reach it through the $strapi alias, but only one of them
instantiates anything: src/core/cms/client.ts constructs the single
StrapiClient with the base URL and token from astro:env/server. One
feature repository pulls in the isStrapiErrorOf guard, and the rest import
types only — BlogGetPayload, TagGetPayload, HeroGetPayload and friends,
which each feature's own types.ts narrows into the shape its components
want. So the client is a singleton while its type surface is used freely,
which is the split you want.
repo-tree.json is the file tree this page renders, produced by
scripts/generate-repo-tree.mjs from git ls-files. It carries a file count,
a directory count and the nested children — and nothing else. It is
committed, though nothing in its content asks for that: it is a pure function
of the tracked file list, and dev, build, test and typecheck each
regenerate it before running. What asks for it is Turborepo. The tree is built
from git ls-files at the repo root while turbo hashes inputs per package,
so a file added under apps/admin/ changed this page without changing the web
build's hash — and a cache hit then served the old tree. Committing it puts
the tree in that hash, and pnpm --filter web gen:tree:check in CI fails the
pull request once the committed copy has drifted. It also used to stamp in the
HEAD SHA, which really is the one thing a generated-and-committed file
cannot hold — writing the file advances HEAD — and that stamp stayed deleted
when the file came back under tracking.
The whole directory is listed in .prettierignore, on the grounds that
regenerating would undo any formatting applied to it, and the CI formatting
check would then flag files nobody wrote.
apps/web/src/core/controller.ts
Every piece of client behaviour on this site is a Controller subclass. There
is no loose <script> doing DOM work: a script block imports its class and
calls Controller.mount(id, resolve), and that one line is the whole
registration surface.
The reason is view transitions. Astro swaps the document instead of reloading
it, so a listener bound on astro:page-load and never removed accumulates one
live copy per navigation, attached to nodes the document no longer contains.
Cleanup is therefore the default here rather than an afterthought: listen()
and own() record a disposer at the moment you register, and disconnect()
unwinds them in reverse. The lifecycle used to be a separate AstroLifecycle
singleton, but Controller was its only caller, so the two were folded into
this one file.
A module script runs once per session, not once per page, so resolve is the
gate that decides whether the current page has anything to mount — byId,
bySelector and whenPresent are the usual three, and returning nothing is
ordinary rather than a fault, so nothing is logged for it. Keying by id means
a repeat registration replaces instead of duplicating. Mounts and teardowns are
serialized through a single promise queue, which is what stops an async mount
from overlapping the teardown of the page it is replacing.
Call sites pass no configuration as arguments. Every hook is a data-*
attribute the controller scans for at connect time, so markup stays in the
.astro file where the rest of the page already lives. Three bases build on
that convention: core/filter-controller.ts takes a prefix and keys, keeps
query and facets in the URL, and leaves present, resultMessages and
onCriteriaChange to its subclasses; core/form-controller.ts reads its copy
from data-strings and owns the status line, the submit lock and the 400
field-error mapping; and design-system/patterns/carousel/EmblaController.ts
scans data-embla-*, takes its direction from dir so Arabic pages need no
wiring at all, and insists the dots wrapper exist even when empty, because its
click handler is delegated to it.
apps/web/src/design-system
Presentation with no domain knowledge. It may not import from features/,
shell/ or i18n/; the one dependency it does take is core/controller,
because three of its patterns are behaviour rather than markup.
The tokens are not in this directory at all. Tailwind v4 has no JavaScript
config file, so they live one package up in
packages/tailwind-config/theme.css. A plain @theme block holds raw values —
--leading-display: 0.92, --leading-heading: 0.95, --tracking-eyebrow: 0.2em, and --text-eyebrow carrying its own --text-eyebrow--line-height so
the smallest type is not left on the default ratio — while an @theme inline
block maps semantic names onto the palette variables, which is what turns
--color-background into a bg-background utility.
Two comments in that file record decisions rather than code. Dark mode is a
plain prefers-color-scheme media query: the class-based version applied its
class from JavaScript on DOMContentLoaded with no CSS fallback, so dark-mode
visitors were served the light palette and saw it for a measured 532 ms. A
media query resolves before first paint, so that flash cannot happen. And there
is no entrance-animation token — --animate-fade-in used both fill mode,
which held every wrapper it touched at opacity: 0 until its animation began,
made the LCP element unstable and cost roughly 100 ms. Both comments exist so
neither decision quietly comes back.
Typography is a compound component over one tv() recipe: fourteen variants
and a tone axis, rendered statically from .astro and never hydrated. Where a
component is impossible the raw typography() function is the escape hatch:
transition:name, set:html, class:list, slot forwarding, an interactive
element and <time> all need the class string rather than the wrapper.
The primitives are deliberately few: Chip with chip.ts, Surface on
tone/radius/padding, Input's shapes, Button and Link sharing one
interactive recipe, and Icon. The bar for adding one is two consumers, not
one. patterns/ holds the composed pieces that still do not know what a
project is — EmptyState, FilterControls, FilterBottomSheet,
AnimatedTooltip, MarkdownBody, PixelSignal, card-transitions and the
Embla carousel. Even generated output takes its colour the same way: the
hand-written Shiki theme in lib/shiki.ts is built from
var(--color-foreground) and color-mix over a transparent background, so one
theme reads correctly in both schemes instead of needing a pair.
apps/web/src/design-system/primitives/Icon.astro
Thirteen lines, five of them the comment explaining why the file exists — the
smallest of the design-system primitives and the one with the longest
justification. It re-exports astro-icon's Icon with one prop forced on:
<AstroIconBase {...Astro.props} is:inline />
Every component in the app imports from here and never from
astro-icon/components. The reason is a failure mode that produces no error
of any kind. By default astro-icon dedupes sprites: the first render of a
given icon name emits the <symbol> definition, and every later render is a
bare <use href="#...">. Document order decides who counts as first — and in
this app, that can be markup inside an astro-island slot (the nav and side
menus), which Astro ships in an inert <template data-astro-template> until
hydration. The symbol never enters the live DOM, so every <use> pointing at
it resolves to nothing and renders nothing. Measured, on a desktop viewport:
all 28 arrow-right icons plus the footer's GitHub and link icons, gone.
Inlining only the icons inside islands does not fix it, because inline renders
still advance astro-icon's per-name counter — the sprite path and the inline
path share the same bookkeeping, so a mixed policy just moves which instances
disappear. Forcing is:inline everywhere is the only setting that is
consistent, at the cost of repeating the SVG markup per instance.
tests/repo-guards.test.ts guards the other half of the problem, using
scripts/icons/scan.ts to check that every pixelarticons:* name referenced
in the source actually exists in the installed icon set, and failing the check
if anything imports lucide-react. The script it replaced was never wired
into CI, so this is the first time either check runs automatically.
apps/web/src/features
Twelve feature modules — about, auth, blogs, contact, entries,
hero, og, profile, projects, search, tags, testimonials — one per
domain. Nothing shared lives here: core/ holds the Controller base, the
Strapi client and the content-collection loaders, design-system/ the
presentation with no domain knowledge, and shell/ the chrome every page
wears.
The organising idea is that a file is named for its role rather than its
kind, and the roles form a chain. repository.ts is the only place allowed to
touch the generated Strapi client: it owns the populate shapes, the filters
and the sort order, and returns raw Strapi types. A page never calls it —
content.config.ts wires it into a loader that runs once per sync, with the
feature's validity predicate declared beside it there, so a bad record names
its collection and entry id instead of surfacing as a thrown assertion
mid-render. service.ts sits above the content store and reads it back out
through getCollection or getEntry. The page awaits the service and passes
the result down; ui/ renders what it is given and fetches nothing itself.
controller.ts — or controllers/<name>.ts where a feature has several — is
the browser half. types.ts holds the domain shapes, and several features
carry a translation-keys.ts that maps domain enum values onto i18n keys in
one place instead of scattering string concatenation across components.
Naming for role is what stops placement being a question: a module that fetches
from Strapi has exactly one place to live, and a file that is hard to name is
usually a file doing two things. It is also why the tree no longer has
components/, utils/ or constants/ directories, and no barrel files at
all — nothing re-exports a sibling, so every import names the module it
actually wants. Imports between features go through the $/ alias and reach
only the other feature's service.ts, which keeps a directory move from
producing a diff full of ../../.. fixups.
auth is the deliberate exception, and worth reading before copying the shape
from anything else here: no repository and no service, because it owns a
session, and a session only exists in a browser.
apps/web/src/features/search
Site search is entirely static. service.ts assembles a SearchDoc[] per
locale at build time — projects, blogs, timeline entries and tags via one
mapper each in mappers.ts, plus hand-authored route and home-anchor docs in
pages.ts so search doubles as navigation. The result is served as a plain
JSON file from src/pages/search-index/[locale].json.ts, deliberately a .ts
endpoint: the localized-static-routes loader only walks .astro files, so it
never injects locale-prefixed variants and the paths inside stay literal.
One subtlety still lives in service.ts: because /projects/[slug] and
/blogs/[slug] generate their static paths from default-locale data only,
every locale's detail page lives at the default-locale slug — so search hrefs
resolve slugs from the canonical English entity, keyed by documentId,
rather than from the localized one it is describing. A second subtlety is
gone: the per-locale index used to be memoized as a promise, not a value,
because the endpoint renders four times per build and each render would
otherwise refetch every collection from Strapi. Reading the canonical set is a
store lookup now, so that promise memoization was deleted.
Matching does not live in this feature at all. $/lib/fuzzy is the only
module in the app that imports Fuse, and the same index also backs the
projects and tags page filters, so all three surfaces rank identically.
engine.ts adapts SearchDoc[] to it and adds the per-kind tiebreak, and
grouping.ts owns the per-kind caps and the overall cap — which drops whole
low-scoring groups rather than shaving rows off a group that is already
reporting an overflow count. Both run in the browser and are deliberately
pure: $/lib/text, their own types, and nothing else.
The palette under ui/ is one of the few genuine React islands on the site,
and it is several small files rather than one large one. SearchPalette.tsx
is the shell and hydrates with client:idle; use-palette-open.ts and
use-palette-search.ts hold the two pieces of state that used to be tangled
inside it; PaletteInput.tsx, PaletteResults.tsx, PaletteRowItem.tsx and
PaletteStates.tsx render, with their class recipes in palette-styles.ts.
None of them fetches anything: worker-client.ts spawns search.worker.ts on
first open — or on a pointer-enter prefetch — and keeps one client per index
URL at module scope, so the index survives a view transition, and falls back
to running the same engine on the main thread where a module worker cannot be
constructed.
apps/web/src/i18n
Translations are compiled by Paraglide from the existing namespaced JSON
catalogs. config.ts remains the source of truth for Locale, locales and
defaultLocale; project.inlang/settings.json mirrors those four locales and
pins the official catalog plugin. The Vite plugin writes ignored, typed
message-modules into src/.generated/paraglide/.
messages.ts is the app's whole message-access surface, not the small helper it
once was. .astro files, and the .ts modules only they reach, read messages
through useMessages(locale) — a per-locale proxy over that generated barrel,
indexed by the catalogue's own keys (t['header:language.label']()) instead of
by importing individual message functions. Enum label tables store LabelRef
data — a catalogue key, or { key, context } — resolved through
label(t, ref). No prerender path calls setLocale(), so Astro's concurrent
build cannot leak language state between pages.
Anything Astro can ship to the browser is the exception: React islands, modules
reached only through an Astro <script> block, and everything those import —
eight files in all — still import the exact generated message functions they
need and pass { locale } explicitly, so the catalogue stays tree-shaken into
their chunks rather than pulled in whole through the barrel.
tests/i18n/paraglide.test.ts computes that client-reachable set and fails the
build if any module in it imports the barrel instead.
The catalog validator still rejects locale, namespace and key drift, invalid
values, legacy placeholders, interpolation mismatches and missing plural
categories. tests/i18n/paraglide.test.ts additionally compiles the project and
checks settings parity, context-suffixed keys, number formatting and all six
Arabic plural categories.
apps/web/src/styles/view-transitions.css
Every animated navigation on the site is configured here, in one stylesheet
rather than scattered across components. Astro's <ClientRouter /> swaps the
document instead of reloading it, and the browser's View Transition API turns
that swap into an animation — this file decides what the animation is.
Two things happen on a navigation. The page itself cross-fades, and any element
marked as shared morphs from where it was into where it is going: a project
card's cover growing into the detail page's hero, for instance. Adding a new
pair is two attributes on each side and no JavaScript — transition:name to
pair them, plus data-vt-image or data-vt-text to pick a preset.
The rules are unlayered on purpose. Astro emits its own transition CSS inside
@layer astro, and unlayered rules beat layered ones, so nothing here needs
!important to win.
Most of the file is comments, and they record measurements rather than taste.
Both the page fade and the shared-element morph were first given a fast-start
curve, and both had to be changed: sampling the browser's own animation frame
by frame put two thirds of the movement inside the first three frames, which
reads as a jump rather than a transition. The numbers are in the file, and
tests/projects/transitions.test.ts re-derives them from these tokens, so a
regression fails the suite instead of shipping.
One rule is written as an absence. The header and footer are deliberately not
given transition names of their own: naming an element makes it a backdrop
root, and the header's frosted pill depends on backdrop-filter sampling the
page behind it, so naming it silently flattened the glass on every page. The
page fade keeps the outgoing snapshot opaque underneath, which means the chrome
never needed lifting out — it simply does not appear to change.
One piece of this cannot be expressed in CSS, and it lives in
design-system/patterns/card-transitions.ts. A transition:name holds a
single value, and a project cover deliberately carries the same name on the
homepage, on the index and on the detail page's hero — that sharing is what
makes the morph work from either list with no per-page wiring. The side effect
is that the two lists then share the name with each other, so navigating from
the homepage to the index paired all four featured covers and flew them in from
2752 px below the fold. CardTransitions.install() adds one
astro:before-preparation listener that reads sourceElement, walks up to its
[data-vt-card] ancestor, and sets viewTransitionName = 'none' inline on
every other card's targets — inline, because the names come from stylesheet
rules and cannot simply be removed, and nothing restores them because the
router discards this document anyway. A history traversal carries no
sourceElement, so every card is unnamed, which is exactly what stops the
reverse trip flying. The helper exists only to keep the morph off the wrong
cards; it knows nothing about projects or blogs, which is why it sits among the
design-system patterns rather than inside either feature that calls it.
turbo.json
Seven tasks, and no knowledge of what either app actually is. build, lint,
lint:fix, typecheck, sync, test and dev declare ordering and caching
and nothing else; how a build happens stays in each app's own package.json
scripts. dev is cache: false, persistent: true so Turbo keeps it attached
instead of trying to cache a server.
The interesting entry is sync, which exists to resolve a race rather than to
run a build step anybody asked for. apps/web/astro.config.mjs sets
cacheDir: '.astro', which deliberately collapses the content-layer store onto
one file — apps/web/.astro/data-store.json — so that Vitest, astro sync and
astro check all read the same content instead of the two separate copies the
default node_modules/.astro produces. The cost of that convergence is that
typecheck and test now write the same file, and Turbo runs independent
tasks concurrently: in CI they collided, failing with an ENOENT while
renaming data-store.json.tmp into place. Declaring sync and giving both
tasks dependsOn: ["^…", "sync"] makes the store exist, once, before either
reads it. sync is the only task with .astro/** as its output.
web#typecheck is cache: false, and that is deliberate rather than an
oversight: astro check syncs live CMS content through every loader, and no
inputs hash can see a row that changed in Strapi, so a cached pass would
report on content that is no longer there. The app-level apps/web/turbo.json
adds only the env lists — the five Astro variables, declared per task because
Turbo runs in strict env mode and silently strips anything undeclared.
This site, and the ones before it
Five versions in two and a half years. Each one fixed the last one's problem and introduced its own — usually by reaching for a framework before the problem was big enough to need it.
v1Feb 2024 – Aug 2025
4 commits
Hand-written pages
- HTML5
- CSS3
- GitHub Pages
- Rendering
- Static files
- Content
- Hard-coded in the markup
- Server
- None
- Repositories
- Hadi-Hijazi · HadiHz88.github.io
Two repositories, four commits, no build step: HTML and a stylesheet pushed to GitHub Pages. Nothing to compile, nothing to deploy, nothing to pay for — and still the fastest this site has ever loaded. It ended for the obvious reason. Content and layout lived in the same file, so adding a project meant copying a block of markup and remembering every other place it had to change.
v2Mar – Apr 2025
50 commits
Laravel, and a front end I did not write
- Laravel
- PHP
- React
- Rendering
- Client-rendered
- Content
- Database behind Laravel
- Server
- PHP and Node
- Repositories
- My-Platform-BackEnd · My-Platform-FrontEnd
I wanted a real backend — a database, an admin panel, authentication — and Laravel handed me all three in an afternoon. Learning it was most of the point, and that part worked. The front end was generated rather than written, so I never really owned it, and three weeks in the shape of the problem was clear: two applications, two deploys, and a server running around the clock to hand out a page that changes twice a month.
v3Jul – Sep 2025
144 commits
The same architecture, in one language
- NestJS
- React
- TypeScript
- Rendering
- Client-rendered
- Content
- Database behind NestJS
- Server
- Node
- Repositories
- my-website-backend · My-Website-Client
Rewriting the API in NestJS made the code mine again: one language across both halves, types shared end to end, and a far better day-to-day. What it did not do was change the architecture. It was still a hand-rolled CRUD API for a few dozen records, still a single-page app that painted a blank screen before it painted content, and still two things to keep deployed for a site nobody logs into.
v4Nov 2025 – Feb 2026
92 commits
Static, with the content in the repository
- Next.js
- Vercel
- Rendering
- Prerendered at build
- Content
- JSON files in the repository
- Server
- None
- Repositories
- HadiHz-Portfolio
Deleting the backend fixed the performance problem outright. Next.js prerendered every page, the content sat in JSON beside the code, and there was no runtime left to be slow or to fall over. The cost moved rather than disappeared: every typo became a commit, JSON is a miserable surface to write prose in, and a second language would have meant maintaining parallel files by hand.
v5Apr 2026 – now
857 commits
Astro and Strapi
- Astro
- Strapi
- React
- AWS
- Rendering
- Prerendered, with islands
- Content
- Strapi
- Server
- None at read time
- Repositories
- hadihz.me
This version keeps the previous one’s output and gives back the writing. Strapi holds the content, Astro prerenders every page, and no JavaScript ships unless a component asks for it — the file tree above and the chart below are islands, everything around them is plain HTML. The remaining tradeoff is visible rather than hidden: publishing needs a rebuild, which is why there is a deploy pipeline.
Commits per month
The same five versions, measured. One line per version, dashed once it stopped serving the site.
1,147 commits across these repositories
Data as of 9/30/2026