diff --git a/.cursor/rules/core.mdc b/.cursor/rules/core.mdc new file mode 100644 index 00000000..24459340 --- /dev/null +++ b/.cursor/rules/core.mdc @@ -0,0 +1,9 @@ +--- +description: Project-wide guardrails. Always applied. +alwaysApply: true +--- + +`AGENTS.md` at the repo root is canonical: read it before writing code, and change it (not these rules) when a convention changes. The per-folder `README.md` files are the deep docs it links to. + +- i18n: this is `variant/document-level` — document-level i18n (one document per language), so AGENTS.md §"Branch-specific notes" wins over its field-level §i18n. Select documents in GROQ with `language == $locale`; their fields are plain and render directly. **NEVER** add `internationalizedArray*` fields or `pickLocalizedString` calls for content. +- i18n: **NEVER** edit the plugin-managed `language` field on documents. diff --git a/.cursor/rules/modules.mdc b/.cursor/rules/modules.mdc new file mode 100644 index 00000000..04ccf089 --- /dev/null +++ b/.cursor/rules/modules.mdc @@ -0,0 +1,15 @@ +--- +description: Module pattern — schema, component and query of a content module. +globs: + - studio/schemas/objects/modules/** + - web/src/components/modules/** + - web/sanity/queries/components/modules/** +--- + +The wiring checklist (six points, inline opt-in) is in `AGENTS.md` §"The module pattern". Prefer `pnpm gen:module `; `pnpm check:wiring` compares every list. + +- i18n: module fields are plain (`string`, `richText`, `richTextMedia`); the projection returns them as they are (the page query already selected the document's language) and the component renders them directly. +- File `module.ts`, `export const module` and `name: "module."` must match — `check:wiring` finds a module only by all three. +- Give every module schema a `preview`, or Studio lists each instance as "Untitled". +- Renaming `module.` once content exists needs a data migration. +- Module components are server components. An interactive part goes behind a lazy client boundary like `web/src/components/media/LazyVideo.tsx` or `…/carousel/LazyCarouselViewport.tsx`, or it lands in every route's first-load JS. diff --git a/.cursor/rules/schemas.mdc b/.cursor/rules/schemas.mdc new file mode 100644 index 00000000..e09d4a06 --- /dev/null +++ b/.cursor/rules/schemas.mdc @@ -0,0 +1,12 @@ +--- +description: Sanity schema and Studio config conventions. +globs: + - studio/schemas/** + - studio/config/** +--- + +Canonical: `AGENTS.md` (Cookbook → "Add a document type"). Deep docs: `studio/schemas/README.md`, `studio/config/README.md`. + +- i18n: document-level — a translatable document type has the hidden, read-only `language` field (`languageField` from `studio/schemas/fields/languageField.ts`; never edit it), which puts it in `TRANSLATABLE_SCHEMA_TYPES`. Content fields are plain; languages load at runtime from `siteLanguageSettings` (`studio/config/sync/supportedLanguages.ts`). +- `schemaTypes` alone shows editors nothing: a document type also needs a structure item, a routable one Presentation wiring, a singleton its role in `studio/config/singletons.ts`. +- Every `SANITY_STUDIO_*` variable ends up in the public Studio bundle — never a secret under that prefix. diff --git a/.cursor/rules/web.mdc b/.cursor/rules/web.mdc new file mode 100644 index 00000000..3e6ec92d --- /dev/null +++ b/.cursor/rules/web.mdc @@ -0,0 +1,13 @@ +--- +description: Next.js web app conventions — routing, i18n, components. +globs: + - web/** +--- + +Canonical: `AGENTS.md`. Deep docs: `web/README.md`, `web/sanity/README.md`, `web/src/i18n/README.md`. + +- i18n: document-level — select per-language documents in GROQ with `language == $locale` (the design here, not `main`'s "no locale in GROQ" anti-pattern) and pass `{ locale, siteLocale }` to anything that builds hrefs. Fields arrive in the route's language and render directly. +- Server components by default; `"use client"` only for state, effects or browser APIs. +- Heavy client code that only some pages render goes behind a lazy boundary (`next/dynamic` in a `"use client"` file); CI fails a route over its first-load JS ceiling in `.github/bundle-budget.json`. +- A module gets the locale as a prop, never from the router, the URL or cookies (`AGENTS.md` §"i18n DO / DON'T"). +- Build locale-prefixed URLs with the helpers in `web/src/i18n/`, never by concatenating prefixes. diff --git a/.github/bundle-budget.json b/.github/bundle-budget.json new file mode 100644 index 00000000..3938e19e --- /dev/null +++ b/.github/bundle-budget.json @@ -0,0 +1,11 @@ +{ + "$comment": "First-load JS per route, KB gzip, as `.github/scripts/bundle-report.mjs` measures it. Both branches share this file: each budget is the larger of the two measurements plus ~3 KB. Raise one only on purpose, and say why in the PR.", + "routes": { + "/[locale]": 170, + "/[locale]/[slug]": 170, + "/[locale]/work": 170, + "/[locale]/work/[slug]": 170, + "/[locale]/[...rest]": 164, + "/_not-found": 146 + } +} diff --git a/.github/copilot-instructions.md b/.github/copilot-instructions.md new file mode 100644 index 00000000..54150d40 --- /dev/null +++ b/.github/copilot-instructions.md @@ -0,0 +1,13 @@ +# GitHub Copilot — repository instructions + +`AGENTS.md` at the repo root holds this repo's guardrails: the module pattern, generated Sanity types, i18n, commands and the definition of done. Read it before suggesting a change; the per-folder `README.md` files are the deep docs it links to. + +Two long-lived branches differ in one thing, where a locale lives: in the field on `main`, in the document on `variant/document-level`. Check the branch (`git rev-parse --abbrev-ref HEAD`) and read AGENTS.md → "Branch-specific notes" before touching locale code. Fixes land on `main` first and reach the variant by `git cherry-pick -x`; every PR names its `Variant-Port`. A project copied from the starter has `main` (production) and `development` (staging) instead and no `Variant-Port`; its AGENTS.md → "This repository" says so. + +Copilot code review reads only this file, so the rules a review most often needs are repeated here (AGENTS.md has the reasons): + +- Never edit generated files (`*.gen.*`, `studio/schema.json`); `pnpm generate` writes them and CI fails on a diff. +- Full queries go through `defineQuery`; no function calls inside a query, no `as` on Sanity data. +- Compare a fetched string to a literal only through `stegaClean` / `isStegaVariant`; never clean a whole rendered document. +- A module is wired in six places (`pnpm check:wiring` checks them); schema and component land in one commit. +- Locale handling differs by branch — judge it against AGENTS.md "Branch-specific notes", not against the other branch. diff --git a/.github/dependabot.yml b/.github/dependabot.yml index 0133d7c9..78dbece0 100644 --- a/.github/dependabot.yml +++ b/.github/dependabot.yml @@ -1,21 +1,124 @@ 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. +# +# Each branch gets one npm and one github-actions entry: +# - monthly schedule — Dependabot's `monthly` interval always runs on the +# 1st of the month (`day` is only honored for `weekly`). The PR→Slack +# monthly digest runs on the 2nd on purpose, so the channel never gets +# bundles + digest on the same day. +# - npm: two catch-all groups per run instead of one PR per dependency: +# `all-non-major` bundles every minor + patch into one auto-mergeable +# PR (see dependabot-auto-merge.yml); `all-major` bundles every major +# into one PR that waits for manual review (e.g. Next 16→17). +# `@types/node` major bumps ignored (pinned to Node 22 LTS). +# - github-actions: every action and reusable workflow in one PR. The +# workflows pin them to commit SHAs; Dependabot moves the SHA and its +# `# vX.Y.Z` comment. Auto-merge treats the bundle like an npm one: a +# major inside it waits for review. +# +# Keep the main and variant entries in lockstep — when editing one, 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 - day: monday - open-pull-requests-limit: 10 + interval: monthly + time: "13:00" + timezone: "Europe/Berlin" + open-pull-requests-limit: 5 groups: - sanity-ecosystem: + all-non-major: patterns: - - "sanity" - - "@sanity/*" - - "sanity-plugin-*" + - "*" + update-types: + - "minor" + - "patch" + all-major: + patterns: + - "*" + update-types: + - "major" + ignore: + - dependency-name: "@types/node" + update-types: ["version-update:semver-major"] + # TypeScript 7 (the native compiler rewrite) breaks `next build` on + # Next 16: `tsc --noEmit` passes, but Next's own TypeScript detection + # fails ("do not have the required package(s) installed") because the + # package layout changed. Next's transitive @module-federation/dts-plugin + # also still pins `typescript@^4.9 || ^5`. Drop this entry once Next + # supports TS 7. + - dependency-name: "typescript" + update-types: ["version-update:semver-major"] + labels: + - "dependencies" + + - package-ecosystem: github-actions + directory: "/" + schedule: + interval: monthly + time: "13:00" + timezone: "Europe/Berlin" + groups: + github-actions: + patterns: + - "*" + labels: + - "dependencies" + + # ── variant/document-level ───────────────────────────────────────────────── + - package-ecosystem: npm + directory: "/" + target-branch: "variant/document-level" + schedule: + interval: monthly + time: "13:00" + timezone: "Europe/Berlin" + open-pull-requests-limit: 5 + groups: + all-non-major: + patterns: + - "*" + update-types: + - "minor" + - "patch" + all-major: + patterns: + - "*" + update-types: + - "major" 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"] + # TypeScript 7 (the native compiler rewrite) breaks `next build` on + # Next 16: `tsc --noEmit` passes, but Next's own TypeScript detection + # fails ("do not have the required package(s) installed") because the + # package layout changed. Next's transitive @module-federation/dts-plugin + # also still pins `typescript@^4.9 || ^5`. Drop this entry once Next + # supports TS 7. + - dependency-name: "typescript" + update-types: ["version-update:semver-major"] + labels: + - "dependencies" + + - package-ecosystem: github-actions + directory: "/" + target-branch: "variant/document-level" + schedule: + interval: monthly + time: "13:00" + timezone: "Europe/Berlin" + groups: + github-actions: + patterns: + - "*" labels: - "dependencies" diff --git a/.github/pull_request_template.md b/.github/pull_request_template.md new file mode 100644 index 00000000..24da1c94 --- /dev/null +++ b/.github/pull_request_template.md @@ -0,0 +1,8 @@ + + + +Variant-Port: diff --git a/.github/scripts/bundle-report.mjs b/.github/scripts/bundle-report.mjs new file mode 100644 index 00000000..20169b9d --- /dev/null +++ b/.github/scripts/bundle-report.mjs @@ -0,0 +1,99 @@ +// @ts-check +/** + * First-load JS per route of the web build, gzipped — the number the browser downloads before + * a page is interactive. + * + * Usage: node .github/scripts/bundle-report.mjs (after `pnpm --filter web run build`) + * + * Reads `web/.next/diagnostics/route-bundle-stats.json`, which `next build` writes with the + * chunk list of every route, and gzips each chunk (level 9). Writes a Markdown table to + * `$GITHUB_STEP_SUMMARY` when set (the `build (web)` job), to stdout otherwise. + * + * Budget gate: `.github/bundle-budget.json` maps routes to a ceiling in KB gzip. A route over + * its ceiling fails the run (exit 1) after the table is written, and so does a budget for a + * route the build doesn't have; a route without one only shows `—`. + */ + +import { appendFileSync, readFileSync } from "node:fs"; +import { join } from "node:path"; +import { fileURLToPath } from "node:url"; +import { gzipSync } from "node:zlib"; + +const webDir = fileURLToPath(new URL("../../web", import.meta.url)); +const statsFile = join(webDir, ".next/diagnostics/route-bundle-stats.json"); +const budgetFile = fileURLToPath( + new URL("../bundle-budget.json", import.meta.url), +); + +/** @type {Record} route → ceiling in KB gzip */ +const budgets = JSON.parse(readFileSync(budgetFile, "utf8")).routes; + +/** @type {{ route: string; firstLoadChunkPaths: string[] }[]} */ +const routes = JSON.parse(readFileSync(statsFile, "utf8")); + +/** @type {Map} */ +const chunkSizes = new Map(); +/** @param {string} chunk */ +function sizeOf(chunk) { + let size = chunkSizes.get(chunk); + if (!size) { + const source = readFileSync(join(webDir, chunk)); + size = { raw: source.length, gzip: gzipSync(source, { level: 9 }).length }; + chunkSizes.set(chunk, size); + } + return size; +} + +/** @param {number} bytes */ +const kb = (bytes) => `${(bytes / 1024).toFixed(1)} KB`; + +const rows = routes.map(({ route, firstLoadChunkPaths }) => { + let raw = 0; + let gzip = 0; + for (const chunk of firstLoadChunkPaths) { + const size = sizeOf(chunk); + raw += size.raw; + gzip += size.gzip; + } + const budget = budgets[route]; + const over = budget !== undefined && gzip / 1024 > budget; + return { route, chunks: firstLoadChunkPaths.length, raw, gzip, budget, over }; +}); + +const table = [ + "### First-load JS per route", + "", + "| Route | Chunks | Raw | Gzip | Budget |", + "|---|---:|---:|---:|---:|", + ...rows.map( + (row) => + `| \`${row.route}\` | ${row.chunks} | ${kb(row.raw)} | **${kb(row.gzip)}** | ${ + row.budget === undefined + ? "—" + : `${row.over ? "❌" : "✅"} ${row.budget} KB` + } |`, + ), + "", +].join("\n"); + +const summary = process.env.GITHUB_STEP_SUMMARY; +if (summary) appendFileSync(summary, `${table}\n`); +else console.log(table); + +const overBudget = rows.filter((row) => row.over); +for (const row of overBudget) { + console.error( + `✗ ${row.route}: ${kb(row.gzip)} gzip exceeds its ${row.budget} KB budget (.github/bundle-budget.json).`, + ); +} +/* A budget for a route the build no longer has would pass forever. */ +const builtRoutes = new Set(rows.map((row) => row.route)); +const orphanBudgets = Object.keys(budgets).filter( + (route) => !builtRoutes.has(route), +); +for (const route of orphanBudgets) { + console.error( + `✗ ${route}: has a budget in .github/bundle-budget.json but no route in the build.`, + ); +} +if (overBudget.length > 0 || orphanBudgets.length > 0) process.exit(1); diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 4e576dc3..07eb8396 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -3,65 +3,68 @@ name: CI on: pull_request: push: - branches: [main, master] + # `development` is a project's staging branch (AGENTS.md → "Projects built + # from this starter"); the starter itself has none. + branches: [main, development, variant/document-level] + +permissions: + contents: read concurrency: group: ci-${{ github.ref }} cancel-in-progress: true +# The four job names are the required checks of the "Merge Protection" ruleset +# on main and variant/document-level. Renaming a job blocks every merge until +# the ruleset is updated. jobs: verify: - name: format • typecheck (Node ${{ matrix.node }}) + name: format • typecheck (Node 22) runs-on: ubuntu-latest - strategy: - fail-fast: false - matrix: - node: ["20", "22"] steps: - - uses: actions/checkout@v4 + - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 - - uses: pnpm/action-setup@v4 + - uses: pnpm/action-setup@ea17c68df8912ef543352723c149a84f56e3d413 # v6.1.0 - - uses: actions/setup-node@v4 + - uses: actions/setup-node@949feb2413d6458794dcd2491c4babbbce0c15c1 # v7.1.0 with: - node-version: ${{ matrix.node }} + node-version: "22" cache: "pnpm" - run: pnpm install --frozen-lockfile - # Organization convention: run `biome check --write` (via `pnpm run format`) - # rather than a read-only lint. Any unfixable issue fails the command; the - # diff check below additionally catches auto-fixable changes that weren't - # committed, so contributors know to run `pnpm run format` locally. - - run: pnpm run format - - name: Ensure formatter produced no changes - run: git diff --exit-code + # Formatting, lint and import order in one read-only pass (`pnpm lint`, + # as in the pre-push hook); findings are annotated on the PR. `pnpm + # format` fixes them locally. + - run: pnpm run lint --reporter=github - run: pnpm run typecheck + # Wiring drift between a module's schema, component, query and the lists + # that register them is a silent bug class — `pnpm gen:module` wires them + # atomically, but hand-edits routinely miss one. This gate catches partial + # wirings before they reach main. + - run: pnpm run check:wiring + # Unit tests for the wiring manifest plus an end-to-end `gen:module` run + # (two modules, one inline) through typegen, check:wiring and tsc. + - run: pnpm run test:tooling schema-typegen: name: schema typegen runs-on: ubuntu-latest - needs: verify steps: - - uses: actions/checkout@v4 + - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 - - uses: pnpm/action-setup@v4 + - uses: pnpm/action-setup@ea17c68df8912ef543352723c149a84f56e3d413 # v6.1.0 - - uses: actions/setup-node@v4 + - uses: actions/setup-node@949feb2413d6458794dcd2491c4babbbce0c15c1 # v7.1.0 with: node-version: "22" cache: "pnpm" - run: pnpm install --frozen-lockfile - # `sanity schema extract` loads sanity.config.ts, which throws without a - # project id. The extracted schema is static (no API call), so a placeholder - # is enough to let the config module load — same pattern as the build jobs. - - run: pnpm --filter studio run generate - env: - SANITY_STUDIO_PROJECT_ID: ci-placeholder - # Web typegen reads the (just-regenerated) `studio/schema.json` and the - # `defineQuery` usages in `web/sanity/queries/**`. A placeholder project id - # is enough — typegen never calls the Sanity API. - - run: pnpm --filter web run generate + # Studio schema extract, web query typegen, then `check:typegen` + # (every `defineQuery` got a type). `sanity schema extract` loads + # sanity.config.ts, which throws without a project id; nothing here calls + # the Sanity API, so a placeholder is enough. + - run: pnpm run generate env: SANITY_STUDIO_PROJECT_ID: ci-placeholder - name: Ensure schema typegen produced no changes @@ -70,13 +73,12 @@ jobs: build-web: name: build (web) runs-on: ubuntu-latest - needs: [verify, schema-typegen] steps: - - uses: actions/checkout@v4 + - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 - - uses: pnpm/action-setup@v4 + - uses: pnpm/action-setup@ea17c68df8912ef543352723c149a84f56e3d413 # v6.1.0 - - uses: actions/setup-node@v4 + - uses: actions/setup-node@949feb2413d6458794dcd2491c4babbbce0c15c1 # v7.1.0 with: node-version: "22" cache: "pnpm" @@ -84,20 +86,23 @@ 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" + # First-load JS per route (gzip) into the job summary; fails when a route + # exceeds its ceiling in .github/bundle-budget.json. + - name: Bundle report + run: node .github/scripts/bundle-report.mjs build-studio: name: build (studio) runs-on: ubuntu-latest - needs: [verify, schema-typegen] steps: - - uses: actions/checkout@v4 + - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 - - uses: pnpm/action-setup@v4 + - uses: pnpm/action-setup@ea17c68df8912ef543352723c149a84f56e3d413 # v6.1.0 - - uses: actions/setup-node@v4 + - uses: actions/setup-node@949feb2413d6458794dcd2491c4babbbce0c15c1 # v7.1.0 with: node-version: "22" cache: "pnpm" diff --git a/.github/workflows/dependabot-auto-merge.yml b/.github/workflows/dependabot-auto-merge.yml new file mode 100644 index 00000000..774f8020 --- /dev/null +++ b/.github/workflows/dependabot-auto-merge.yml @@ -0,0 +1,46 @@ +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 remain manual: fetch-metadata reports the highest +# update-type across a group, so the `all-major` bundle (see +# dependabot.yml) returns `version-update:semver-major` and skips 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: + # Org-internal: template forks decide on auto-merge themselves. + if: >- + github.repository == 'backendforth/next-sanity-starter' && + github.event.pull_request.user.login == 'dependabot[bot]' + runs-on: ubuntu-latest + steps: + - name: Fetch dependabot metadata + id: meta + uses: dependabot/fetch-metadata@25dd0e34f4fe68f24cc83900b1fe3fe149efef98 # v3.1.0 + 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/netlify-deploy.yml b/.github/workflows/netlify-deploy.yml new file mode 100644 index 00000000..7c4326eb --- /dev/null +++ b/.github/workflows/netlify-deploy.yml @@ -0,0 +1,75 @@ +name: Netlify deploy + +# Rebuilds the web site and/or the Studio on Netlify from GitHub, so anyone +# with write access to the repo can redeploy staging or production without a +# local setup or a Netlify login: Actions → "Netlify deploy" → Run workflow. +# +# Each Netlify project gets a build hook, a secret URL that starts a build of +# one branch. The hooks live as secrets in the GitHub environments `staging` +# and `production` (README → "Redeploy from GitHub"), so no Netlify token is +# stored here and the job needs no repository permissions. GitHub only shows +# that a build was started; whether it passed shows on the site a few minutes +# later. + +on: + workflow_dispatch: + inputs: + environment: + description: Environment + type: choice + options: [staging, production] + default: staging + target: + description: What to rebuild + type: choice + options: [site and studio, site, studio] + default: site and studio + +permissions: {} + +concurrency: + group: netlify-deploy-${{ inputs.environment }} + cancel-in-progress: false + +jobs: + deploy: + name: ${{ inputs.target }} (${{ inputs.environment }}) + runs-on: ubuntu-latest + environment: ${{ inputs.environment }} + timeout-minutes: 5 + steps: + - name: Start Netlify builds + env: + SITE_HOOK: ${{ secrets.NETLIFY_SITE_BUILD_HOOK }} + STUDIO_HOOK: ${{ secrets.NETLIFY_STUDIO_BUILD_HOOK }} + SITE_URL: ${{ vars.SITE_URL }} + STUDIO_URL: ${{ vars.STUDIO_URL }} + ENVIRONMENT: ${{ inputs.environment }} + TARGET: ${{ inputs.target }} + ACTOR: ${{ github.actor }} + run: | + set -euo pipefail + case "$TARGET" in + site) targets="site" ;; + studio) targets="studio" ;; + *) targets="site studio" ;; + esac + printf '### Netlify builds (%s)\n\n' "$ENVIRONMENT" >> "$GITHUB_STEP_SUMMARY" + status=0 + for t in $targets; do + if [ "$t" = site ]; then hook="$SITE_HOOK" url="$SITE_URL"; else hook="$STUDIO_HOOK" url="$STUDIO_URL"; fi + if [ -z "$hook" ]; then + echo "::error::No $t build hook in the '$ENVIRONMENT' environment. README → \"Redeploy from GitHub\" sets it up." + echo "- $t: not set up for $ENVIRONMENT" >> "$GITHUB_STEP_SUMMARY" + status=1 + continue + fi + # clear_cache: a full rebuild. With a cache, the `ignore` rule in + # netlify.toml skips a project whose files didn't change. + curl -fsS -X POST --get -o /dev/null \ + --data clear_cache=true \ + --data-urlencode "trigger_title=Redeploy by $ACTOR via GitHub Actions" \ + "$hook" + echo "- $t: build started${url:+, live in a few minutes at $url}" >> "$GITHUB_STEP_SUMMARY" + done + exit "$status" diff --git a/.github/workflows/pr-slack-notify.yml b/.github/workflows/pr-slack-notify.yml new file mode 100644 index 00000000..fb7b7791 --- /dev/null +++ b/.github/workflows/pr-slack-notify.yml @@ -0,0 +1,15 @@ +name: PR Slack notify + +on: + pull_request_target: + types: [opened, ready_for_review, closed] + branches: + - main + - variant/document-level + +jobs: + notify: + # Org-internal: skip in template forks so they never ping backendforth's Slack. + if: ${{ github.repository == 'backendforth/next-sanity-starter' }} + uses: backendforth/.github/.github/workflows/pr-slack-summary.yml@d14c26b47da93ba6943bee16471f2bdced345fb8 # main + secrets: inherit diff --git a/.github/workflows/strip-readmes.yml b/.github/workflows/strip-readmes.yml deleted file mode 100644 index 79bd029d..00000000 --- a/.github/workflows/strip-readmes.yml +++ /dev/null @@ -1,32 +0,0 @@ -# Manual only: Actions → Strip README files → Run workflow -# Deletes every README.md (same logic as `pnpm run strip-readmes`), then commits if anything changed. -name: Strip README files - -on: - workflow_dispatch: - -permissions: - contents: write - -jobs: - strip: - runs-on: ubuntu-latest - steps: - - uses: actions/checkout@v4 - - - uses: pnpm/action-setup@v4 - - - uses: actions/setup-node@v4 - with: - node-version: "22" - cache: "pnpm" - - - run: pnpm install --frozen-lockfile - - - name: Remove all README.md files - run: pnpm --filter @repo/strip-readmes run strip - - - name: Commit changes - uses: stefanzweifel/git-auto-commit-action@v5 - with: - commit_message: "chore: remove README files (boilerplate cleanup)" diff --git a/.gitignore b/.gitignore index 8a7e36aa..b624d22c 100644 --- a/.gitignore +++ b/.gitignore @@ -23,7 +23,8 @@ out/ # Editors / agents .claude -.cursor +.cursor/* +!.cursor/rules/ # Coverage / test artefacts coverage/ diff --git a/.husky/pre-commit b/.husky/pre-commit index 1a8f261f..74ecddca 100755 --- a/.husky/pre-commit +++ b/.husky/pre-commit @@ -1 +1,8 @@ -pnpm run format +# Formatting rewrites whole files and `--again` re-stages them, so a partly staged file would +# carry its unstaged hunks into the commit. Refuse any path staged in column X that also changes +# in column Y, whatever the change type (a file-type change shows as `T`). +if git status --porcelain | grep -qE '^[^ ?!][^ ]'; then + echo "pre-commit: a staged file also has unstaged changes. Stage all of it, or stash the rest." >&2 + exit 1 +fi +pnpm exec biome check --write --staged --no-errors-on-unmatched && git update-index --again diff --git a/.husky/pre-push b/.husky/pre-push index 1fe6519b..eb90fc3a 100755 --- a/.husky/pre-push +++ b/.husky/pre-push @@ -1 +1 @@ -pnpm run format && pnpm run typecheck +pnpm run lint && pnpm run typecheck diff --git a/AGENTS.md b/AGENTS.md new file mode 100644 index 00000000..978800df --- /dev/null +++ b/AGENTS.md @@ -0,0 +1,403 @@ +# 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. Subtree `CLAUDE.md` files, `.cursor/rules/*.mdc` and `.github/copilot-instructions.md` only point here and add local gotchas this file doesn't state — keep conventions here, not in them. + +## TL;DR + +This is a pnpm monorepo with two apps (`web` Next.js 16 App Router, `studio` Sanity v6) 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 six places (inline use in rich text is opt-in). Multilingual content uses `sanity-plugin-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**; `pnpm gen:module ` wires all 6 points. +3. After any schema or query change → run `pnpm 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 6 wiring points or revert.** A partially wired module silently breaks the editor or the renderer. Atomic. +4. **Typegen is the only source of types for Sanity data.** Never hand-edit the generated files. Web types come from `defineQuery` results via `web/sanity/types.ts` (`ModuleOf<"module.x">`, `HomeDocument`, …) — never write a Sanity-shaped type by hand. +5. **Locale-aware at render time, not query time.** Never `coalesce(field[language=="en"]...)` in GROQ. Always resolve via `pickLocalizedString` / `pickLocalizedPortableTextBlocks`. +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 v6. Editorial UI + schemas. | +| `studio/netlify.toml` | Netlify pipeline for the Studio — the default hosting, a second Netlify site with package directory `studio`. The root `netlify.toml` is the web site's. | +| `packages/sanity-dataset-resolve/` | Shared dev/prod dataset resolver used by web + studio. | +| `packages/site-languages/` | The `siteLanguageSettings` projection, row rules and `en` fallback, shared by the web's routing and the Studio's language config. | +| `packages/check-wiring/` | `check:wiring`, `check:typegen` and the wiring manifest (`src/manifest.mjs`) both checks and the scaffolder use. | +| `packages/scaffold-module/` | `gen:module` — scaffolds and wires a module. | +| `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.ts` | Names for the generated query types (`sanity.types.gen.ts`). Derived only. | +| `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. + +### 6-point wiring checklist + +`pnpm gen:module ` creates the files and patches every list below; `pnpm check:wiring` compares them exactly (a missing entry fails, and so does an entry without a schema). By hand, touch all six. + +| # | 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/fields/modulesArrayField.ts` | Append `{ type: "module." }` to `moduleTypes` so the module is insertable in document-level `modules[]` fields. | +| 4 | `web/src/components/modules/Module.tsx` | React component typed `module: ModuleOf<"module.">`. Accept `{ module, locale, siteLocale }` (and any module-specific props). No `data-sanity` in the component: `ModulesRenderer` sets it. | +| 5 | `web/src/components/modules/ModulesRenderer.tsx` | A `case "module.":` in `renderModuleChild()`. The switch ends in `const unhandled: never = mod`, so a module the query returns without a `case` fails `tsc`. | +| 6 | `web/sanity/queries/components/modules/.ts` | `moduleQuery` — the GROQ projection (`_type == "module." => { ...fields }`), listed in `modulesQuery` in `queries/components/modules/index.ts`. | + +Then `pnpm generate` and commit `studio/schema.json` and `web/sanity/sanity.types.gen.ts`; CI rejects a diff. The module's TS type is not a wiring point — it is `ModuleOf<"module.">`, derived from the generated query types of the page stack and of rich text, so it does not depend on either. + +Points 3, 5 and 6 are the **page stack**: a module editors add to a document's `modules[]` is in all three, a module that only lives inline in rich text is in none. Taking a module out of the page stack is those three lines; `check:wiring` names any left over. Every module stays in the page stack or inline: one that only lives inside another module (`module.media` without its own entries, only in a carousel's slides) gets projected to the parent's shape, keeps no `ModuleOf` type, and `check:wiring` fails it. + +### Inline modules (opt-in) + +A module can also be insertable inside rich text (`richTextMedia`). That is a choice per module, not a default: `module.media` and `module.carousel` are inline, `module.text` and `module.contentRefs` are not. An inline module is registered in **all three** of these places or in none — `pnpm check:wiring` fails on anything in between, and `tsc` fails when the query returns an inline type the components map doesn't handle: + +1. `studio/schemas/objects/editors/richTextMedia.ts` — `{ type: "module." }` in `of`. +2. `web/sanity/queries/components/text/richTextMedia.ts` — `${moduleQuery}` in `richTextMediaQuery`. +3. `web/src/components/text/portableTextComponents.tsx` — a `"module."` handler in `types`. + +`pnpm gen:module --inline` does all three. + +### DO / DON'T + +| ✅ DO | ❌ DON'T | +|---|---| +| Ship schema + component + query + generated types 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 generate` after every schema or query edit. | Hand-edit `web/sanity/sanity.types.gen.ts` or `studio/schema.json`. | +| Make a module inline only when it belongs inside text, and then in all three places. | Register an inline type in one place and not the others. | +| Use `pnpm typecheck` to catch wiring drift. | Mark work "done" without typecheck + format passing. | + +## Sanity data types (TypeGen) and stega + +- **Queries.** Fragments are plain `const` template strings; every full query goes through `defineQuery(...)`. No function calls anywhere in a query: TypeGen skips what it can't evaluate statically, and `pnpm generate` fails on it (`check:typegen`). A singleton read by `_id` filters by type as well, `*[_type == "siteLanguageSettings" && _id == "siteLanguageSettings"][0]` — an `_id` alone gives TypeGen no document type to narrow to. +- **Types.** `web/sanity/types.ts` derives app-facing names from `web/sanity/sanity.types.gen.ts` (`ModuleOf<"module.text">`, `RichTextMediaValue`, `SiteCookieBanner`, …). Fetchers return what `sanityFetch` returns; no `as` on Sanity data. One deliberate exception: the media helpers (`sanityImageBuilder.ts`, `src/utils/muxImage.ts`) take `unknown` and check the shape at runtime, because they read several projections (image fields, posters, Mux fallbacks). +- **Stega.** In Presentation `sanityFetch` appends invisible markers to strings, and the types say so (`StegaString`). Compare such a value to a literal through `stegaClean(value)`, or narrow a union with `isStegaVariant(obj, "kind", "loop")` (`web/sanity/utils/stegaVariant.ts`). Never `stegaClean` a whole rendered document: the markers are what click-to-edit reads. Values that leave the page unrendered — metadata strings, analytics ids — are cleaned where they are used (`metadataFromSanityPageData`, the layout's `stegaClean(chrome.analytics)`). + +## Where things live — decision tree + +Ask yourself, in order: + +1. **Is this a content block authored in Studio?** → It is a **module**. Follow the 6-point 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 or query edit: + +```bash +pnpm generate # schema extract + web typegen + check:typegen +pnpm check:wiring # YOU MUST verify the module wiring is consistent +pnpm typecheck # web, studio, check-wiring, scaffold-module +pnpm format # biome check --write . +``` + +The pre-commit hook formats and re-stages the staged files (it refuses a file that is only partly staged); the pre-push hook runs `pnpm lint` and `typecheck`. **Don't** bypass with `--no-verify`. If a hook fails, fix the underlying issue and create a **new** commit. + +**IMPORTANT — never edit:** + +- `web/sanity/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 Hosting (`*.sanity.studio`, production target) — the alternative to Netlify. | +| `pnpm studio:register` | Register the Netlify-hosted Studio URL (`SANITY_STUDIO_HOSTING_URL`) with Sanity and upload the schema. Netlify production deploys run it when `SANITY_AUTH_TOKEN` is set. | +| `pnpm generate` | Studio schema extract, web query typegen, then `check:typegen`. **Run after every schema or query edit.** | +| `pnpm studio:sync-prod-to-dev` | Clone production dataset → development. | +| `pnpm typecheck` | `tsc --noEmit` in every workspace package with a `typecheck` script (web, studio, check-wiring, scaffold-module). | +| `pnpm lint` | `biome ci .`: format, lint and import order, no writes. The pre-push hook and CI run it. | +| `pnpm format` | `biome check --write .` (auto-fix). | +| `pnpm gen:module ` | Scaffold a new module: creates the schema, component and query files and wires all 6 points, the `ModulesRenderer` `case` included (`--inline` also registers it in the three inline places). Writes nothing if any anchor is missing. PascalCase name. Add `--dry-run` to preview. | +| `pnpm check:wiring` | Compare the module schemas with the schema index, both ways, the three page-stack places and the three inline places each with each other, and fail an unused module. CI gate. | +| `pnpm check:typegen` | Every `defineQuery` in `web/sanity/` got a generated type (TypeGen skips queries it can't evaluate silently). Part of `pnpm generate`. | +| `pnpm test:tooling` | `node:test` suites for check-wiring and gen:module, including an end-to-end scaffold. CI gate. | +| `pnpm up -r` | pnpm built-in: update every workspace's dependencies within their ranges (`--latest` crosses them, `-i` picks interactively). `pnpm --filter studio up` does the Studio only. | + +### web (run from `web/` or via `pnpm --filter web run …`) + +| Command | Purpose | +|---|---| +| `pnpm dev` | `next dev` (Turbopack). | +| `pnpm build` | `next build`. | +| `pnpm start` | `next start` (after `build`). | +| `pnpm typecheck` | `tsc --noEmit` (web only). | +| `pnpm lint` | Biome on `web/`. | +| `pnpm format` | `biome check --write` on `web/`. | +| `pnpm generate` | `sanity typegen generate` — query types into `sanity/sanity.types.gen.ts`. Run via the root `pnpm generate`. | + +### 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 run deploy` | `SANITY_STUDIO_DEPLOYMENT_TARGET=production sanity deploy` (Sanity Hosting). Needs `run`: `pnpm deploy` is a pnpm built-in. | +| `pnpm register` | `node scripts/register-hosting.mjs` → `sanity deploy --external`. Pass `--dry-run` to preview. | +| `pnpm generate` | `sanity schema extract --enforce-required-fields --force` → `schema.json`, which the web typegen reads. Run via the root `pnpm generate`. | +| `pnpm sync:prod-to-dev` | Sync production dataset → dev (interactive confirmation). | +| `pnpm mux:migrate-assets` | One-off: lists Mux assets with static MP4 or premium encoding; `-- --apply` turns static MP4 off. Needs `MUX_TOKEN_ID` / `MUX_TOKEN_SECRET`. | +| `pnpm typecheck` | `tsc --noEmit` (studio only). | +| `pnpm lint` | Biome on `studio/`. | +| `pnpm format` | `biome check --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). | +| `pickLocalizedPortableTextBlocks(entries, locale, siteLocale)` | `internationalizedArrayRichText*` bodies — the block array for the locale, `[]` when empty. Embedded `module.*` objects stay untouched; their renderers resolve their own fields. | +| `pickLocalizedValue(entries, locale, siteLocale)` | Any other i18n value. All three are generic over the entry's `value`, so the generated query types come back typed. | + +**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: pickLocalizedPortableTextBlocks(module.body, locale, siteLocale) +``` + +`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` / `pickLocalizedPortableTextBlocks` 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 the locale from `useRouter()`, `usePathname()`, `window.location` or cookies inside a module — breaks SSR and the Presentation iframe. | +| 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. | + +## Mux video resolution + +| Layer | Where | Value | +|---|---|---| +| Encoding ceiling (what Mux stores) | `options: { max_resolution_tier }` on the `mux.video` field | `media.video` **2160p**, `media.videoLoop` **1440p** | +| Plugin default | `muxInput({ max_resolution_tier })` in `studio/sanity.config.ts` | 1440p, overridden by the field options | +| Playback ceiling | `MUX_PLAYBACK_MAX_TIER` in `web/src/utils/muxPlayback.ts` | 2160p for loops and the full player | + +- **Encoding is the only knob.** To give loops 4K, set `videoLoop.ts` to `"2160p"`. Leave the web ceiling alone. +- Assets uploaded before a change keep their old ladder until they are re-encoded in Mux. +- `mp4_support: "none"` stays: HLS only, no progressive MP4. + +## 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 type for Sanity data instead of deriving it from the generated query types (`web/sanity/types.ts`). +- Comparing a fetched string to a literal without `stegaClean` / `isStegaVariant` — it is always `false` in Presentation. +- Filtering an i18n field by language inside the query. +- Adding a language to the code fallback (`@repo/site-languages`, `en` only on purpose) instead of the Studio singleton. +- Importing from `studio/` inside `web/` or vice versa (use a `packages/*` package). +- Bypassing the pre-commit hook with `--no-verify`. +- Deleting a branch by hand while open PRs are based on it — GitHub closes them instead of retargeting. First `gh pr list --base `, then `gh pr edit --base ` for each. +- Editing a `*.gen.ts` file to "fix" a type error — fix the schema or the query instead. + +## Cookbook + +### Add a content module + +1. `pnpm gen:module ` (add `--inline` only if it belongs inside rich text) — or touch all 6 wiring points by hand (see table above). +2. Fill in the schema fields and the GROQ projection, then `pnpm generate` → the component's `ModuleOf<"module.">` has the fields. Commit the generated files. +3. `pnpm check:wiring && pnpm typecheck && pnpm format`. + +`studio/schemas/README.md` §8 "Content modules" has the Studio details. + +### 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. A slug type goes into `SLUG_BASED_DOCUMENT_TYPES` (`/:slug`) or `PREFIXED_SLUG_DOCUMENT_TYPES` (`/work/:slug`) in `studio/config/presentation/conventions.ts`. A singleton goes into `SINGLETON_PREVIEW_ROLE` in `studio/config/singletons.ts`: its role (`root`, `none`, `custom`) fills `SITE_ROOT_DOCUMENT_TYPES` / `DOCUMENT_TYPES_WITHOUT_WEB_PREVIEW`; a singleton with a fixed `_id` also loses delete, unpublish and duplicate (on `variant/document-level` the per-language ones keep them). Other routes: `resolve.ts` / `locationsResolver.ts`. +4. If internally linkable, add to `studio/schemas/constants/references.ts` (`PAGE_REFERENCES`). +5. `pnpm 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. Wrap the full query in `defineQuery`, run `pnpm generate`, and name its result in `web/sanity/types.ts` if components need it. + +### Add a locale + +1. Studio → **Site Language Settings** singleton → append `{ id: "", title: "" }` to `availableLanguages`. Position controls fallback priority. Optionally update `defaultLanguageId`. +2. Add the language's fixed UI copy to `web/src/i18n/uiLabels.ts` and `web/src/i18n/cookieBannerLabels.ts`. Without it the language gets English UI text (a regional id like `de-CH` uses its base language's). +3. The Studio picks the language up on its own — `studio/config/sync/internationalizedArrayLanguages.ts` on `main`, `studio/config/sync/supportedLanguages.ts` on `variant/document-level`. 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) | Setup from clone to production: layout, local setup, how the site works, env, deploy (staging, dataset per site, webhook), day-to-day commands. | +| `studio/README.md` | Studio architecture, schema/structure wiring. | +| `studio/schemas/README.md` | Adding schema types; module details beyond the wiring 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. | +| `packages/check-wiring/README.md` | What `check:wiring` and `check:typegen` check; the wiring manifest. | +| `packages/scaffold-module/README.md` | `gen:module` usage, templates, tests. | + +## Branch-specific notes + +Both `main` and `variant/document-level` ship this file. The two branches have converged on the whole structural surface — document types, module set, the six wiring points, query/type layout and barrels are **identical**. Exactly one thing differs by design: where a locale lives — in the field (`main`) or in the document (`variant`). Every row below is that difference or follows from it. + +**Verify before you trust this table.** It has been wrong in both directions: it described a much wider divergence for months after the branches had converged, and later claimed the variant still used `internationalizedArray*` fields when it had moved to plain ones (corrected 2026-10-13). If a row here contradicts the branch you are on, the branch wins — fix the row. + +| Aspect | `main` | `variant/document-level` | +|---|---|---| +| Where the locale lives | In the field. One document per page; translatable fields are `internationalizedArray*` (`[{ _key, language, value }]`). | In the document. One document per language; every translatable document type (`TRANSLATABLE_SCHEMA_TYPES` in `studio/sanity.config.ts`: `page`, `project`, `projectCategory`, `work`, `home`, `siteSettings`, `siteNav`, `errorSettings`, `siteCookieBanner`) has a `language` string field (`readOnly`, `hidden`, initial value `siteLanguageSettings.defaultLanguageId`) set by the plugin — **never edit it manually**. Content fields are plain (`string`, `richText`, `richTextMedia`). | +| Studio plugin | `sanity-plugin-internationalized-array`, languages from `studio/config/sync/internationalizedArrayLanguages.ts`. | `@sanity/document-internationalization`, languages from `studio/config/sync/supportedLanguages.ts`; translations are linked through `translation.metadata` documents. | +| GROQ | No locale parameter; singletons by type and `_id` (`*[_type == "siteSettings" && _id == "siteSettings"][0]`). The "never filter locale in GROQ" rule applies here. | Queries select the document for the route's language and take `$locale` (`*[_type == "siteSettings" && language == $locale][0]`). Selecting by `language` is the design here, not the anti-pattern. | +| Render-time resolution | Every translatable field through `sanityLocalizedText.ts` (`pickLocalizedString` / `pickLocalizedPortableTextBlocks` / `pickLocalizedValue`). | Fields already hold the route's language; render them directly. Only `resolveLinkLabel` still resolves localized arrays: a dataset shared with `main` holds main-shaped documents a link can point at. | +| Shared singletons | `siteLanguageSettings` (both branches) and `siteAnalyticsSettings` are global singletons addressed by `_id`. | Same. | +| Layout chrome | `app/[locale]/layout.tsx` fetches the chrome bundle (`fetchSiteChrome`: nav, cookie banner, site settings, analytics, 404 copy) in parallel with the locale check. | Fetches it (`fetchSiteChrome(locale)`) after `isAppLocale(locale)`: the bundle is per language, so its cache key needs a validated locale. | + +Everything else — `page`/`project`/`projectCategory`/`work`, all four `module.*` schemas, all four web renderers under `web/src/components/modules/`, flat `queries/components/modules/.ts` with the barrel, the route paths from `queries/snippets/routePath.ts` (a deleted or unpublished link target renders without a link), the derived `web/sanity/types.ts`, the wiring tooling in `packages/` (only the `scaffold-module` templates follow the branch's field types), and the inline-module set (`module.media` + `module.carousel` on both) — is the same on both branches. Treat a difference you find there as **drift to be reconciled**, not as intended divergence. + +### Variant-branch rules + +- The `language` field on every translatable document type is set by the i18n plugin; **do not edit, hide, expose, or remove it**. New documents get it from the plugin's "create translation" action, else start in the default language. +- Anything reading a per-language document (`home`, `work`, `page`, `project`, `projectCategory`, `siteSettings`, `siteNav`, `errorSettings`, `siteCookieBanner`) needs the `$locale` param on this branch. Porting such a query from `main` means adding the `language == $locale` filter, not copying the `_id` lookup. +- A field that is `internationalizedArray*` on `main` is plain on this branch: drop the `pickLocalizedString` call in the port, render the value directly. +- Web fixes authored on `main` are expected to apply here with context conflicts only. If a port looks structural, re-check against the branch before adapting — the divergence table above has been wrong in that direction before. + +### Porting fixes between branches + +Fixes land on `main` first and reach the variant by `git cherry-pick -x`, as their own PR. Every PR names its counterpart in the description — the PR template asks for it: + +- On a `main` PR: `Variant-Port: #`, or `Variant-Port: n/a` with the reason (main-only i18n code, a workflow that runs from `main` only). +- On a variant PR: `Variant-Port: #
` for a port, `Variant-Port: variant-only` otherwise. + +Without that line, ten fixes from #182, #190 and #191 reached only `main` in 2026-10. + +### Updating conventions on both branches + +`AGENTS.md` and `.github/copilot-instructions.md` are identical on both branches — the notes above describe both, and the Copilot file stays branch-neutral. The other mirrors are not: the root and subtree `CLAUDE.md` files and `.cursor/rules/*.mdc` carry the variant's document-level i18n rules on `variant/document-level`. Each mirror keeps its i18n-model statements on lines of their own that start with `i18n:`, so a port swaps those lines and takes the rest from `main`. Carry a change over by cherry-picking it, never by copying files from `main` — a copy silently overwrites those variant rules. + +1. Edit `AGENTS.md` on `main` (a mirror only when its local gotcha changes) and commit. +2. On `variant/document-level`: `git cherry-pick -x `. +3. Resolve conflicts in the mirrors by keeping the variant's `i18n:` lines and taking everything else from `main`. A conflict outside the i18n lines is drift — reconcile it. +4. Re-read the notes above and fix a row if the branch contradicts it. +5. Once both are merged, `git diff main variant/document-level -- AGENTS.md .github/copilot-instructions.md` must be empty. + +## Projects built from this starter + +A client project starts as a copy of one starter branch without its history and lives on from there. This section is for the copy; the starter itself has no staging branch. + +### "This repository" block + +Right under the title of the copy's `AGENTS.md`, add this block filled in, so every agent reads the overrides first. Keep the i18n line only in a copy of `variant/document-level`. + +```markdown +## This repository + + is , copied from `backendforth/next-sanity-starter` at `` `` () without its history. The rest of this file is the starter's and describes both starter branches. Read it with these overrides: + +- **i18n is document-level.** Wherever this file states the field-level model (TL;DR, principle 5, "i18n — the multilingual structure", the definition of done), the `variant/document-level` column of "Branch-specific notes" and "Variant-branch rules" win: one document per language, queries filter `language == $locale`, content fields are plain. +- **Two long-lived branches: `main` (production) and `development` (staging).** See "Branches in a project". There is no variant branch to port to, and PRs carry no `Variant-Port` line. +- **Starter changes arrive by cherry-pick.** There is no shared history with the starter, so `git merge` cannot be used. See "Syncing from the starter". +``` + +### Branches in a project + +- `main` is production: Netlify production deploys of the web and the Studio project, on the `production` dataset. +- `development` is staging: a Netlify branch deploy on both projects, on the `development` dataset. Branch deploys pick that dataset without a setting (README → "Datasets"). +- Feature branches start from `development` and merge back into it by PR. A release is a PR from `development` into `main`. +- Dependabot reads its config from the default branch, `main`, and opens its PRs there. After merging one, merge `main` back into `development`. +- CI runs on every PR and on pushes to `main` and `development`. + +### Syncing from the starter + +1. `git fetch starter `. Once per clone, add the remote before the first fetch: `git remote add starter https://github.com/backendforth/next-sanity-starter.git`. +2. `git log --oneline ..starter/` lists what is new; the anchor is the starter commit in "This repository". +3. On a branch from `development`, `git cherry-pick -x` what applies. Skip commits that only concern the starter's two-branch process. +4. Open the PR against `development` and move the anchor in "This repository" in the same PR. + +## Definition of done + +Before declaring a task complete, all of the following must hold: + +- [ ] `pnpm typecheck` passes (web, studio, tooling packages). +- [ ] `pnpm format` produces no diff. +- [ ] `pnpm generate` (after schema or query edits) produces no uncommitted diff. +- [ ] `pnpm check:wiring` passes; for a new/renamed module all 6 wiring points land in the same commit as the schema. +- [ ] No edits to `*.gen.*` files. +- [ ] No `--no-verify` commits, no `coalesce(...language==...)` GROQ, no hardcoded locale literals outside `web/src/i18n/`. +- [ ] In the starter, the PR description names its `Variant-Port` (see "Porting fixes between branches"). diff --git a/CLAUDE.md b/CLAUDE.md new file mode 100644 index 00000000..6d259606 --- /dev/null +++ b/CLAUDE.md @@ -0,0 +1,8 @@ +# Claude Code — repo entrypoint + +Project guardrails: @AGENTS.md (canonical for all AI coding tools). + +Subtree `CLAUDE.md` files load with their folder (`web/`, `web/sanity/`, `web/src/components/modules/`, `studio/`, `studio/schemas/objects/modules/`) and add only local gotchas. On a conflict AGENTS.md wins — change it first, then the mirror. + +- i18n: this is `variant/document-level` — document-level i18n (one document per language), so AGENTS.md §"Branch-specific notes" wins over its field-level §i18n. Select documents in GROQ with `language == $locale`; their fields are plain and render directly. **NEVER** add `internationalizedArray*` fields or `pickLocalizedString` calls for content. +- i18n: **NEVER** edit the plugin-managed `language` field on documents. diff --git a/README.md b/README.md index be2d2810..d7c2f55e 100644 --- a/README.md +++ b/README.md @@ -1,271 +1,568 @@ -# Next.js + Sanity monorepo - -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. +# next-sanity-starter + +A starter for multilingual websites edited in Sanity: a **Next.js 16** site and a **Sanity Studio v6**, both deployable to Netlify, with optional **Mux** video. + +[![Next.js 16](https://img.shields.io/badge/Next.js-16-000?logo=next.js&logoColor=white)](https://nextjs.org) +[![React 19](https://img.shields.io/badge/React-19-149eca?logo=react&logoColor=white)](https://react.dev) +[![Sanity v6](https://img.shields.io/badge/Sanity-6-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-60a5fa?logo=biome&logoColor=white)](https://biomejs.dev) +[![License: ISC](https://img.shields.io/badge/license-ISC-green)](LICENSE) + +This README takes you from a fresh clone to a site in production. Read it in order once: each section builds on the ones before. Details live next to the code, see [Further reading](#further-reading). + +## Contents + +1. [What you get](#what-you-get) +2. [Choose a branch](#choose-a-branch) +3. [Repository layout](#repository-layout) +4. [Requirements](#requirements) +5. [Local setup](#local-setup) +6. [How it works](#how-it-works) +7. [Environment variables](#environment-variables) +8. [Deploy](#deploy) +9. [Day-to-day work](#day-to-day-work) +10. [Starting a new project](#starting-a-new-project) +11. [Further reading](#further-reading) --- -## Table of contents +## What you get -- [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) +- **Site.** Next.js App Router with language routing: the default language has no URL prefix, every other one lives under `//…`. Routes render statically and refresh per document type. +- **Studio.** Sanity Studio v6 with Structure, Presentation (live preview with click-to-edit), one document per language and the site's languages in one settings document. +- **Content modules.** Every content block is a Studio schema plus a React component. `pnpm gen:module` creates and wires a new one, CI checks the wiring. +- **Media.** Responsive images from the Sanity CDN with crop, hotspot and no layout shift. Mux video as a full player or a muted background loop. Video code loads only on pages that have a video. +- **SEO.** A sitemap with language alternates, canonical and `hreflang` tags on every route, and a `robots.txt` that allows crawling only on production. +- **Security headers.** CSP, HSTS, `Permissions-Policy`, `Referrer-Policy` and `X-Content-Type-Options`, set in `web/next.config.ts` so they reach every page on any host. +- **Cookie consent and analytics.** An optional cookie banner and five trackers (GA4, Microsoft Clarity, PostHog, Plausible, Matomo), all set up in the Studio. +- **Typed queries.** Sanity TypeGen generates the result type of every GROQ query. CI fails when the types are out of date. +- **Styling.** Tailwind CSS v4 with design tokens in CSS and a light/dark toggle. --- -## Monorepo layout +## Choose a branch + +The starter has two branches. They share every document type, module, query and component and differ in one thing: where a translation lives. -Managed with **pnpm workspaces** (`pnpm-workspace.yaml`: `web`, `studio`, `packages/*`). +| Branch | Translations | Pick it when | +|---|---|---| +| [`main`](../../tree/main) | **Per field.** One document per page, every translatable field holds all languages (`sanity-plugin-internationalized-array`). | All languages share one structure: same slug, same modules. | +| [`variant/document-level`](../../tree/variant/document-level) | **Per document.** One document per language, linked as translations (`@sanity/document-internationalization`). | Languages differ: own slugs, own modules, own SEO. | -| 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 | +[`AGENTS.md`](AGENTS.md) → "Branch-specific notes" lists every difference. --- -## Feature highlights - -### 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/`. - -### 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 `