Skip to content

How this site works

The problem it solves

A portfolio that needs layout changes every time a project ships stops getting updated. This site is built so adding project #40 in three years is a content commit, not a PR that touches a component.

Two content tiers, one schema

Flagship projects are one Markdown file each under src/content/projects/, typed against a Zod schema in src/content/config.ts (title, lens, stack, metrics, confidential flag, and so on). Copy _template.md, fill it in, push — Astro's content collections handle the rest, including per-project OG metadata and the case-study route at /work/[slug].

Everything else — every repo that doesn't warrant a hand-written narrative — lives in the Lab tier. Tag a GitHub repo with the topic portfolio-lab and it shows up on/lab after the next sync. No frontmatter, no file in this repo at all.

The Lab sync Action

.github/workflows/lab-sync.yml runs on a daily cron (plus manual dispatch), calls scripts/sync-lab.mjs, which queries the GitHub search API foruser:omarbounawarapy topic:portfolio-lab, and writes the result tosrc/data/lab.json. The workflow commits that file back to the repo, which triggers the normal Cloudflare Pages build. If the GitHub API call fails, the script writes { ok: false, repos: [] } instead of throwing, and /labrenders an honest "couldn't sync" state rather than breaking the build.

Hosting and CI

Astro ships near-zero JS by default; React islands are used only for the genuinely interactive pieces (the metrics quiz, the chat widget). Cloudflare Pages builds on every push to main, with preview URLs per branch, and hosts the two serverless functions this site needs — /api/contact and /api/chat — as Pages Functions. CI (.github/workflows/ci.yml) runs a typecheck, a build, andnpm audit on every push and PR, so a regression fails loudly instead of quietly shipping.

The chat widget's spend ceiling

The "ask about my work" widget calls OpenRouter with a small rotation of free-tier keys (three keys, ~50 requests/day each, tried in order) rather than a single paid key with no cap — that rotation is the spend ceiling: once all three are exhausted or erroring for the day, the function falls back to a static answer instead of failing or costing anything further. The system prompt is built only from a maintained context file (context/site-context.md) and never from the visitor's own message, so nothing typed into the box can make it claim a skill I don't have.

What's still a stub

The contact form's Turnstile integration needs a site key from a provisioned Cloudflare Pages project — until then the form still works, it just skips the bot check. Email delivery is wired to Resend but falls back to logging the submission if no key is set, so a message is never silently lost either way.