diff --git a/.cursor/rules/core.mdc b/.cursor/rules/core.mdc new file mode 100644 index 0000000..1fb0fe7 --- /dev/null +++ b/.cursor/rules/core.mdc @@ -0,0 +1,14 @@ +--- +description: Project-wide guardrails. Always applied. +alwaysApply: true +--- + +Canonical guardrails: `/AGENTS.md`. Read it before writing code in this repo. + +Top-level rules (do not duplicate — see AGENTS.md for the full context): + +- Reuse before invention; grep `studio/schemas/objects/modules/`, `web/src/components/modules/`, `web/sanity/queries/snippets/` first. +- Content blocks are **modules** — paired Sanity object + React component, wired in 8 places. Skipping any one is a bug. +- Locale-aware at render time, never inside GROQ. Always use `pickLocalizedString` / `parseLocalizedText` from `web/sanity/utils/sanityLocalizedText.ts`. +- After schema edits: `pnpm studio:generate` → `pnpm typecheck` → `pnpm format`. Never edit `studio/sanity.types.gen.ts` or `studio/schema.json`. +- `web/sanity/types/*` are hand-maintained; keep aligned with field changes. diff --git a/.cursor/rules/modules.mdc b/.cursor/rules/modules.mdc new file mode 100644 index 0000000..1aef694 --- /dev/null +++ b/.cursor/rules/modules.mdc @@ -0,0 +1,36 @@ +--- +description: Module pattern — 8-step wiring. Applies when editing modules anywhere. +globs: + - studio/schemas/objects/modules/** + - web/src/components/modules/** + - web/sanity/queries/components/modules/** + - web/sanity/types/modules/** +--- + +A **module** is a paired Sanity object (`module.`) plus a React component (`Module.tsx`). Adding, renaming, or removing one is atomic across **8 files**: + +1. `studio/schemas/objects/modules/module.ts` — `defineType`, `name: "module."`, fields, `preview`, optional `icon`. +2. `studio/schemas/index.ts` — import + add to `schemaTypes`. +3. `studio/schemas/objects/editors/richTextMedia.ts` — append `{ type: "module." }` to `of`. +4. `studio/schemas/fields/modulesArrayField.ts` — append `{ type: "module." }` to `moduleTypes`. +5. `web/src/components/modules/Module.tsx` — accepts `{ data, locale, siteLocale }`, sets `data-sanity` attrs. +6. `web/src/components/modules/index.ts` — re-export from barrel and register in `ModulesRenderer.tsx`. +7. `web/sanity/queries/components/modules/.ts` — GROQ projection, full i18n arrays, re-export from `index.ts`. +8. `web/sanity/types/modules/.ts` — hand-maintained TS shape, re-export from `index.ts`. + +After steps 1–4 land, run `pnpm studio:generate` and commit the gen artifacts. + +DO: + +- Ship all 8 in a single commit. +- Reuse `media.image`, `media.video`, `media.videoLoop` from `studio/schemas/objects/media/`. +- Project i18n fields as full `{ _key, _type, language, value }` arrays. +- Read i18n via `pickLocalizedString` / `parseLocalizedText`. + +DON'T: + +- Land schema without component (or vice versa). +- Add `{ type: "module." }` to only one of `richTextMedia.ts` / `modulesArrayField.ts`. +- Index i18n arrays directly (`title[0].value`). +- `coalesce(field[language==$locale].value, ...)` in GROQ. +- Hand-edit `studio/sanity.types.gen.ts` or `studio/schema.json`. diff --git a/.cursor/rules/schemas.mdc b/.cursor/rules/schemas.mdc new file mode 100644 index 0000000..b8086cb --- /dev/null +++ b/.cursor/rules/schemas.mdc @@ -0,0 +1,36 @@ +--- +description: Sanity schema conventions and typegen workflow. +globs: + - studio/schemas/** + - studio/config/** +--- + +Sanity Studio v5. Schemas live under `studio/schemas/`. Indent 2 spaces, double quotes. + +Hard rules: + +- A new type is invisible until exported and added to `schemaTypes` in `studio/schemas/index.ts`. +- New document types also need a structure item under `studio/config/structure/items/` and registration in `studio/config/structure/index.ts`. The sidebar does NOT auto-populate from schemas. +- Routable types must be wired into Presentation: `studio/config/presentation/conventions.ts` (`SLUG_BASED_DOCUMENT_TYPES`, `SITE_ROOT_DOCUMENT_TYPES`, `DOCUMENT_TYPES_WITHOUT_WEB_PREVIEW`) and `resolve.ts` / `locationsResolver.ts`. +- Internally linkable types go into `studio/schemas/constants/references.ts` (`PAGE_REFERENCES`). +- Translatable fields use `internationalizedArrayString` / `internationalizedArrayRichText` / `internationalizedArrayRichTextMedia` — never plain `string` for translatable content. +- Slug fields use `validateSlug` from `studio/utils/validateSlug.ts`. +- Reuse `media.image`, `media.video`, `media.videoLoop` from `studio/schemas/objects/media/`. + +After every schema edit: + +``` +pnpm studio:generate +git add studio/schema.json studio/sanity.types.gen.ts +pnpm typecheck +``` + +CI rejects any uncommitted gen diff. + +NEVER edit: + +- `studio/sanity.types.gen.ts` +- `studio/schema.json` +- Anything under `studio/.sanity/` + +Languages come from the `siteLanguageSettings` singleton — add new languages there, not in source. `web/src/i18n/fallbackSiteLocales.ts` is the offline fallback only. diff --git a/.cursor/rules/web.mdc b/.cursor/rules/web.mdc new file mode 100644 index 0000000..1fcb3e3 --- /dev/null +++ b/.cursor/rules/web.mdc @@ -0,0 +1,33 @@ +--- +description: Next.js web app conventions — routing, i18n, components. +globs: + - web/** +--- + +Next.js 16 App Router. RSC by default; `"use client"` only when needed (state, effects, browser APIs). Tailwind v4. Biome: tabs, double quotes. Path alias `@/*` → `web/`. + +Hard rules: + +- Thread `{ locale, siteLocale }` through every render path that touches Sanity content. +- Resolve translatable fields via `pickLocalizedString` / `parseLocalizedText` from `web/sanity/utils/sanityLocalizedText.ts`. Never index i18n arrays. +- No locale filtering inside GROQ — projections return full `{ _key, _type, language, value }` arrays. +- Content blocks are **modules** (see `.cursor/rules/modules.mdc`). No ad-hoc `
` in route files. +- `web/sanity/types/*` are hand-maintained. Update them in the same commit as the GROQ projection. +- Set `data-sanity` attrs on Sanity-rendered roots (copy from `ModuleText.tsx` / `MediaImage.tsx`) — Visual Editing depends on it. +- Never read locale from `useRouter()`, `usePathname()`, `window.location`, or cookies inside a module — breaks SSR + Presentation iframes. +- No hardcoded locale codes outside `web/src/i18n/`. + +URL/locale helpers live in `web/src/i18n/` (`paths.ts`, `siteLocalePathUtils.ts`, `site-locales.ts`, `proxyLocaleFetch.ts`). Use them; do not concatenate locale prefixes by hand. + +After web changes: + +``` +pnpm typecheck +pnpm format +``` + +NEVER edit: + +- `web/.next/` +- Any `*.gen.*` files +- Imports from `studio/...` (use `packages/*` for shared code). diff --git a/.github/copilot-instructions.md b/.github/copilot-instructions.md new file mode 100644 index 0000000..26574d1 --- /dev/null +++ b/.github/copilot-instructions.md @@ -0,0 +1,57 @@ +# GitHub Copilot — repository instructions + +Full guardrails live in `AGENTS.md` at the repo root. This file is a condensed mirror for inline suggestions. + +## Repo at a glance + +- pnpm monorepo: `web` (Next.js 16 App Router), `studio` (Sanity v5), shared `packages/*`. +- Editorial content is rendered via the **module pattern**: every content block is a paired `module.` Sanity object + `Module.tsx` React component, wired in 8 places. +- Multilingual content uses `@sanity/internationalized-array`. Locale is resolved at **render time** via `web/sanity/utils/sanityLocalizedText.ts`, never inside GROQ. + +## Top rules + +1. Reuse before invention — check `studio/schemas/objects/modules/`, `web/src/components/modules/`, `web/sanity/queries/snippets/`, `web/sanity/utils/` first. +2. New content block → it is a **module**. Touch all 8 wiring points or revert: + - `studio/schemas/objects/modules/module.ts` + - `studio/schemas/index.ts` + - `studio/schemas/objects/editors/richTextMedia.ts` + - `studio/schemas/fields/modulesArrayField.ts` + - `web/src/components/modules/Module.tsx` + - `web/src/components/modules/index.ts` (+ register in `ModulesRenderer.tsx`) + - `web/sanity/queries/components/modules/.ts` (+ barrel) + - `web/sanity/types/modules/.ts` (+ barrel) +3. After every schema edit: `pnpm studio:generate`, then `pnpm typecheck`, then `pnpm format`. +4. Never edit `studio/sanity.types.gen.ts` or `studio/schema.json` — generated. +5. `web/sanity/types/*` are hand-maintained — update them when schema fields change. +6. Locale-aware always: use `pickLocalizedString(field, locale, siteLocale)` or `parseLocalizedText({ value, locale, siteLocale, as })`. Never `field[0].value` or `field.find(t => t.language === locale)`. Never `coalesce(field[language==$locale].value, ...)` in GROQ. +7. Add a new language only via the `siteLanguageSettings` Studio singleton (offline fallback: `web/src/i18n/fallbackSiteLocales.ts`). No schema, query, or component change. +8. No hardcoded locale codes outside `web/src/i18n/`. +9. No imports from `studio/` inside `web/` (or vice versa). Share via `packages/*`. + +## Naming + +- Sanity module name `module.` ↔ React component `Module.tsx`. +- Path alias `@/*` resolves to `web/` only. +- Biome: tabs (web + root), 2 spaces (studio), double quotes throughout. + +## Decision tree — where things live + +- Authored content block? → module. +- Layout primitive (header, footer, container)? → `web/src/components/{navigation,theme,…}/`. +- Sanity-only helper? → `studio/utils/` or `studio/config/`. +- Shared between web + studio? → a package in `packages/*`. +- Locale string / URL helper? → `web/src/i18n/`. +- Reusable GROQ fragment? → `web/sanity/queries/snippets/`. + +## Branches + +The repo has two long-lived branches with non-trivial differences: + +- `main` — document types `page`, `project`, `projectCategory`, `work`; four web module renderers all under `web/src/components/modules/` with an `index.ts` barrel; `module.contentRefs` schema supports project filtering. +- `variant/document-level` — only `page` (no projects/work); all four module schemas exist but only `ModuleMedia` and `ModuleText` have local renderers — `ModuleCarousel` lives in `web/src/components/carousel/`, `ModuleContentRefs` has a dev-only placeholder. No `web/src/components/modules/index.ts` barrel. `module.contentRefs` is simplified to `PAGE_REFERENCES` with an `allowMultiple` toggle. `page` has a `language` string field set by the i18n plugin — **never edit manually**. + +Check `git rev-parse --abbrev-ref HEAD` and the actual file layout on the branch before adding modules. + +## Definition of done + +`pnpm typecheck` passes, `pnpm format` clean, gen artifacts committed if schema changed, all 8 module wiring points touched (when applicable), no `--no-verify`. diff --git a/.github/dependabot.yml b/.github/dependabot.yml index 0133d7c..12a88fc 100644 --- a/.github/dependabot.yml +++ b/.github/dependabot.yml @@ -1,20 +1,64 @@ version: 2 + +# Two parallel pipelines because `main` and `variant/document-level` are +# treated as independently-shipped lines of the starter, not as +# trunk + downstream. Dependabot reads this file from the default branch +# only (`main`); the `target-branch` field is what fans updates out. +# +# Both entries share the same shape: +# - monthly schedule (first Monday 06:00 CET) +# - one catch-all `all-non-major` group bundles every minor + patch +# across the workspace into a single PR per run; majors stay +# individual for manual review (e.g. Next 16→17, React 19→20) +# - `@types/node` major bumps ignored (pinned to Node 22 LTS) +# +# Keep the two entries in lockstep — when editing either, mirror the +# change to the other. +# +# Security advisories are NOT schedule-gated and arrive immediately on +# both branches regardless of the monthly cadence. + updates: + # ── main ─────────────────────────────────────────────────────────────────── - package-ecosystem: npm directory: "/" schedule: - interval: weekly + interval: monthly day: monday - open-pull-requests-limit: 10 + time: "06:00" + timezone: "Europe/Berlin" + open-pull-requests-limit: 5 groups: - sanity-ecosystem: + all-non-major: patterns: - - "sanity" - - "@sanity/*" - - "sanity-plugin-*" + - "*" + update-types: + - "minor" + - "patch" + ignore: + - dependency-name: "@types/node" + update-types: ["version-update:semver-major"] + labels: + - "dependencies" + + # ── variant/document-level ───────────────────────────────────────────────── + - package-ecosystem: npm + directory: "/" + target-branch: "variant/document-level" + schedule: + interval: monthly + day: monday + time: "06:00" + timezone: "Europe/Berlin" + open-pull-requests-limit: 5 + groups: + all-non-major: + patterns: + - "*" + update-types: + - "minor" + - "patch" ignore: - # @types/node must track the runtime (Node 22 LTS), not the latest major. - # Allow minor/patch within v22; block major bumps (e.g. 25.x). - dependency-name: "@types/node" update-types: ["version-update:semver-major"] labels: diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 4e576dc..5497da9 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -36,6 +36,11 @@ jobs: - name: Ensure formatter produced no changes run: git diff --exit-code - run: pnpm run typecheck + # Wiring drift between schema, component, query, and type files is a + # silent bug class — `pnpm gen:module` creates them atomically, but + # hand-edits routinely miss a file. This gate catches partial wirings + # before they reach main. + - run: pnpm run check:wiring schema-typegen: name: schema typegen @@ -84,7 +89,7 @@ jobs: - run: pnpm install --frozen-lockfile - run: pnpm --filter web run build env: - # Builds without a project must still succeed (boilerplate). Real + # Builds without a project must still succeed (starter). Real # deploys provide SANITY_STUDIO_PROJECT_ID via the host's env. NEXT_TELEMETRY_DISABLED: "1" diff --git a/.github/workflows/dependabot-auto-merge.yml b/.github/workflows/dependabot-auto-merge.yml new file mode 100644 index 0000000..2f12171 --- /dev/null +++ b/.github/workflows/dependabot-auto-merge.yml @@ -0,0 +1,43 @@ +name: Dependabot auto-merge + +# Enables GitHub auto-merge on Dependabot PRs for patch + minor bumps +# (including grouped PRs). GitHub then waits for ALL required checks to +# pass before squash-merging. Auto-merge ≠ merge-regardless — failing CI +# leaves the PR sitting open until you act on it. +# +# Major bumps fall through and remain manual: fetch-metadata reports the +# highest update-type across a group, and our dependabot.yml group filter +# (`update-types: [minor, patch]`) excludes majors, so single-major PRs +# return `version-update:semver-major` and skip the merge step. + +on: + pull_request: + # Both branches are treated as parallel main lines; Dependabot opens + # PRs against both (see dependabot.yml). + branches: + - main + - variant/document-level + +permissions: + contents: write + pull-requests: write + +jobs: + auto-merge: + if: github.event.pull_request.user.login == 'dependabot[bot]' + runs-on: ubuntu-latest + steps: + - name: Fetch dependabot metadata + id: meta + uses: dependabot/fetch-metadata@v2 + with: + github-token: ${{ secrets.GITHUB_TOKEN }} + + - name: Enable auto-merge for patch + minor + if: | + steps.meta.outputs.update-type == 'version-update:semver-patch' || + steps.meta.outputs.update-type == 'version-update:semver-minor' + run: gh pr merge --auto --squash "$PR_URL" + env: + PR_URL: ${{ github.event.pull_request.html_url }} + GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} diff --git a/.github/workflows/pr-slack-notify.yml b/.github/workflows/pr-slack-notify.yml new file mode 100644 index 0000000..d090f52 --- /dev/null +++ b/.github/workflows/pr-slack-notify.yml @@ -0,0 +1,13 @@ +name: PR Slack notify + +on: + pull_request: + types: [opened, ready_for_review, closed] + branches: + - main + - variant/document-level + +jobs: + notify: + uses: backendforth/.github/.github/workflows/pr-slack-summary.yml@main + secrets: inherit diff --git a/.github/workflows/strip-readmes.yml b/.github/workflows/strip-readmes.yml index 79bd029..b19b95f 100644 --- a/.github/workflows/strip-readmes.yml +++ b/.github/workflows/strip-readmes.yml @@ -29,4 +29,4 @@ jobs: - name: Commit changes uses: stefanzweifel/git-auto-commit-action@v5 with: - commit_message: "chore: remove README files (boilerplate cleanup)" + commit_message: "chore: remove README files (starter cleanup)" diff --git a/.gitignore b/.gitignore index 8a7e36a..b624d22 100644 --- a/.gitignore +++ b/.gitignore @@ -23,7 +23,8 @@ out/ # Editors / agents .claude -.cursor +.cursor/* +!.cursor/rules/ # Coverage / test artefacts coverage/ diff --git a/AGENTS.md b/AGENTS.md new file mode 100644 index 0000000..0bc7b74 --- /dev/null +++ b/AGENTS.md @@ -0,0 +1,320 @@ +# AGENTS.md — next-sanity-starter + +> This is the canonical guardrails file for AI coding assistants (Claude Code, Cursor, Windsurf, Codex, Copilot). Read this **before** writing or changing code. Per-subtree `CLAUDE.md`, `.cursor/rules/*.mdc`, and `.github/copilot-instructions.md` all defer to this document — keep edits here, not in mirrors. + +## TL;DR + +This is a pnpm monorepo with two apps (`web` Next.js 16 App Router, `studio` Sanity v5) and shared `packages/*`. Editorial content is rendered via a strict **module pattern**: every renderable content block is a paired `module.` Sanity object **plus** a `Module.tsx` React component, wired in eight places. Multilingual content uses `@sanity/internationalized-array` — locales are resolved at **render time**, never inside GROQ. + +Before writing code: + +1. Search for an existing module, util, snippet, or query that already does what you need. +2. If you need a new content block → it is a **module**; wire all 8 points or revert. +3. After any schema change → run `pnpm studio:generate`, then `pnpm typecheck`, then `pnpm format`. + +## Principles (priority order — apply top-down when in doubt) + +1. **Reuse before invention.** Grep `studio/schemas/objects/modules/`, `web/src/components/modules/`, `web/sanity/queries/snippets/`, `web/sanity/utils/` first. +2. **Pair or don't ship.** A schema without its component (or vice versa) is a bug, not a partial commit. +3. **All 8 wiring points or revert.** A partially wired module silently breaks the editor or the renderer. Atomic. +4. **Typegen is the source of truth for Studio types.** Never hand-edit `studio/sanity.types.gen.ts` or `studio/schema.json`. `web/sanity/types/*` are hand-maintained for now — keep them aligned with schema field changes. +5. **Locale-aware at render time, not query time.** Never `coalesce(field[language=="en"]...)` in GROQ. Always resolve via `pickLocalizedString` / `parseLocalizedText`. +6. **READMEs are deep docs.** Per-folder `README.md` files are authoritative for the local pattern — link to them, do not duplicate. + +## Repository map + +| Path | Purpose | +|---|---| +| `web/` | Next.js 16 app (App Router). User-facing site. | +| `studio/` | Sanity Studio v5. Editorial UI + schemas. | +| `packages/sanity-dataset-resolve/` | Shared dev/prod dataset resolver used by web + studio. | +| `packages/strip-readmes/` | CLI to clean READMEs when shipping a downstream fork. | +| `studio/schemas/objects/modules/` | **Schema half** of every module. | +| `web/src/components/modules/` | **Component half** of every module. | +| `web/sanity/queries/components/modules/` | GROQ projection per module. | +| `web/sanity/types/modules/` | Hand-maintained TS shapes per module. | +| `web/src/i18n/` | Locale routing, fallback config, path utils. | + +## The module pattern (the most important concept) + +A **module** is one content block. It exists as two halves that must always evolve together: + +- **Studio half** — `defineType({ name: "module.", type: "object", ... })` in `studio/schemas/objects/modules/module.ts`. +- **Web half** — a React component `Module.tsx` in `web/src/components/modules/`, rendered by `ModulesRenderer.tsx` via `_type` switch. + +### 8-step wiring checklist + +When you add or rename a module, **touch all eight** files. Skipping any one silently breaks either the editor experience or the renderer. + +| # | File | What | +|---|---|---| +| 1 | `studio/schemas/objects/modules/module.ts` | `defineType` with `name: "module."`, fields, `preview`, optional `icon`. | +| 2 | `studio/schemas/index.ts` | Import + add to `schemaTypes` array. Group with other `module.*`. | +| 3 | `studio/schemas/objects/editors/richTextMedia.ts` | Append `{ type: "module." }` to the `of` array so the module is insertable in Portable Text. | +| 4 | `studio/schemas/fields/modulesArrayField.ts` | Append `{ type: "module." }` to `moduleTypes` so the module is insertable in document-level `modules[]` fields. | +| 5 | `web/src/components/modules/Module.tsx` | React component. Accept `{ data, locale, siteLocale }` (and any module-specific props). Use `data-sanity` attrs for Visual Editing. | +| 6 | `web/src/components/modules/index.ts` | Re-export the component from the barrel. | +| 7 | `web/sanity/queries/components/modules/.ts` | GROQ projection for the module (`_key, _type, ...fields`). Re-export from `web/sanity/queries/components/modules/index.ts`. | +| 8 | `web/sanity/types/modules/.ts` | Hand-written TS type matching the GROQ projection. Re-export from `web/sanity/types/modules/index.ts`. | + +After step 1–4 land, run `pnpm studio:generate` and commit `studio/schema.json` + `studio/sanity.types.gen.ts`. CI rejects a diff. + +### DO / DON'T + +| ✅ DO | ❌ DON'T | +|---|---| +| Ship schema + component + query + type in **one** commit. | Land a Studio schema without its web counterpart (or vice versa). | +| Render new content via `ModulesRenderer`. | Add an ad-hoc page section directly inside a page template. | +| Reuse `media.image`, `media.video`, `media.videoLoop` from `studio/schemas/objects/media/`. | Re-declare image/video field shapes inline in a new module. | +| Run `pnpm studio:generate` after every schema edit. | Hand-edit `studio/sanity.types.gen.ts` or `studio/schema.json`. | +| Keep `richTextMedia.ts` and `modulesArrayField.ts` in sync (same module set). | Restrict a module to only one of the two unless explicitly intended. | +| Use `pnpm typecheck` to catch wiring drift. | Mark work "done" without typecheck + format passing. | + +## Where things live — decision tree + +Ask yourself, in order: + +1. **Is this a content block authored in Studio?** → It is a **module**. Follow the 8-step wiring. +2. **Is this a layout primitive (header, footer, container, theme toggle)?** → `web/src/components/{navigation,theme,…}/`. +3. **Is this a Sanity-only helper (slug validator, structure item, presentation resolver)?** → `studio/utils/` or `studio/config/`. +4. **Is this code or types shared by web AND studio?** → A package in `packages/*`. +5. **Is this a locale string, URL path util, or fallback config?** → `web/src/i18n/`. +6. **Is this a GROQ snippet you'll reuse across queries?** → `web/sanity/queries/snippets/`. + +If a piece of code does not fit any branch, stop and ask — do not invent a new top-level folder. + +## Naming + +- **Modules.** Studio name `module.` (lowercase, dot-separated). Web component `Module.tsx` (PascalCase). The two MUST correspond 1:1 (`module.text` ↔ `ModuleText.tsx`). +- **Studio files.** kebab-case or camelCase per existing neighbours; one `defineType` per file. +- **Web files.** PascalCase for components, camelCase for utils, kebab-case discouraged. +- **Path alias.** `@/*` resolves to `web/`. Only valid inside `web/`. Never inside `studio/` or `packages/*`. +- **Quotes / indent.** Biome enforces: tabs everywhere except `studio/*` which uses 2 spaces; double quotes throughout. + +## Tooling priorities (run in this order) + +After any schema edit: + +```bash +pnpm studio:generate # regenerate studio/schema.json + studio/sanity.types.gen.ts +pnpm check:wiring # YOU MUST verify all 8 module wiring points are consistent +pnpm typecheck # web + studio +pnpm format # biome check --write . +``` + +Pre-commit / pre-push hooks already run `format` and `typecheck`. **Don't** bypass with `--no-verify`. If a hook fails, fix the underlying issue and create a **new** commit. + +**IMPORTANT — never edit:** + +- `studio/sanity.types.gen.ts` +- `studio/schema.json` +- Any `*.gen.*` file +- Lockfiles (`pnpm-lock.yaml`) unless you ran a dependency command + +## CLI command reference + +The repo has three pnpm scopes: **root** (orchestrates the monorepo), **web** (Next.js app), and **studio** (Sanity). Most root scripts forward to one or both apps. + +### Root (run from repo root) + +| Command | Purpose | +|---|---| +| `pnpm dev` | Start web (3000) and studio (3333) in parallel. | +| `pnpm web:dev` | Start the web app only. | +| `pnpm studio:dev` | Start the Studio only (uses `studio/scripts/dev-with-hint.mjs`). | +| `pnpm build` | Build both apps. | +| `pnpm studio:build` | Build the Studio only. | +| `pnpm studio:deploy` | Deploy the Studio to Sanity (production target). | +| `pnpm studio:generate` | Regenerate `studio/schema.json` + `studio/sanity.types.gen.ts`. **Run after every schema edit.** | +| `pnpm studio:sync-prod-to-dev` | Clone production dataset → development. | +| `pnpm typecheck` | `tsc --noEmit` across web + studio. | +| `pnpm lint` | `biome check .` (no writes). | +| `pnpm format` | `biome check --write .` (auto-fix). | +| `pnpm gen:module ` | Scaffold a new module (all 8 wiring points). PascalCase name. Add `--dry-run` to preview. | +| `pnpm check:wiring` | Validate all 8 module wiring points across the monorepo. CI gate. | +| `pnpm strip-readmes` | Remove nested READMEs after forking the starter. The root `README.md` is preserved. | +| `pnpm strip-readmes:dry-run` | Preview what `strip-readmes` would delete. | +| `pnpm update` | `pnpm up --stream -r` (interactive upgrade across workspaces). | +| `pnpm studio:update` | Upgrade Studio dependencies only. | + +### web (run from `web/` or via `pnpm --filter web run …`) + +| Command | Purpose | +|---|---| +| `pnpm dev` | `next dev --webpack`. | +| `pnpm build` | `next build`. | +| `pnpm start` | `next start` (after `build`). | +| `pnpm typecheck` | `tsc --noEmit` (web only). | +| `pnpm lint` | Biome on `web/`. | +| `pnpm format` | Biome write on `web/`. | +| `pnpm generate` | `sanity typegen generate` (web-side typegen — currently unused, web types are hand-maintained). | + +### studio (run from `studio/` or via `pnpm --filter studio run …`) + +| Command | Purpose | +|---|---| +| `pnpm dev` | Studio dev server via `scripts/dev-with-hint.mjs`. | +| `pnpm start` | `sanity start`. | +| `pnpm build` | `sanity build`. | +| `pnpm deploy` | `SANITY_STUDIO_DEPLOYMENT_TARGET=production sanity deploy`. | +| `pnpm generate` | `sanity schema extract --enforce-required-fields && sanity typegen generate`. | +| `pnpm sync:prod-to-dev` | Sync production dataset → dev (interactive confirmation). | +| `pnpm migrate:project-body-rich-text` | One-off migration. Only run when explicitly needed. | +| `pnpm typecheck` | `tsc --noEmit` (studio only). | +| `pnpm lint` | Biome on `studio/`. | +| `pnpm format` | Biome write on `studio/`. | + +## i18n — the multilingual structure + +The starter does **not** filter locales in GROQ. Every translatable field is an `internationalizedArray*` storing `[{ _key, language, value }]`. Locale resolution happens at React render time via `web/sanity/utils/sanityLocalizedText.ts`. + +### Three-layer architecture + +1. **Storage (Studio).** Translatable fields use `internationalizedArrayString`, `internationalizedArrayRichText`, or `internationalizedArrayRichTextMedia` (defined in `studio/schemas/objects/editors/`). Available languages come from the `siteLanguageSettings` singleton (`studio/schemas/settings/siteLanguageSettings.ts`) — the order of `availableLanguages[]` defines fallback priority; `defaultLanguageId` is the site default. +2. **Fetch (GROQ).** Queries project the **full** `{ _key, _type, language, value }` array. No locale parameter. Snippets in `web/sanity/queries/snippets/` already encode the correct projection — reuse them. +3. **Resolve (Render).** `web/sanity/utils/sanityLocalizedText.ts` is the single resolver. Always call into it; never index arrays directly. + +### Required resolver utilities + +| Utility | Use for | +|---|---| +| `pickLocalizedString(entries, locale, siteLocale)` | Single i18n string field (title, alt, label). | +| `parseLocalizedText({ value, locale, siteLocale, as })` | Polymorphic: `as: "auto" \| "string" \| "blocks"` — handles string or Portable Text. | +| `resolveLocalizedPortableTextDeep(entries, locale, siteLocale)` | Portable Text with embedded i18n fields (used inside RichTextMedia + modules in rich text). | + +**Fallback chain (must-know):** exact locale → base tag (`en-US` → `en`) → `siteLocale.localeIds` in order → first non-empty entry. + +### Locale flow + +``` +URL /[locale]/[slug] + └─ app/[locale]/page.tsx reads `locale` from params + └─ fetchPageBySlug(slug) — GROQ returns full i18n arrays + └─ render({ locale, siteLocale, page }) + ├─ pickLocalizedString(page.title, locale, siteLocale) + └─ ModulesRenderer({ locale, siteLocale, modules }) + └─ ModuleText: parseLocalizedText({ value: body, locale, siteLocale, as: "blocks" }) +``` + +`siteLocale` (typed `SiteLocaleConfig`, defined in `web/src/i18n/fallbackSiteLocales.ts`) must be threaded into every render path. It is loaded from `siteLanguageSettings` at request time, with `FALLBACK_SITE_LOCALE_CONFIG` as the offline default. + +### i18n DO / DON'T + +| ✅ DO | ❌ DON'T | +|---|---| +| Project full i18n arrays in GROQ. | `coalesce(field[language=="en"].value, …)` — breaks fallback chain. | +| Read fields via `pickLocalizedString` / `parseLocalizedText` in components. | Index arrays directly (`title[0].value`, `title.find(t => t.language === locale)`). | +| Add a new language **only** by editing the `siteLanguageSettings` singleton in Studio. | Hardcode `"en"` / `"de"` outside `web/src/i18n/`. | +| Pass `{ locale, siteLocale }` as props through every render boundary. | Read locale from `useRouter()` inside a module — breaks SSR + Visual Editing. | +| Mark translatable schema fields with `internationalizedArray*`. | Use a plain `string` or `array` field for translatable content. | +| Extend `sanityLocalizedText.ts` if a new resolver is genuinely needed. | Write a parallel ad-hoc resolver inside a component. | + +## Anti-patterns (stop if you're about to do any of these) + +- Creating a one-off `
` directly in `app/[locale]/page.tsx` instead of a module. +- Hand-writing a Sanity-shaped type without a matching GROQ projection. +- Filtering an i18n field by language inside the query. +- Adding a language by editing `fallbackSiteLocales.ts` only and forgetting the Studio singleton. +- Importing from `studio/` inside `web/` or vice versa (use a `packages/*` package). +- Bypassing the pre-commit hook with `--no-verify`. +- Editing `studio/sanity.types.gen.ts` to "fix" a type error — fix the schema or the hand type instead. + +## Cookbook + +### Add a content module + +1. Read `studio/schemas/README.md` §8 "Content modules" — it has the canonical narrative. +2. Touch all 8 wiring points (see table above). +3. `pnpm studio:generate` → commit gen artifacts. +4. `pnpm typecheck && pnpm format`. + +### Add a document type (page-like) + +1. Define in `studio/schemas/documents/` and register in `studio/schemas/index.ts`. +2. Add a structure item under `studio/config/structure/items/` and wire into `studio/config/structure/index.ts`. +3. Decide Presentation behaviour — touch `studio/config/presentation/conventions.ts` (`SLUG_BASED_DOCUMENT_TYPES`, `SITE_ROOT_DOCUMENT_TYPES`, `DOCUMENT_TYPES_WITHOUT_WEB_PREVIEW`) and `resolve.ts` / `locationsResolver.ts` as needed. +4. If internally linkable, add to `studio/schemas/constants/references.ts` (`PAGE_REFERENCES`). +5. `pnpm studio:generate` → typecheck → format. + +See `studio/config/README.md` and `studio/config/structure/README.md` for details. + +### Add a GROQ query + +1. Read `web/sanity/queries/README.md` and `web/sanity/queries/snippets/README.md`. +2. Compose from existing snippets — do not re-write `slug`, `seo`, `link`, `media`, `modules` shapes. +3. Project i18n fields as full arrays. +4. Add a matching hand type in `web/sanity/types/` if the query returns a new shape. + +### Add a locale + +1. Studio → **Site Language Settings** singleton → append `{ id: "", title: "" }` to `availableLanguages`. Position controls fallback priority. Optionally update `defaultLanguageId`. +2. `web/src/i18n/fallbackSiteLocales.ts` → extend `FALLBACK_SITE_LOCALE_CONFIG.localeIds` and `languages` in the same order (offline fallback only). +3. Studio language tabs appear automatically via `studio/config/sync/internationalizedArrayLanguages.ts`. No schema, query, or component change is required. If you think one is required — stop, that is an anti-pattern. + +See `web/src/i18n/README.md`. + +## Per-folder README index + +Always link to these; never duplicate their content. + +| README | Covers | +|---|---| +| `README.md` (root) | Project overview, monorepo layout. | +| `studio/README.md` | Studio architecture, schema/structure wiring. | +| `studio/schemas/README.md` | Adding schemas + the canonical module checklist. | +| `studio/config/README.md` | Structure + Presentation wiring. | +| `studio/config/structure/README.md` | Sidebar / desk structure patterns. | +| `studio/utils/README.md` | Slug validation, label utils, language sync. | +| `studio/scripts/README.md` | Dataset sync, deploy helpers. | +| `web/README.md` | Web app overview, env, locales. | +| `web/sanity/README.md` | Data-layer architecture, GROQ patterns, Presentation troubleshooting. | +| `web/sanity/queries/README.md` | Query organisation (pages vs components vs snippets). | +| `web/sanity/queries/snippets/README.md` | Reusable GROQ snippets. | +| `web/sanity/queries/components/README.md` | Per-component projections. | +| `web/sanity/queries/pages/README.md` | Per-route projections. | +| `web/src/i18n/README.md` | i18n internals, locale flow, language settings. | +| `web/src/assets/styles/README.md` | Tailwind v4 token setup. | +| `web/src/assets/styles/tailwind/README.md` | Tailwind config split. | +| `web/src/assets/fonts/README.md` | Font loading conventions. | + +## Branch-specific notes + +Both `main` and `variant/document-level` ship this file. The convention surface is largely identical but key wiring differs. **Always check the active branch (`git rev-parse --abbrev-ref HEAD`) before touching modules, queries, or document types.** + +| Aspect | `main` | `variant/document-level` | +|---|---|---| +| Document types | `page`, `project`, `projectCategory`, `work` (+ `home`, settings singletons) | `page` only (+ `home`, settings singletons) | +| Module schemas (Studio) | All four: `module.media`, `module.text`, `module.carousel`, `module.contentRefs` | All four exist as schemas | +| Web module renderers | `ModuleMedia`, `ModuleText`, `ModuleCarousel`, `ModuleContentRefs` (+ Client) — all under `web/src/components/modules/`, registered in `web/src/components/modules/index.ts` (barrel) | `ModuleMedia`, `ModuleText` under `web/src/components/modules/`; `ModuleCarousel` lives in `web/src/components/carousel/` (dynamic-imported); `ModuleContentRefs` has a **dev-only placeholder** in `ModulesRenderer.tsx` ("no frontend renderer yet"). **No `index.ts` barrel** for modules. | +| `module.contentRefs` schema | `sourceScope` toggles between `PAGE_REFERENCES` / `PROJECT_REFERENCES` / both; project filter UI; `showProjectFilters`, `selection`. | Simplified: `allowMultiple` toggle, `reference` (single) or `references` (array), `PAGE_REFERENCES` only. | +| `richTextMedia.ts` inline modules | `module.media`, `module.carousel`, `module.text`, `module.contentRefs` (mirrors `modulesArrayField`) | Only `module.media` and `module.carousel` — narrower than `modulesArrayField` on purpose. | +| Document-level locale field | None — i18n is purely field-level via `internationalizedArray*`. | `page` has a `language` string field (`readOnly: true`, `hidden: true`, `initialValue: "en"`) set by the i18n plugin, **never edit it manually**. Title, module bodies, and other content fields still use `internationalizedArray*` and resolve through `sanityLocalizedText.ts` exactly as on `main`. | +| Query/type layout | Flat files under `web/sanity/queries/components/modules/.ts` + `web/sanity/types/modules/.ts` with barrels. | Subfolder structure under `web/sanity/queries/components/modules/` and `web/sanity/types/modules/`; consult the directory layout on the branch before adding files. | + +### Variant-branch rules + +- **Never** reference `project`, `projectCategory`, or `work` from queries or components — those document types do not exist. +- The `page.language` field is set programmatically by the i18n plugin; **do not edit, hide, expose, or remove it**. New page documents inherit it from `initialValue`. +- When adding a renderer for `module.contentRefs`, replace the dev-only placeholder in `ModulesRenderer.tsx` — the schema already exists. +- When adding a web module, follow the on-branch convention: do **not** recreate `web/src/components/modules/index.ts` unless you also rewrite `ModulesRenderer.tsx` to import through it. The renderer currently imports module components directly. +- Check `web/sanity/queries/components/modules/` and `web/sanity/types/modules/` for the actual subfolder layout before adding files — it differs from `main`. + +### Updating conventions on both branches + +1. Edit `AGENTS.md` (+ mirrors) on `main`. +2. Commit and merge. +3. `git checkout variant/document-level && git checkout main -- AGENTS.md CLAUDE.md web/CLAUDE.md studio/CLAUDE.md studio/schemas/objects/modules/CLAUDE.md web/src/components/modules/CLAUDE.md web/sanity/CLAUDE.md .cursor/rules/ .github/copilot-instructions.md`. +4. Re-read this section and adjust if a real divergence appeared. +5. Commit on `variant/document-level`. + +## Definition of done + +Before declaring a task complete, all of the following must hold: + +- [ ] `pnpm typecheck` passes (web + studio). +- [ ] `pnpm format` produces no diff. +- [ ] `pnpm studio:generate` (after schema edits) produces no uncommitted diff. +- [ ] For a new/renamed module: all 8 wiring points touched, schema + component land in the same commit. +- [ ] For i18n changes: `siteLanguageSettings` singleton updated **before** `fallbackSiteLocales.ts`, never the reverse-only. +- [ ] No edits to `*.gen.*` files. +- [ ] No `--no-verify` commits, no `coalesce(...language==...)` GROQ, no hardcoded locale literals outside `web/src/i18n/`. diff --git a/CLAUDE.md b/CLAUDE.md new file mode 100644 index 0000000..237de24 --- /dev/null +++ b/CLAUDE.md @@ -0,0 +1,20 @@ +# Claude Code — repo entrypoint + +Project guardrails: @AGENTS.md (canonical for all AI coding tools). + +Claude Code additionally auto-loads scoped rules from subtree `CLAUDE.md` files based on the current working directory. Each one focuses on subtree-specific gotchas; the canonical rules stay in `AGENTS.md`: + +- `web/CLAUDE.md` — Next.js app rules +- `studio/CLAUDE.md` — Sanity Studio rules +- `studio/schemas/objects/modules/CLAUDE.md` — schema half of the module pattern +- `web/src/components/modules/CLAUDE.md` — component half of the module pattern +- `web/sanity/CLAUDE.md` — query/type layer rules + +**IMPORTANT:** When subtree rules conflict with `AGENTS.md`, `AGENTS.md` wins. Update `AGENTS.md` first, then propagate. + +## Quick reminders (the full versions live in AGENTS.md) + +- **YOU MUST** run all 8 module wiring points when adding/renaming a module. Skipping any is a bug. +- **YOU MUST** run `pnpm studio:generate` after schema edits, then `pnpm check:wiring`, then `pnpm typecheck`. +- **NEVER** edit `studio/sanity.types.gen.ts`, `studio/schema.json`, or any `*.gen.*` file. +- **NEVER** filter locale inside GROQ. Locale resolves at render time via `pickLocalizedString` / `parseLocalizedText`. diff --git a/README.md b/README.md index be2d281..e1aac39 100644 --- a/README.md +++ b/README.md @@ -1,209 +1,136 @@ -# Next.js + Sanity monorepo +# next-sanity-starter -Opinionated boilerplate for a **multi-language, CMS-driven site** built on **Next.js 16 (App Router, React 19)** and **Sanity Studio v5**, with optional **Mux** video and **Netlify** hosting integrations. Designed to stay slim: no unused features, production-grade defaults, and a clear path from `git clone` → first page. +> A production-shaped starting point for a multi-language, CMS-driven site — **Next.js 16** App Router, **React 19**, **Sanity Studio v5**, with optional **Mux** video and **Netlify** hosting. + +[![Next.js 16](https://img.shields.io/badge/Next.js-16.2-000?logo=next.js&logoColor=white)](https://nextjs.org) +[![React 19](https://img.shields.io/badge/React-19.2-149eca?logo=react&logoColor=white)](https://react.dev) +[![Sanity v5](https://img.shields.io/badge/Sanity-5.27-f03e2f?logo=sanity&logoColor=white)](https://www.sanity.io) +[![TypeScript 6](https://img.shields.io/badge/TypeScript-6-3178c6?logo=typescript&logoColor=white)](https://www.typescriptlang.org) +[![Tailwind v4](https://img.shields.io/badge/Tailwind-4-38bdf8?logo=tailwindcss&logoColor=white)](https://tailwindcss.com) +[![Biome](https://img.shields.io/badge/Biome-2.4-60a5fa?logo=biome&logoColor=white)](https://biomejs.dev) +[![License: ISC](https://img.shields.io/badge/license-ISC-green)](LICENSE) + +It stays slim on purpose: no unused features, production-grade defaults, and a clear path from `git clone` to first published page. Documentation lives next to the code that needs it — strip the per-folder READMEs with `pnpm strip-readmes` once you're ready to ship. --- -## Table of contents +## Two flavours + +This starter ships in **two parallel variants** so you can pick the internationalisation model that matches your editorial workflow: + +| Branch | i18n strategy | Pick when | +|---|---|---| +| **[`main`](../../tree/main)** | Field-level via `sanity-plugin-internationalized-array` | All translations of one piece of content live in one document with language tabs per field; short, UI-shaped translations. | +| **[`variant/document-level`](../../tree/variant/document-level)** | Document-level via `@sanity/document-internationalization` | Each language is its own document — different slugs, modules, SEO per locale. GROQ does the locale matching; no runtime resolver. | -- [Monorepo layout](#monorepo-layout) -- [Feature highlights](#feature-highlights) -- [Requirements](#requirements) -- [Quickstart](#quickstart) -- [Environment & dataset resolution](#environment--dataset-resolution) -- [Core APIs & modules](#core-apis--modules) -- [Scripts reference](#scripts-reference) -- [Tooling](#tooling) -- [Deployment notes](#deployment-notes) -- [Learn more](#learn-more) +[**PR #62**](../../pull/62) keeps a permanent side-by-side comparison + decision guide for adopters. Neither branch is a downgrade of the other; both are production-shaped. --- -## Monorepo layout +## What you get -Managed with **pnpm workspaces** (`pnpm-workspace.yaml`: `web`, `studio`, `packages/*`). +**Next.js app** — App Router with locale-aware routing (`[locale]/[slug]`), `generateStaticParams`, ISR-style revalidation via cache tags, fully wired Sanity Draft Mode + Visual Editing (`SanityLive`, Presentation, stega). `SanityLive` only mounts when it's actually useful (read token present or draft mode active). -| Package | Path | Role | -|---------|------|------| -| **Web** | `web/` | Next.js 16 App Router app — i18n routing, GROQ data fetching, Portable Text, Mux, sitemap/robots, cache-tag revalidation | -| **Studio** | `studio/` | Sanity Studio v5 — schema, plugins, Presentation (Visual Editing), dev/prod dataset sync | -| **`@repo/sanity-dataset-resolve`** | `packages/sanity-dataset-resolve/` | Shared dev/prod dataset resolution used by **both** web and Studio | -| **`@repo/strip-readmes`** | `packages/strip-readmes/` | Internal utility to bulk-clean documentation during exports | +**Sanity Studio v5** — Vision, Dashboard, Presentation with configurable preview origin, runtime language tabs driven by a `siteLanguageSettings` singleton, deploy-on-publish via Netlify plugin, safe production → development dataset clone. ---- +**Media pipeline** — Native `` with deterministic Sanity CDN URLs, full responsive `srcset`/`sizes`, hotspot-aware `object-position`, LQIP and zero hydration drift. Mux video via the official `` imported `/lazy`, plus a lightweight `hls.js` background-loop player that only loads when the element enters the viewport and respects `prefers-reduced-motion`. -## Feature highlights +**Hardened revalidation** — `POST /api/revalidate` does HMAC-SHA256 signature verification (Sanity signed webhooks), payload validation, a document-type allow-list, in-memory rate limiting, and is fail-closed in production. -### Next.js web app -- **App Router** with nested `[locale]/[slug]` routing, static generation (`generateStaticParams`) and ISR-style revalidation via cache tags. -- **Localization** driven by a Sanity singleton (`siteLanguageSettings`). The schema seeds **English + German** on first create (`initialValue`); editors own the list afterward. Routing, ``, `hreflang` alternates, and Portable Text fallbacks read from Studio; a **minimal code fallback** (`en` only) applies only when the document is missing or invalid (CI, first deploy). -- **Draft Mode + Visual Editing** wired through `next-sanity` (`SanityLive`, Presentation overlays, stega). `` only mounts when a read token is present or draft mode is active. -- **Webhook-hardened revalidation** at `POST /api/revalidate` — HMAC-SHA256 signature check (Sanity signed webhooks), payload validation, document-type allowlist, in-memory rate limiter, fail-closed in production. -- **SEO out of the box** — `sitemap.ts` with per-locale `alternates` + `x-default`, staging-aware `robots.ts`, `resolveSanityMetadata` → canonical + `hreflang` metadata for every page. -- **Security headers** via `netlify.toml` — CSP with `frame-ancestors` (Studio-friendly), HSTS preload, `Permissions-Policy`, `Referrer-Policy`. Netlify `build.ignore` skips web builds on Studio-only commits. -- **Tailwind CSS v4** + PostCSS pipeline (`@import`, `calc()`, nested ancestors, custom `rem()` helper). Design tokens live in `web/src/assets/styles/`. +**SEO out of the box** — `sitemap.ts` with per-locale `alternates` and `x-default`, staging-aware `robots.ts`, `resolveSanityMetadata` builds canonical + `hreflang` metadata for every route. -### Media pipeline (Sanity + Mux) -- **`MediaImage`** — native `` with deterministic Sanity CDN URLs, full responsive `srcset`/`sizes`, crop/hotspot-aware `object-position`, LQIP, zero hydration drift. Lazy fade-in via a tiny inline script. -- **`MediaVideo`** — the official Mux React element **``** imported from `@mux/mux-player-react/lazy` (viewport-deferred JS). Poster image comes from Sanity (hotspot-aware) or Mux's `thumbnail.jpg` sized to the container. -- **`MediaVideoLoop`** — lightweight native `