Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
30 commits
Select commit Hold shift + click to select a range
7ff96eb
docs: bring README, AGENTS and llms.txt back in line with the code
danielheene Nov 11, 2026
03db130
refactor(app): move robots and sitemap into the frontend route group
danielheene Nov 11, 2026
3e4b70c
fix(app): load stylesheet and fonts in the global error boundary
danielheene Nov 11, 2026
f89942c
feat(seo): serve one robots rule for every crawler
danielheene Nov 11, 2026
b8af18e
build(deps): put playwright packages on one version and pin it in syn…
danielheene Nov 11, 2026
148cb18
chore(env): remove the unused OPENAI_API_KEY
danielheene Nov 11, 2026
ab0e33b
chore(env): declare STATUS_PAGE_WORKER_HEARTBEAT_PUSH_URL in the env …
danielheene Nov 11, 2026
4ddf09a
feat(seo): disallow all crawlers outside production
danielheene Nov 11, 2026
84697b6
docs(agents): correct the preview-route claim and cover robots, globa…
danielheene Nov 11, 2026
5b066f5
feat(env): read the production host from PRODUCTION_HOST
danielheene Nov 11, 2026
5988c39
fix(ai): describe pages from their content, guard the generate action…
danielheene Nov 11, 2026
5717bcc
feat(ai): translate resume content with Sonnet 5.5
danielheene Nov 11, 2026
d82fe09
docs(llms): bring llms.txt up to date with the branch
danielheene Nov 11, 2026
9b88543
fix(app): keep robots and manifest at the app root
danielheene Nov 11, 2026
8ccfb80
ci: add bundle analyzer and a per-route bundle size budget
danielheene Nov 11, 2026
c01ef27
ci: drop the bundle size job from the pull-request workflow
danielheene Nov 11, 2026
8dbf241
build: reduce bundle analysis to the stock @next/bundle-analyzer setup
danielheene Nov 11, 2026
8931a21
fix(resume): render the download button as a plain anchor
danielheene Nov 11, 2026
b1a6356
build(deps): bump dependencies and sort package.json scripts
danielheene Nov 11, 2026
9d74680
feat(payload): make generatorFlags a required array with shared schem…
danielheene Nov 11, 2026
bb0a381
refactor(payload): remove the normalizeIncomingFlags hook
danielheene Nov 11, 2026
7d82c4d
build(scripts): rename syncpack commands to packages
danielheene Nov 11, 2026
60c3672
build(deps): pin package families to one version and align react-dom
danielheene Nov 12, 2026
b4c0fed
ci: pin the cache and artifact actions and cache the Next build
danielheene Nov 12, 2026
abc87e3
chore(config): type process.env from the env schema and tidy the Next…
danielheene Nov 12, 2026
3066c09
refactor(content): load pages, posts and resume documents through slu…
danielheene Nov 12, 2026
8ef687e
refactor: remove 'use server' from code that only runs on the server
danielheene Nov 12, 2026
afe6036
fix(umami): move getToken out of the server-action module
danielheene Nov 12, 2026
29e004f
fix(umami): require a session for the analytics server actions
danielheene Nov 12, 2026
a639fbc
chore(config): warn on redundant type constituents
danielheene Nov 12, 2026
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 2 additions & 2 deletions .env.test
Original file line number Diff line number Diff line change
@@ -1,13 +1,14 @@
# Test environment — committed on purpose.
# Dummy but format-valid values that satisfy src/types/environment.ts so the
# app can boot for E2E tests. Flows depending on real external services will
# not work with these: AI generation (OpenAI/Anthropic), Mapbox geocoding,
# not work with these: AI generation (Anthropic), Mapbox geocoding,
# email (UseSend), Umami analytics, status-page heartbeat.
# Prerequisite: `docker compose up -d` (Mongo, Redis, S3 must be reachable).

# Not the real dev-server port (3000): that literal can also appear hardcoded
# in vendor code.
SERVER_HOST=localhost:39217
PRODUCTION_HOST=daniel.heene.io
SERVER_URL=http://localhost:39217
NEXT_PUBLIC_SERVER_URL=http://localhost:39217
NEXT_PUBLIC_STATUS_PAGE_URL=http://localhost:3001
Expand Down Expand Up @@ -37,7 +38,6 @@ USESEND_API_KEY=test-key
USESEND_DEFAULT_FROM_ADDRESS=test@example.com
USESEND_DEFAULT_FROM_NAME=Test

OPENAI_API_KEY=sk-test
ANTHROPIC_API_KEY=sk-ant-test
MAPBOX_API_KEY=pk.test
UNSPLASH_ACCESS_KEY=
7 changes: 0 additions & 7 deletions .github/actionlint.yaml

This file was deleted.

2 changes: 1 addition & 1 deletion .github/actions/setup/action.yml
Original file line number Diff line number Diff line change
Expand Up @@ -17,7 +17,7 @@ runs:
node-version: 26

- name: Cache Bun packages
uses: actions/cache@v4 # TODO: pin to a commit SHA like the other actions
uses: actions/cache@55cc8345863c7cc4c66a329aec7e433d2d1c52a9 # v6.1.0
with:
path: ~/.bun/install/cache
key: bun-${{ runner.os }}-${{ hashFiles('bun.lock') }}
Expand Down
13 changes: 12 additions & 1 deletion .github/workflows/build-and-deploy.yml
Original file line number Diff line number Diff line change
Expand Up @@ -154,6 +154,7 @@ jobs:
# per-branch NODE_ENV build arg is gone with the Docker build phase.
NODE_ENV: production
SERVER_HOST: ${{ secrets.SERVER_HOST }}
PRODUCTION_HOST: ${{ secrets.PRODUCTION_HOST }}
SERVER_URL: ${{ secrets.SERVER_URL }}
NEXT_PUBLIC_SERVER_URL: ${{ secrets.NEXT_PUBLIC_SERVER_URL }}
NEXT_PUBLIC_STATUS_PAGE_URL: ${{ secrets.NEXT_PUBLIC_STATUS_PAGE_URL }}
Expand All @@ -177,7 +178,6 @@ jobs:
USESEND_API_KEY: ${{ secrets.USESEND_API_KEY }}
USESEND_DEFAULT_FROM_ADDRESS: ${{ secrets.USESEND_DEFAULT_FROM_ADDRESS }}
USESEND_DEFAULT_FROM_NAME: ${{ secrets.USESEND_DEFAULT_FROM_NAME }}
OPENAI_API_KEY: ${{ secrets.OPENAI_API_KEY }}
ANTHROPIC_API_KEY: ${{ secrets.ANTHROPIC_API_KEY }}
MAPBOX_API_KEY: ${{ secrets.MAPBOX_API_KEY }}
UNSPLASH_ACCESS_KEY: ${{ secrets.UNSPLASH_ACCESS_KEY }}
Expand Down Expand Up @@ -209,6 +209,17 @@ jobs:
- name: Run database migrations
run: bun run migrate

# Webpack's persistent cache; the only part of .next worth restoring
# between builds.
- name: Restore Next.js build cache
uses: actions/cache@55cc8345863c7cc4c66a329aec7e433d2d1c52a9 # v6.1.0
with:
path: .next/cache
key: next-${{ runner.os }}-${{ hashFiles('bun.lock') }}-${{ github.sha }}
restore-keys: |
next-${{ runner.os }}-${{ hashFiles('bun.lock') }}-
next-${{ runner.os }}-

# One build against this environment's real values — no separate
# test-env compile to reconcile with afterward (see lint-and-test.yml's
# `e2e` job for the PR-time equivalent, which uses .env.test instead).
Expand Down
4 changes: 2 additions & 2 deletions .github/workflows/lint-and-test.yml
Original file line number Diff line number Diff line change
Expand Up @@ -43,7 +43,7 @@ jobs:
run: bun run lint

- name: Check dependency version groups
run: bun run deps:lint
run: bun run packages:lint

# Generated files are committed; a stale one means someone changed a
# collection/field/block without running `bun run generate`.
Expand Down Expand Up @@ -94,7 +94,7 @@ jobs:

- name: Upload coverage
if: always()
uses: actions/upload-artifact@v4 # TODO: pin to a commit SHA like the other actions
uses: actions/upload-artifact@cf430e030ddbb5b0abf93d22962f4752f3646cd9 # v7.0.2
with:
name: coverage
path: coverage/
Expand Down
107 changes: 78 additions & 29 deletions AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -30,15 +30,17 @@ See `README.md` for setup/run instructions; this file focuses on conventions and

```bash
bun install # install deps (Bun only — see packageManager in package.json)
bun run dev # runs Next.js dev server + Storybook in parallel
bun run dev:app # Next.js dev server only
bun run generate # regenerate Payload types + import map (run after changing collections/fields)
bun run build # production build
bun run dev # Next.js dev server via scripts/dev.mjs (--tunnel opens the Cloudflare Tunnel)
bun run dev:app # plain `next dev`, without the tunnel wrapper
bun run dev:storybook # Storybook on :6006 (not started by `bun run dev`)
bun run generate # regenerate Next route types, Payload types + import map
bun run build # production build (`next build --webpack`)
bun run typecheck # tsc --noEmit
bun run analyze # production build with @next/bundle-analyzer reports
bun run lint # oxlint + oxfmt --check (does NOT auto-fix)
bun run lint:fix # oxlint --fix + oxfmt (auto-fixes what it can)
bun run format # oxfmt (formatting and import order only)
bun run test # unit tests (Vitest)
bun run deps:lint # syncpack: dependency version groups
bun run packages:lint # syncpack: dependency version groups
bun run test:e2e # Playwright E2E (needs docker compose up -d)
```

Expand All @@ -55,11 +57,19 @@ files import generated types from `src/types/payload.ts` and `@/types/collection
Don't let those block your task, but do not introduce new lint errors or warnings, and prefer
fixing warnings in files you already touch.

`syncpack.config.mjs` pins each package family (Payload, Storybook, Vitest, Playwright, React) to
one version with `pinVersion`; to upgrade a family, change its pin and run `bun run packages:fix`
(`packages:update` skips pinned families). Pins rather than `sameRange`: syncpack only compares
one dependency across packages, so in this single-package repo no policy catches `react` and
`react-dom` drifting apart. `scripts/e2e-docker.sh` derives its image tag from the installed
`@playwright/test`.

Always spell scripts `bun run <script>`: bare `bun test` and `bun build` run Bun's own test runner
and bundler, not the package scripts.

Commits go through Lefthook (`lefthook.yml`): `pre-commit` runs `oxfmt` then `oxlint --fix` on
staged files (re-staging the fixes), `commit-msg` enforces Conventional
staged files (re-staging the fixes) and, when `package.json` is staged, `syncpack format` + `fix`
followed by `bun install` (re-staging `bun.lock`); `commit-msg` enforces Conventional
Commits. Use the existing types (`feat`, `fix`, `chore`, `refactor`, `docs`,
`test`, `build`, `ci`, `perf`, `style`, `revert`); scopes are unrestricted.

Expand All @@ -76,7 +86,7 @@ Commits. Use the existing types (`feat`, `fix`, `chore`, `refactor`, `docs`,
4. `@/**` path-alias imports
5. relative imports
6. style imports
Run `bun run format` (or your editor's Oxc integration) instead of manually sorting imports.
Run `bun run lint:fix` (or your editor's Oxc integration) instead of manually sorting imports.
- `tsconfig.json` has `"strict": false` — do not rely on the compiler to catch null/undefined
bugs; be explicit and defensive, especially in Payload hooks and access-control functions.
- `import type` vs. value imports is **not** enforced (`typescript/consistent-type-imports` is off) — either style is
Expand All @@ -94,7 +104,8 @@ Commits. Use the existing types (`feat`, `fix`, `chore`, `refactor`, `docs`,
the two hook flavors stay visually distinct — React hooks in `src/lib/hooks/` are camelCase named
after the hook (`useIsMobile.ts`), Payload lifecycle hooks in `src/lib/payloadHooks/` are
camelCase named after what they do (`generateChecksum.ts`). Plain utils use camelCase
(`generateSlug.ts`).
(`generateSlug.ts`). `src/components/hooks/` is an older second home for React hooks (one of
them kebab-case, `use-server-sent-events.ts`); put new hooks in `src/lib/hooks/`.
- **Fields**: folder name has no suffix (`src/fields/Slug/`), but the exported factory function
has an `XField` suffix (`SlugField`, `TitleField`). Keep this pattern for new fields.
- **Globals**: naming is currently inconsistent (`SiteSettings`, `SettingsGlobalUser`,
Expand Down Expand Up @@ -125,9 +136,9 @@ Commits. Use the existing types (`feat`, `fix`, `chore`, `refactor`, `docs`,
functions), `hooks/` (React hooks), `payloadHooks/` (Payload collection/global lifecycle hooks —
distinct from `hooks/`, don't conflate the two), `anthropic/` and `mapbox/` (external API
clients, same shape as `unsplash/`), plus `actions/`, `fetchers/`, `date/`, `i18n/`, `jsonLd/`,
`redirects/`, `references/`, `seed/`, `shiki/`, `sse/`, `umami/`, `unsplash/`. A new
external API client or hook goes in the matching subfolder; a handful of genuinely standalone
utilities stay flat at the top of `src/lib/`.
`blog/`, `lexical/`, `migrations/`, `redirects/`, `references/`, `seed/`, `shiki/`, `sse/`,
`umami/`, `unsplash/`. A new external API client or hook goes in the matching subfolder; a
handful of genuinely standalone utilities stay flat at the top of `src/lib/`.
- **Collections/Blocks/Fields/Globals** are factory-function based — most fields (e.g.
`TitleField()`, `SlugField()`) accept an `overrides` object rather than being edited in place.
Reuse existing field factories instead of inlining raw Payload field configs when one exists.
Expand All @@ -141,7 +152,7 @@ Commits. Use the existing types (`feat`, `fix`, `chore`, `refactor`, `docs`,
only inside the grid's Suspense boundary so the shell still prerenders. `src/lib/blog/listing.ts`
owns the params (sort options, parsing, href building); `proxy.ts` uses it to redirect
non-canonical forms (legacy `/page/<n>` segments, `page=1`, unknown sorts) and to mark non-default
sorts `noindex`. The RSS feed (`/blog/feed.xml`) and `app/sitemap.ts` cache under the `posts` tag,
sorts `noindex`. The RSS feed (`/blog/feed.xml`) and `app/(frontend)/sitemap.ts` cache under the `posts` tag,
which `revalidateBlogPost` invalidates.
- **Dashboard widgets** (`src/widgets/`): async server components registered in `payload.config.ts`
under `admin.dashboard.widgets`. Each widget has a `slug`, `Component` path, and optional
Expand All @@ -150,6 +161,26 @@ Commits. Use the existing types (`feat`, `fix`, `chore`, `refactor`, `docs`,
`*.client.tsx` files and call server actions from `src/lib/actions/` for mutations.
- **Server actions** (`src/lib/actions/`): `'use server'` functions for admin mutations (job
rescheduling, cancellation, etc.). Use `useTransition` on the client side when calling them.
- **Claude calls** (`src/lib/anthropic/`): every fetcher goes through `generateAnthropicText()`,
which owns the model IDs (`ANTHROPIC_MODEL`, Haiku 5.5; `ANTHROPIC_TRANSLATION_MODEL`, Sonnet
5.5, for résumé translation only), effort, timeout and output limit, and throws when
the answer was cut off or declined rather than returning partial text. Give the model the
content itself (wrapped in tags), never a URL: it cannot fetch. These modules are plain server
modules, not `'use server'` files, and the worker imports them too, so `server-only` (which
throws outside Next) is not an option. Callers that run inside a save (`generateAlt`) catch the
error so a Claude outage never blocks the write.
- **Plugins** (`src/plugins/`): `redirects` and `references` are local Payload plugins that add the
`Redirects` and `References` collections; `bun run refs:backfill` rebuilds the reference index.
- **Stories** live in `.storybook/components/*.stories.tsx`, not next to the components; the
design-foundation and shader docs are MDX under `.storybook/foundations/` and
`.storybook/shaders/`.
- **`app/global-error.tsx`, `app/robots.ts` and `app/manifest.ts`** have to stay at the root of
`app/`: inside a route group Next silently ignores all three (no error, the URL just 404s).
`sitemap.ts` is the exception and works from `app/(frontend)/`. `global-error.tsx` replaces the
root layout, so it imports the stylesheet and fonts itself.
- **Two `llms.txt`**: the root `llms.txt` describes this repository for coding agents and is
maintained by hand; `app/llms.txt/route.ts` serves the site's public `/llms.txt` from published
content.

## Environment-specific values

Expand All @@ -167,15 +198,16 @@ the Next.js bundle; their **handlers** are not. Each handler lives in `handlers/
config points at it with `handler: handlerPath('<name>.ts')` (Payload loads string handlers at run
time). A handler module must use `req.payload` instead of `getPayload({ config })`. The web process
only enqueues jobs (never `runByID`); the worker
(`scripts/start-worker.mjs`, polling every 5 seconds) runs them.
(`scripts/start-worker.mjs`, polling every 5 seconds) runs them. The app process runs jobs itself
only when `PAYLOAD_JOBS_ENABLE_APP_WORKERS=true` (default `false`).

## Security Guardrails (found during review — respect these when touching related code)

- **SSE / Redis channels** (`app/(frontend)/api/sse/route.ts`): the `channel` query param is
matched against an allowlist (`src/lib/sse/channels.ts`) before `subscribe()` ever sees it, and
per-job channels (`bilingual-translate:`, `seed-task:`) additionally require a signed-in
`payload.auth()` session. The public channels in `SSE_CHANNELS` (e.g. `service-status`) stay
unauthenticated by design. If you add a new channel that carries non-public data, add it to the
per-job channels (`bilingual-translate:`, `seed-task:`, `scheduled-job:`, `resume-generate:`)
additionally require a signed-in `payload.auth()` session. The public channels in `SSE_CHANNELS`
(e.g. `service-status`) stay unauthenticated by design. If you add a new channel that carries non-public data, add it to the
authenticated branch rather than `SSE_CHANNELS` — see the route's own comments for the exact
split.
- **Raw SVG rendering**: `src/collections/ResumeCustomers` stores raw SVG markup rendered via
Expand All @@ -193,20 +225,37 @@ only enqueues jobs (never `runByID`); the worker
`src/types/environment.ts` declares the schema and is the only place the full set of
variables is enumerated. The one committed env file is `.env.test`, which holds dummy,
format-valid values for E2E runs.
- New authenticated API routes should mirror `app/(frontend)/api/preview/route.ts`'s pattern of
pairing a shared-secret check with a real `payload.auth()` session check where feasible.
- **Semgrep** runs on every PR (`lint-and-test.yml`, rulesets `nodejs`, `typescript`, `react`,
`secrets`) and fails the check on any finding. A reviewed, accepted finding is marked on its own
line with `// nosemgrep: <rule> -- <reason>` (see `src/components/LogoCarousel/LogoCarousel.tsx`).
- New authenticated API routes and server actions need a real `payload.auth()` session check, as
in the SSE route's per-job branch and `src/lib/actions/enqueueBilingualTranslation.ts` — a Server
Action is its own reachable endpoint, whichever page renders the button. Inline `'use server'`
functions in admin field components call `requireSession()` (`src/lib/actions/requireSession.ts`)
first. A server-side fetch of an uploaded file goes through `resolveOwnMediaUrl()`
(`src/lib/resolveOwnMediaUrl.ts`), which rejects any other origin. Compare shared secrets
with `crypto.timingSafeEqual` and accept only same-site relative redirect targets, as
`app/(frontend)/api/preview/route.ts` does (that route checks `PREVIEW_SECRET` only, no session).
- **Production detection**: `NODE_ENV` cannot tell environments apart (every deployed image runs
`production`). Use `isProductionHost()` (`src/lib/isProductionHost.ts`), which compares
`SERVER_HOST` with `PRODUCTION_HOST` and reads an unset value as "not production". Outside
production `app/robots.ts` disallows all crawlers and `src/pdf/index.tsx` stamps
PDFs as DRAFT.

## Known Issues / Tech Debt (baseline, not blocking, but don't add more)

- `bun run lint` passes but reports pre-existing warnings, mostly `no-explicit-any`, `no-unused-vars`,
`no-img-element` and the React Compiler rules (`set-state-in-effect`, `purity`, `refs`), which
`no-img-element`, the React Compiler rules (`set-state-in-effect`, `purity`, `refs`), and the
backlog of the `tailwindcss/*` and vendored `anti-slop/*` rules (`tools/oxlint/anti-slop/`), which
`oxlint.config.ts` keeps at `warn` until they are cleaned up.
- Leftover debug `console.log`s (e.g. `src/collections/ResumeJobs/index.ts`,
`src/blocks/ResumeDownloadsBlock/Renderer/Renderer.tsx`) and commented-out dead code (e.g.
`src/collections/ResumeSkillTags/index.ts`) exist and should be cleaned up opportunistically.
- `package.json` `overrides` pins `postcss-merge-rules` to `9.0.4`: `9.0.6` throws
`RangeError: Invalid string length` while cssnano minifies Payload's admin CSS in the
production build. Lift the pin once a newer release builds cleanly (`bun run build`).
- A leftover debug `console.log` (`src/components/VideoMedia/VideoMedia.tsx`) and commented-out dead
code (e.g. `src/collections/ResumeSkills/index.ts`,
`src/components/ResumeRenderer/sections/Hero/Hero.tsx`) exist and should be cleaned up
opportunistically.
- `package.json` `overrides` pins `pdfjs-dist` to `5.4.296` because `patches/pdfjs-dist@5.4.296.patch`
targets that exact version (it gives the fake-worker import a default `workerSrc`, see
vercel/next.js#65406). Bumping `pdfjs-dist` means regenerating the patch.
- The E2E job in `lint-and-test.yml` is commented out, so Playwright only runs locally.
- No app-level rate limiting exists on public API routes.
- No shared logging abstraction — error logging is ad hoc `console.error` calls.

Expand All @@ -217,11 +266,11 @@ only enqueues jobs (never `runByID`); the worker
test; E2E specs live in `e2e/*.spec.ts` (never `*.spec.ts` under `src/`).
- Shared mocks live in `vitest.setup.ts`: `payload` (`getPayload` stubbed via `importOriginal`
spread — never replace the whole module, value imports from it are used at runtime),
`next/cache` and `redis`. `tailwind-merge`, `date-fns`, `slugify`, `pupa` and `neotraverse` are
intentionally NOT mocked — their real behavior is the contract under test.
`next/cache`, `server-only` and `redis`. `tailwind-merge`, `date-fns`, `slugify`, `pupa` and
`neotraverse` are intentionally NOT mocked — their real behavior is the contract under test.
- `TZ=UTC` is forced in `vitest.setup.ts` and date tests depend on it. Anything touching
`Interval.setToX()` or `getLocalISOString` needs `vi.useFakeTimers()` + `vi.setSystemTime()`.
Use `vi.stubEnv()` for env-dependent code (`SERVER_URL`, `PREVIEW_SECRET`).
Use `vi.stubEnv()` for env-dependent code (`SERVER_URL`, `SERVER_HOST`, `PRODUCTION_HOST`, `PREVIEW_SECRET`).
- There is no CI-enforced coverage threshold; when fixing a bug, add a regression test near
existing tests for that module if a suitable test file already exists.

Expand Down
Loading
Loading