Getting Started
Development setup
The White Tree Digital repo is a monorepo with two independent packages — website/ (the Astro 6 marketing site) and studio/ (the Sanity v5 content Studio) — each with its own package.json, its own dev server, and its own port; you install and run them separately.
This page covers the local loop: install, dev, build, preview, typecheck, and test for both packages, plus the two footguns worth memorizing before you run anything. Environment variables and secrets are documented in Environment variables; shipping to production is in Deployment.
Mental model
There's no root-level install or root package.json script orchestration — website/ and studio/ are two separate npm projects that happen to live side by side. You cd into one, install it, run it. They don't share a Git history or a lockfile.
website/ | studio/ | |
|---|---|---|
| What it is | Astro 6 + Tailwind v4 + React 19 islands, SSR on Cloudflare Workers | Sanity v5 Studio (content authoring) |
| Dev port | http://localhost:4321 | http://localhost:3333 |
| Reads/writes Sanity | reads only | authors content |
| Node | ≥22.12 | — |
The website only reads from Sanity; never write to the dataset from website code. All authoring happens in the Studio.
Website (website/)
From the website/ directory:
npm install
npm run dev # http://localhost:4321
npm run build # astro build (hybrid SSR output)
npm run preview # serve the production build locally
npx astro check # typecheck (safe alongside dev)
npx vitest run # audit-tool checks + section-type drift test
npm run dev starts astro dev on port 4321. Sanity-driven pages need network access to reach the dataset, so the dev server is not fully offline.
Build targets
The same repo builds two ways, selected by the BUILD_TARGET env var (the full rationale is in Two-build split):
npm run build # astro build → server (SSR) output, used by staging
npm run build:static # cross-env BUILD_TARGET=static astro build → static public site
npm run build:static sets BUILD_TARGET=static (via cross-env, so it works on every OS) and prerenders content pages to HTML. Both builds stay hybrid — /api/audit and /api/draft-mode/* keep export const prerender = false, so even the static build emits dist/server/ (required for wrangler deploy). For day-to-day local work, plain npm run dev / npm run build is what you want.
Never run a build while the dev server is up
Running npm run build (or npm run build:static) while npm run dev is running corrupts the dev server's Vite cache — it 404s the dev server's bundled gsap dependency, which silently breaks every client script (hero animation, scroll reveals, the audit island). If you must build, stop the dev server first, then restart astro dev afterward. npx astro check is safe to run alongside dev; only the build clobbers the cache.
Typecheck
npx astro check
astro check runs the TypeScript + Astro diagnostics over the whole site (@astrojs/check is a devDependency). It's safe to run while the dev server is up, and it's the cheapest way to catch a broken prop or import before a build.
Tests (Vitest)
npx vitest run # one-shot (also: npm run test)
npx vitest # watch mode (also: npm run test:watch)
Vitest covers the audit tool's check logic and a section-type drift guard. The config (website/vitest.config.ts) is deliberately minimal:
import {defineConfig} from 'vitest/config';
// Checks are pure functions over plain SiteData objects — no DOM, no network.
export default defineConfig({
test: {
environment: 'node',
include: ['src/**/*.test.ts'],
},
});
The node environment (no jsdom, no network) is a deliberate contract: the audit checks are pure functions over plain SiteData objects, and they're tested against hand-authored fixtures so the truth table is deterministic and offline. Two tests worth knowing about:
src/components/audit/checks.test.ts— the audit scoring/teaser checks (see Audit checks).src/components/sectionTypes.test.ts— the section-type drift test. It enforces a three-way contract: the Studio section registry, the website'sSectionRenderer, and theSectionDataunion must all stay in sync. If you add a section type to one but not the others, this test fails. (Adding a section is covered in Adding a section.)
Other website scripts
npm run refresh-psi # tsx scripts/refresh-psi.ts — refresh cached PageSpeed Insights data
npm run deploy:public # POST the public deploy hook (reads DEPLOY_HOOK_URL) — see Deployment
deploy:public fires the Cloudflare Workers Build deploy hook to rebuild the public static site on demand; it's a deploy action, not part of the local loop — details in Deployment.
Studio (studio/)
From the studio/ directory:
npm install
npm run dev # http://localhost:3333
npm run build # sanity build
npm run deploy # publishes to whitetreedigital.sanity.studio
npm run dev runs sanity dev on port 3333. npm run build is sanity build; npm run deploy is sanity deploy, which publishes the hosted Studio at whitetreedigital.sanity.studio (deploy mechanics are in Deployment).
The Studio targets project ID 8ftz0iuv and the single production dataset; both are pinned in sanity.config.ts and sanity.cli.ts, so CLI commands (sanity exec, sanity dataset list, …) hit production by default.
Studio scripts (sanity exec)
Seed and maintenance scripts run under sanity exec's Node context with a user token:
npx sanity exec scripts/seed-homepage-sections.ts --with-user-token
npx sanity exec scripts/init-site-styles.ts --with-user-token
See Studio & singletons for what each script does and when to run it.
Never delete a Sanity singleton to reset it
Don't run npx sanity documents delete <singleton-id> (e.g. globalStyles, homepage) to "reset" a singleton — Sanity tombstones the ID, and Studio's initialValue won't recover it afterward. The only recovery path is then client.createOrReplace(...). To reset color tokens, re-run scripts/init-site-styles.ts (which uses createOrReplace), never delete-then-recreate.
Running both together (Presentation Tool)
The Studio's Presentation Tool (split-screen visual editing) iframes the live, draft-aware website. For it to work locally, both dev servers must be running at once:
npm run devinwebsite/→http://localhost:4321npm run devinstudio/→http://localhost:3333
The Studio's SANITY_STUDIO_PREVIEW_URL (in studio/.env.development) points at http://localhost:4321, so the iframe loads your local Astro site. If the website dev server isn't up, the Presentation iframe shows nothing.
For draft preview specifically, the same pairing applies: visiting the site in draft mode requires the website dev server to be running so it can read the preview cookie at request time and fetch with the stega-enabled client. The full handshake, the companion-cookie HMAC gate, and the stega rules live in Presentation Tool & draft mode.
Stega breaks string equality in draft preview
In draft/preview mode the Sanity client encodes every string field with invisible Unicode tag characters (for click-to-edit). They're invisible but not zero-length, so an exact comparison like variant === 'sidebar' silently fails in draft while passing in production. Call stegaClean() from @sanity/client/stega on any Sanity string that drives logic (equality, switch, regex, an object/array index key) before comparing. This is a draft-only footgun — production has no stega, so the bug never surfaces there. Details and examples in Presentation Tool & draft mode.
Point local Presentation at your local website, not the deployed one
While iterating on the website, keep studio/.env.development's SANITY_STUDIO_PREVIEW_URL on http://localhost:4321. If you point it at the deployed Workers URL, the iframe serves the main branch — your local feature-branch changes won't appear in Presentation.
Environment & secrets
Both packages read env vars, but secrets never go in .env. The website splits config into two layers: PUBLIC_* build-time vars in .env (copy .env.example → .env) and runtime secrets (SANITY_API_READ_TOKEN, PSI_API_KEY) in .dev.vars. Both .env and .dev.vars are gitignored.
Secrets live in .dev.vars, never .env
SANITY_API_READ_TOKEN and PSI_API_KEY are runtime secrets read via cloudflare:workers env — they belong in website/.dev.vars locally (and as encrypted Secrets in the CF Workers dashboard in production), never in .env and never committed. Putting them in .env exposes them to the client build.
The full variable contract — every PUBLIC_* var, what each secret is for, and how the Studio's preview/deploy-hook vars are split across .env.development / .env.production — is in Environment variables.
Tailwind v4
The website uses Tailwind v4 via @tailwindcss/vite — there's no tailwind.config.js; theme tokens are declared in CSS with @theme in src/styles/global.css. The project's Tailwind rules (no @apply, no deprecated v3 utilities, line-height modifiers instead of leading-*, gap instead of space-x-*) and the v4 upgrade procedure are documented in website/tailwind.md. The upgrade tool, if you ever need it:
npx @tailwindcss/upgrade@latest
See Tailwind v4 for the full ruleset and Brand tokens & theme for the token set.
Where this lives
| File | What's in it |
|---|---|
website/README.md | Website commands, ports, and the build-vs-dev cache warning |
website/package.json | Website scripts (dev, build, build:static, preview, test, refresh-psi, deploy:public) and the Node ≥22.12 engine pin |
website/vitest.config.ts | Vitest config — node environment, src/**/*.test.ts |
website/src/components/sectionTypes.test.ts | The three-way section-type drift test |
website/tailwind.md | Tailwind v4 rules + upgrade procedure |
studio/README.md | Studio dev/build/deploy commands and the website-server requirement for Presentation |
studio/package.json | Studio scripts (dev, build, deploy) and dependencies |
studio/CLAUDE.md | Studio architecture, scripts, singleton mechanics, Presentation Tool, defaults |