This repository contains the source code for the personal website, blog, and resume builder: daniel.heene.io.
For AI coding agents and contributors: See
AGENTS.mdfor coding conventions, architecture notes, and known security/style guardrails before making changes.
- Language & Runtime: TypeScript / Node.js (
^26.0.0) - Framework: Next.js 16 (App Router, React 19)
- CMS: Payload CMS 3.x
- Database: MongoDB 8 (via
@payloadcms/db-mongodb) - Cache & Pub/Sub: Redis 8 (
@payloadcms/kv-redisfor Payload's KV store, plussrc/lib/RedisHandler.tsfor app-level caching and the SSE pub/sub layer) - Storage: S3-compatible storage (RustFS in local dev via Docker, AWS S3 / Cloudflare R2 in production via
@payloadcms/storage-s3) - Styling: Tailwind CSS 4, PostCSS, Tailwind Variants
- Component Development: Storybook 10
- Linting & Formatting: Oxlint and Oxfmt (no ESLint/Prettier), with
oxlint-tailwindcssand a vendoredanti-sloprule plugin (tools/oxlint/anti-slop/) - Package Manager: Bun (
^1.4.0, pinnedbun@1.4.2); Node.js still runs Next, Payload and the scripts - Testing: Vitest (Unit), Playwright (E2E)
- Integrations & Services:
- Analytics: Umami (Optional)
- Transactional Email: UseSend / React Email
- AI Metadata, Alt-Text, Excerpts & Translation: Anthropic Claude via the AI SDK (
src/lib/anthropic/) - Address & Geocoding: Mapbox
- Stock Photos: Unsplash API (Optional)
- Icons: Self-hosted Iconify API (
https://icons.heene.io) - Dev Tunnel: Cloudflare Tunnel (Optional,
bun run dev --tunnel)
- Node.js:
^26.0.0 - Bun:
^1.4.0(v1.4.2 recommended) - Docker & Docker Compose: For running local database (MongoDB), cache (Redis), and object storage (RustFS).
- Doppler CLI: For environment configuration and secret management.
Configuration and secrets are managed in Doppler. Install the CLI, link this directory to the project, then write the config out to .env.local:
brew install dopplerhq/cli/doppler # See docs.doppler.com/docs/install-cli for other platforms
doppler login
doppler setup --project website --config <your-config>
bun run load-env # Writes .env.local from active Doppler configdoppler setupstores the project and config against this directory in~/.doppler(one-time step per clone).bun run load-envis the only command that talks to Doppler. Next.js loads.env.localautomatically, so standard commands (bun run dev,bun run build) work without wrappingdoppler run.- Re-run
bun run load-envafter changing variables in Doppler or switching configs withdoppler setup --config <name>. bun run load-env --checkreports whether.env.localis in sync with Doppler and exits non-zero if drift is detected (without writing)..env.localis gitignored and written with owner-only permissions (0600).- To inspect secrets without writing to disk, run
doppler secrets.
Launch the infrastructure services (MongoDB 8, Redis 8, and RustFS storage with automatic bucket creation) using Docker Compose:
docker compose up -dbun installGit hooks (Lefthook, commitlint) are installed automatically during
bun install.
Payload requires generated TypeScript types and an import map for the admin panel (this also runs next typegen for the route types):
bun run generateAlways run
bun run generateafter adding or changing collections, globals, blocks, or fields.
Start the Next.js development server:
bun run dev # http://localhost:3000
bun run dev --tunnel # same, also exposed through the Cloudflare Tunnel (needs CLOUDFLARE_TUNNEL_*)Storybook and the email previews run as separate processes:
bun run dev:app # Plain `next dev`, without the --tunnel wrapper
bun run dev:storybook # Storybook (http://localhost:6006)
bun run dev:email # React Email preview server (http://localhost:3005)- Frontend: http://localhost:3000
- Admin Panel: http://localhost:3000/admin
- Storybook: http://localhost:6006
- React Email Previews: http://localhost:3005
- Next.js App Router:
app/(frontend)/: Public website pages, layouts, and route handlers:/api/preview,/api/sse,/api/health,/api/icons/collection,/blog/feed.xmland/download/resume.pdf. CMS pages are under[slug]/, the blog underblog/, resume documents underresume/[slug]/.app/(payload)/: Payload CMS admin panel (/admin) and Payload's REST API (/api/*; GraphQL is disabled).app/llms.txt/: The site's public/llms.txt, generated from published pages, posts and topics (unrelated to the repository's ownllms.txtin the project root).app/robots.ts,app/manifest.ts,app/global-error.tsx: Crawler rules, the web app manifest and the root error boundary. These three have to stay at the root ofapp/; Next.js does not pick them up inside a route group.robots.txtdisallows everything unlessSERVER_HOSTequalsPRODUCTION_HOST.app/(frontend)/sitemap.ts: The sitemap.
- Request Proxy:
proxy.ts(Next.js 16's replacement formiddleware.ts) canonicalizes blog listing URLs and marks non-default sortsnoindex. - Payload Configuration:
payload.config.tsin the project root, integrated into Next.js viawithPayloadinnext.config.ts. - Jobs Queue & Worker:
src/jobs-queue/and standalone background worker entrypointscripts/start-worker.mjswithscripts/health-server.ts. The web process only enqueues jobs; the worker runs them. - Dev Runner:
scripts/dev.mjs(anext devwrapper that owns the--tunnelflag). - Environment Loader:
scripts/load-env.mjs(fetches active Doppler secrets into.env.local).
| Script | Description |
|---|---|
bun run _email |
Wrapper to execute React Email CLI commands. |
bun run _next |
Wrapper to execute Next.js CLI commands with telemetry disabled. |
bun run _payload |
Wrapper to execute Payload CLI commands (used by migrate, generate and the other scripts). |
bun run _storybook |
Wrapper to execute Storybook CLI commands. |
bun run analyze |
Production build with @next/bundle-analyzer; opens the bundle reports when it finishes. |
bun run build |
Production build (next build --webpack). |
bun run build:storybook |
Builds static Storybook documentation into dist/. |
bun run chore:reinstall |
Cleans node_modules and bun.lock, then runs fresh bun install. |
bun run ci |
Local convenience: runs database migrations and a full production build (CI itself runs the steps separately). |
bun run dev |
Starts the Next.js dev server through scripts/dev.mjs (localhost:3000, supports --tunnel). |
bun run dev:app |
Starts the Next.js dev server directly, without the tunnel wrapper. |
bun run dev:email |
Starts React Email development server on port 3005 (src/emails). |
bun run dev:storybook |
Starts Storybook development server (localhost:6006). |
bun run generate |
Runs generate:next-types, generate:payload-types and generate:importmap in sequence. |
bun run generate:importmap |
Regenerates Payload admin component import map (app/(payload)/admin/importMap.js). |
bun run generate:next-types |
Generates Next.js route types (next typegen). |
bun run generate:payload-types |
Generates TypeScript types for Payload collections and globals (src/types/payload.ts). |
bun run links:migrate |
Runs database migration for link field naming. |
bun run lint |
Runs oxlint and oxfmt --check (linting and format checks). |
bun run lint:fix |
Runs oxlint --fix and oxfmt to auto-fix what they can. |
bun run load-env |
Writes .env.local from active Doppler config (--check validates drift). |
bun run migrate |
Runs database migrations via Payload CLI. |
bun run packages:fix |
Runs Syncpack to automatically align dependency versions. |
bun run packages:format |
Formats package.json field order using Syncpack. |
bun run packages:lint |
Runs Syncpack to check dependency version consistency across packages. |
bun run packages:update |
Updates dependency versions using Syncpack. |
bun run prepare |
Installs Lefthook Git hooks. |
bun run refs:backfill |
Rebuilds content reference index table. |
bun run release |
Runs semantic-release to calculate version, tag, and publish changelog. |
bun run release:dry-run |
Runs semantic-release in dry-run mode. |
bun run seed:pages |
Seeds fixture pages (-- --count <n>, default 10; seed:pages:clean removes them). |
bun run seed:pages:clean |
Removes fixture pages. |
bun run seed:posts |
Seeds fixture blog posts and media (-- --count <n>, default 10; seed:posts:clean removes them). |
bun run seed:posts:clean |
Removes fixture blog posts and media. |
bun run seed:resume-documents |
Seeds an older and a newer fixture resume document without PDFs (seed:resume-documents:clean removes them). |
bun run seed:resume-documents:clean |
Removes fixture resume documents. |
bun run seed:topics |
Seeds fixture blog topics (-- --count <n>, default 6; seed:topics:clean removes them). |
bun run seed:topics:clean |
Removes fixture blog topics. |
bun run start:app |
Starts the production Next.js application server. |
bun run start:storybook |
Serves the static Storybook build on port 3020. |
bun run start:worker |
Runs standalone background jobs worker and health monitoring server. |
bun run test |
Runs unit tests once via Vitest. |
bun run test:coverage |
Runs Vitest with v8 code coverage reporting. |
bun run test:e2e |
Runs Playwright E2E tests against .env.test. |
bun run test:e2e:docker |
Runs Playwright E2E tests inside Docker Chromium container, against a dev server on the host. |
bun run test:e2e:ui |
Runs Playwright E2E tests with interactive UI. |
bun run test:watch |
Runs Vitest in interactive watch mode. |
bun run typecheck |
Runs TypeScript typechecker (tsc --noEmit). |
The source of truth is declared as a Zod schema in src/types/environment.ts. It is validated by next.config.ts during startup, failing fast if required variables are missing or malformed.
| Variable | Type / Requirement | Purpose |
|---|---|---|
NODE_ENV |
Optional (development | production | test) |
Runtime environment (defaults to development). |
SERVER_HOST |
Required | Host/port used for server-side URL construction. |
PRODUCTION_HOST |
Required | Host of the production site. An environment counts as production when SERVER_HOST equals it; everywhere else robots.txt disallows all crawlers and generated PDFs carry a DRAFT watermark. |
SERVER_URL |
Required (URL) | Public URL of the site, read server-side. |
NEXT_PUBLIC_SERVER_URL |
Required (URL) | Same value as SERVER_URL, exposed to the browser for the live-preview listener. |
RESUME_REDIRECT_URL_BASE |
Required (URL) | Base URL for the redirect target of /download/resume.pdf and localized resume jobs. |
PAYLOAD_SECRET |
Required | Secret key used to encrypt Payload JWT tokens. |
PREVIEW_SECRET |
Required | Shared secret for draft/preview authentication. |
CRON_SECRET |
Required | Reserved for securing cron/scheduled routes; declared but not read anywhere yet. |
| Variable | Type / Requirement | Purpose |
|---|---|---|
DATABASE_URL |
Required | MongoDB connection string (e.g. mongodb://127.0.0.1:27017/productiondb). |
REDIS_URL |
Required | Redis connection string (used for KV adapter, cache handler, and SSE pub/sub). |
| Variable | Type / Requirement | Purpose |
|---|---|---|
S3_BUCKET |
Required | S3 bucket name. |
S3_ENDPOINT |
Required | S3 API endpoint URL (e.g. http://127.0.0.1:9000 locally). |
S3_REGION |
Required | S3 region identifier (us-east-1 for local RustFS). |
S3_ACCESS_KEY |
Required | S3 access key ID. |
S3_SECRET_KEY |
Required | S3 secret access key. |
| Variable | Type / Requirement | Purpose |
|---|---|---|
PAYLOAD_JOBS_ENABLE_APP_WORKERS |
Optional (true | false) |
Enables embedded job workers in the main Next.js app process (defaults to false; the standalone worker normally runs the jobs). |
| Variable | Type / Requirement | Purpose |
|---|---|---|
NEXT_PUBLIC_STATUS_PAGE_URL |
Required (URL) | Public status page URL, read in the browser. |
STATUS_PAGE_HEARTBEAT_URL |
Required (URL) | Status page heartbeat endpoint the server fetches (Redis-cached) for the service-status indicator. |
STATUS_PAGE_WORKER_HEARTBEAT_PUSH_URL |
Optional (URL) | Push monitor URL the worker's heartbeatPing job calls every minute; without it the job only logs. |
| Variable | Type / Requirement | Purpose |
|---|---|---|
NEXT_PUBLIC_UMAMI_URL |
Required | Umami analytics script URL / rewrite target. |
NEXT_PUBLIC_UMAMI_SITE_ID |
Required (UUID) | Umami tracking site UUID. |
UMAMI_USERNAME |
Required | Umami account username for server-side stats queries. |
UMAMI_PASSWORD |
Required | Umami account password for server-side stats queries. |
USESEND_URL |
Required (URL) | UseSend transactional email endpoint. |
USESEND_API_KEY |
Required | UseSend API authorization key. |
USESEND_DEFAULT_FROM_ADDRESS |
Required (Email) | Default sender email address. |
USESEND_DEFAULT_FROM_NAME |
Required | Default sender display name. |
| Variable | Type / Requirement | Purpose |
|---|---|---|
NEXT_PUBLIC_ICONIFY_API |
Optional (URL) | Self-hosted Iconify API (defaults to https://icons.heene.io). |
ANTHROPIC_API_KEY |
Required | API key for Anthropic Claude (alt-text, meta descriptions, blog excerpts, resume translation). |
MAPBOX_API_KEY |
Required | Mapbox access token for address and coordinate lookups. |
UNSPLASH_ACCESS_KEY |
Optional | Unsplash API access key for stock photo search & direct media import. |
| Variable | Type / Requirement | Purpose |
|---|---|---|
CLOUDFLARE_TUNNEL_HOST |
Optional | Hostname for local dev tunnel. |
CLOUDFLARE_TUNNEL_URL |
Optional (URL) | Target URL for Cloudflare tunnel. |
CLOUDFLARE_TUNNEL_TOKEN |
Optional | Cloudflare tunnel authentication token. |
Build-Time Inlining Notice:
NEXT_PUBLIC_*values are inlined into the client bundle, and route shells prerendered withcacheComponents: truecapture server-side values. Ensure correct values are present duringbun run build.
.
├── app/ # Next.js App Router
│ ├── (frontend)/ # Public website routes, layouts, and API routes
│ │ ├── [slug]/ # CMS-driven pages
│ │ ├── api/ # Route handlers (preview, sse, health, icons/collection)
│ │ ├── blog/ # Blog listing, topics, posts, and the RSS feed (feed.xml)
│ │ ├── download/ # /download/resume.pdf redirect
│ │ ├── resume/ # Resume documents by slug
│ │ ├── sitemap.ts # Sitemap
│ │ └── layout.tsx # Frontend root layout
│ ├── (payload)/ # Payload CMS admin routes
│ │ ├── admin/ # Payload admin UI routes (and the generated import map)
│ │ ├── api/ # Payload REST API endpoints
│ │ └── layout.tsx # Payload admin layout
│ ├── llms.txt/ # Public /llms.txt route
│ ├── robots.ts # Crawler rules ┐
│ ├── manifest.ts # Web app manifest ├ must stay at the app root
│ └── global-error.tsx # Root error boundary ┘
├── src/ # Application source code
│ ├── blocks/ # Payload blocks (rich text, code, feature cards, resume sections, etc.)
│ ├── collections/ # Payload collections (Media*, Pages, BlogPosts, BlogTopics, Resume*, Redirects, References, Users)
│ ├── components/ # React UI components
│ ├── emails/ # React Email templates
│ ├── fields/ # Reusable Payload field factories (Slug, HeroSlides, Link, etc.)
│ ├── fonts/ # Custom font definitions
│ ├── globals/ # Payload globals (SiteSettings, SettingsGlobalUser, PDFGeneratorSettings)
│ ├── jobs-queue/ # Payload job tasks, workflows, their handlers, and health checks
│ ├── lib/ # Utilities, grouped by domain: access/, hooks/, payloadHooks/, actions/,
│ │ # fetchers/, sse/, seed/, redirects/, references/, anthropic/, etc.
│ ├── migrations/ # Database migration scripts
│ ├── pdf/ # PDF resume generator components (@react-pdf/renderer)
│ ├── plugins/ # Custom Payload plugins (redirects, references)
│ ├── styles/ # CSS and Tailwind 4 stylesheets
│ ├── types/ # TypeScript declarations, environment schema, generated types
│ └── widgets/ # Custom Payload dashboard widgets
├── .storybook/ # Storybook config, component stories, and foundations/shaders docs (MDX)
├── .github/ # Workflows (lint-and-test, build-and-deploy), setup action, Dependabot
├── public/ # Static public assets (favicons, OG image, Payload admin assets)
├── scripts/ # Utility scripts (dev runner, worker, seeders, environment loader, image assembly)
├── tools/oxlint/anti-slop/ # Vendored Oxlint rule plugin
├── patches/ # Bun dependency patches (pdfjs-dist)
├── e2e/ # Playwright end-to-end test specs
├── Dockerfile # COPY-only images: app, worker, storybook
├── docker-compose.yml # Local development infrastructure (MongoDB, Redis, RustFS)
├── next.config.ts # Next.js configuration, env validation & Payload integration
├── payload.config.ts # Payload CMS master configuration
├── proxy.ts # Next.js request proxy (blog URL canonicalization)
├── instrumentation.ts # Next.js server instrumentation hook
├── instrumentation-client.ts # First-pageview Umami tracking
├── llms.txt # Entry points of this repository for AI coding agents
├── oxlint.config.ts # Oxlint rules
├── oxfmt.config.ts # Oxfmt formatting and import order
├── lefthook.yml # Git hooks
├── commitlint.config.mjs # Conventional Commits rules
├── release.config.mjs # semantic-release configuration
├── syncpack.config.mjs # Dependency version groups
├── bunfig.toml # Bun install settings
├── playwright.config.ts # Playwright E2E configuration
├── vitest.config.ts # Vitest unit test configuration
├── vitest.setup.ts # Vitest environment setup and mocks
└── package.json # Dependencies, scripts, and engine requirements
Unit tests are co-located next to the code under test as *.test.ts / *.test.tsx.
bun run test # Run all unit tests once
bun run test:watch # Run unit tests in interactive watch mode
bun run test:coverage # Generate test coverage report- Configuration:
vitest.config.ts. - Mocks & environment:
vitest.setup.tsforcesTZ=UTCand mockspayload,next/cache,server-only, andredis. Real implementations oftailwind-merge,date-fns,slugify,pupa, andneotraverseare tested directly.
E2E tests live in e2e/*.spec.ts and test structural health and smoke flows.
bun run test:e2e # Run Playwright tests headlessly
bun run test:e2e:ui # Open Playwright interactive UI test runner
bun run test:e2e:docker # Run tests in Playwright Docker Chromium containerE2E Prerequisites:
- Run
docker compose up -dso MongoDB, Redis, and RustFS storage are active. - E2E tests execute using
.env.testwith dummy valid credentials. bun run test:e2eautomatically starts or reuses the local dev server. For Docker tests (bun run test:e2e:docker), start the dev server beforehand withbun run dev:app.E2E_BASE_URL,E2E_SERVER_COMMANDandE2E_NO_SERVERoverride the target URL, the server command, and whether Playwright starts a server at all (seeplaywright.config.ts).
The E2E job in CI is currently disabled (commented out in
lint-and-test.yml); run the suite locally.
Seed fixture content into local MongoDB for development and testing:
# Seed Topics (categories)
bun run seed:topics # Creates 6 fixture blog topics
bun run seed:topics:clean # Removes seeded blog topics
# Seed Blog Posts (with images)
bun run seed:posts # Creates blog topics & 10 posts with downloaded images
bun run seed:posts:clean # Removes seeded posts and associated media
# Pass custom count via: bun run seed:posts -- --count 25
# Seed Pages
bun run seed:pages # Creates 10 fixture pages
bun run seed:pages:clean # Removes seeded pages
# Pass custom count via: bun run seed:pages -- --count 5
# Seed Resume Documents
bun run seed:resume-documents # Creates an older and a newer resume document (no PDFs)
bun run seed:resume-documents:clean # Removes themThe same seed/clean logic (src/lib/seed/) is also available from the admin panel, where it runs as a queued job.
- Seeders are idempotent and match by slug.
- Article prose and layouts are deterministic per title using a seeded pseudo-random generator.
- Images are downloaded from
picsum.photosand uploaded into Payload media.
Everything is built in CI; Docker images only COPY finished output (no package manager, installs or builds inside an image). Pull requests are gated by lint-and-test.yml, pushes are built and deployed by build-and-deploy.yml. Both use the composite action .github/actions/setup (Bun, Node.js 26, bun install --frozen-lockfile).
- Pull requests to
main/develop(lint-and-test.yml, required by branch protection):- Lint:
commitlintover the PR's commits,oxlint+oxfmt --check,syncpack, a check that the committed generated files (src/types/payload.ts, the admin import map) matchbun run generate, andactionlint. - Semgrep: static security analysis with the
nodejs,typescript,reactandsecretsrulesets. Accepted findings are marked in place with// nosemgrep: <rule>and a reason. - Unit Tests:
vitest run --coverage, with the coverage report uploaded as an artifact. - E2E: currently disabled (the job is commented out in the workflow).
- Lint:
- Pushes to
develop/main(build-and-deploy.yml, also runnable throughworkflow_dispatchon those two branches):- Bump version: semver bump, tag and changelog via
semantic-release(release.config.mjs), committed as a GitHub App so the push passesmain's ruleset.maincuts releases (vX.Y.Z, images also taggedlatest),developcuts release candidates (vX.Y.Z-rc.N, images also taggededge). After amainrelease,developis fast-forwarded tomainwhen it has no commits of its own; otherwise it has to be brought up to date by hand, sincedeveloponly cuts release candidates while it contains everymainrelease. - Deploy (environment
Productiononmain,Developmentondevelop): connects to the tailnet, runsbun run migrateagainst the target database, runs onenext build --webpackwith that environment's values, builds Storybook, assemblesout/{web,worker,storybook}(scripts/assemble-images.mjs), builds the COPY-only images (app,worker,storybookinDockerfile) forlinux/amd64, boot-checks each one as a real container (/api/healthon 3000,/healthon 3010, Storybook on 3020), pushes them toghcr.io/danielheene/website/*and triggers the Dokploy redeploy webhooks. The lint and test jobs are not re-run here; the merged commit already passed them as PR checks.
- Bump version: semver bump, tag and changelog via
Each push to main/develop builds and deploys against that environment's real secrets, so environment-specific values the browser needs (site URL, status page, Umami, the Iconify API) are ordinary NEXT_PUBLIC_* environment variables read via process.env.NEXT_PUBLIC_*.
Lefthook (lefthook.yml) manages Git hooks, installed automatically during bun install:
pre-commit: Runsoxfmtandoxlint --fixon staged files. Whenpackage.jsonis staged it also runs syncpack (format+fix) andbun install, re-stagingbun.lock.commit-msg: Enforces Conventional Commits format usingcommitlint.
Example valid commit messages:
feat(admin): add Iconify icon picker field
fix(media): sanitize SVG uploads before rendering
chore: update dependencies
This project is licensed under the MIT License.