This website

How this site is built, and what is in the repository that produces it.

Repository

762 files across 284 directories
apps
web
src
content.config.ts
.gitignore
AGENTS.md
astro.config.mjs
eslint.config.js
package.json
README.md
tsconfig.json
turbo.json
vitest.config.ts
.gitignore
.mcp.json
.npmrc
.nvmrc
.prettierignore
AGENTS.md
CLAUDE.md
package.json
pnpm-lock.yaml
pnpm-workspace.yaml
prettier.config.js
README.md
renovate.json
turbo.json

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-config and packages/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 of packages/tailwind-config/theme.css rather 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.

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.

  1. 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.

  2. 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.

  3. 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.

  4. 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.

  5. 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