# rosslabs-ai

Personal portfolio site built with Astro and Keystatic CMS. Showcases projects, posts, and experience.

Status: active

## Problem

A portfolio spanning ~19 shipped apps/tools plus long-form technical writing (agent-architecture deep dives, benchmark write-ups, failure-mode catalogs) doesn't fit a blog template or a page-builder. A blog template has no structured project metadata — tech stack, status, group ordering, live/repo URLs — so every entry degrades to freeform HTML. A page builder (Squarespace/Wix/Webflow-class tools) has no schema enforcement at all: adding a 20th project means hand-copying markup and hoping the fields stay consistent. Neither gives an editor a typed form for "add a project" that guarantees every entry has a title, a ≤160-char description, and a valid status.

## Approach

The design bet: typed content collections (Astro + Zod) as the source of truth, with Keystatic as a git-backed editing UI layered on top of the same files — not a headless CMS with a remote database. Content lives in `src/content/**` as markdown/MDX/JSON and is validated at build time against schemas in `src/content/config.ts`; Keystatic's admin panel (`keystatic.config.ts`) reads and writes those same files, so a schema change and its content migration land in one commit.

Deliberately not done: no hosted CMS (Contentful/Sanity-class) — that would decouple content state from the code that renders it and add a network dependency for every edit. No SPA/client-rendered framework — the site is HTML-first with React used only for genuinely interactive islands (toolkit grid, waitlist modal), so most routes ship no client JS.

## Architecture

```mermaid
flowchart LR
    A[Editor: Keystatic admin UI] -->|reads/writes| B[src/content/**\nmarkdown/MDX/JSON]
    B -->|validated by| C[src/content/config.ts\nZod schemas, 26 collections]
    C --> D[Astro build]
    D -->|prerender: true\ngetStaticPaths over getCollection| E[Static content routes\n/projects/[slug], /preview/[slug]]
    D -->|output: server| F[Dynamic routes\n/api/*, waitlist]
    F --> G[Cloudflare D1\nrosslabs-waitlist]
    E --> H[Cloudflare Workers\n@astrojs/cloudflare adapter]
    F --> H
```

`astro.config.mjs` sets `output: 'server'` with the `@astrojs/cloudflare` adapter for the whole site, but individual content routes opt back into static generation per-route.

## How it works

1. Content is authored as markdown/MDX/JSON under `src/content/<collection>/` and validated against the Zod schema for that collection in `src/content/config.ts` — e.g. the `projects` collection requires `title`, `description` (max 160 chars), `group` (enum `apps | dev-tools | ai-tools | utilities`), and `techStack: string[]`.
2. The shared `tagList` field runs `z.preprocess` to strip `null`/empty rows from relationship arrays *before* element validation runs. Keystatic's relationship UI can save an unselected row as `null`, producing `tags: [null]`; the `reference('tags')` element schema rejects `null` outright, which previously aborted the whole production build with `InvalidContentEntryDataError` (repro'd at commit `651a38c`). A `.transform` on the array would run *after* per-element validation, so it wouldn't catch this — `preprocess` intercepts the raw input first.
3. `keystatic.config.ts` maps each collection to form fields and writes back to the same files Astro reads — no separate content database, no sync step.
4. `src/pages/projects/[slug].astro` and `src/pages/preview/[slug].astro` each set `export const prerender = true` and populate `getStaticPaths()` from `getCollection('projects')` / `getCollection('previews')`, so those routes are pre-rendered at build time even though the site as a whole runs in `server` mode. Only routes with real per-request state (the waitlist API) execute on the Cloudflare Worker at request time.
5. The table of contents on project pages re-reads the raw markdown file via `import.meta.glob(..., { query: '?raw' })`, regexes headings with `/^(#{2,4})\s+(.+)$/gm`, and slugifies each match (lowercase, strip non `[a-z0-9-]`) to build the anchor list. `rehypeSlug` (registered in `astro.config.mjs`'s `markdown.rehypePlugins`) assigns matching `id` attributes to the rendered headings so the anchors resolve.
6. `wrangler.toml` binds one D1 database (`rosslabs-waitlist`) for the single stateful feature on the site — early-access signups. Everything else is static content served from the edge with no database round-trip.

## Tech stack

Astro (`output: 'server'` + `@astrojs/cloudflare` adapter), Keystatic (git/local-backed CMS, no hosted backend), Zod (schema validation across 26 content collections), Tailwind v4 via `@tailwindcss/vite`, React islands (deduped to a single instance via Vite's `resolve.dedupe` to avoid a duplicate-React hook-call crash), Cloudflare Workers + D1.

## Results

Worked example, from the `tagList` preprocessing step above:

- Input from Keystatic: `tags: [null, "ref-agent-tools"]`
- Without `z.preprocess`: `reference('tags')` element validation fails on the `null` entry → `InvalidContentEntryDataError`, production build aborts.
- With `z.preprocess`: the array is filtered to `["ref-agent-tools"]` before element validation runs → the entry saves and the build succeeds.

⚠️ No build-time or runtime performance benchmark exists for the site (page weight, TTFB, Lighthouse score). That would need to be measured, not asserted.

## Lessons

- Git-backed Keystatic was chosen over a hosted headless CMS because content then version-controls alongside the code that renders it — a schema change and its content migration ship in the same commit instead of two systems drifting out of sync.
- `z.preprocess` was chosen over a post-validation `.transform` for the `tagList` field because Zod validates array elements before any array-level `.transform` runs; only preprocessing the raw input intercepts a `null` element before `reference('tags')` rejects it.
- Hybrid rendering — per-route `prerender: true` inside an overall `output: 'server'` config — was chosen over a fully static build because the waitlist feature needs a real server (D1-backed API routes), while the bulk of the site has no reason to hit the origin on every request.

## Links

- [HTML project page](https://rosslabs.ai/projects/rosslabs-ai/)
- [Live app](https://rosslabs.ai)
