Skip to content

Latest commit

 

History

History
594 lines (389 loc) · 27.8 KB

File metadata and controls

594 lines (389 loc) · 27.8 KB

Agent Guide for Sanity Plugins Monorepo

This guide is for AI agents working on this codebase. Follow these instructions to ensure successful contributions.

Self-Improvement: If you discover undocumented requirements, commands, or workflows during your work (e.g., a reviewer asks you to run something not covered here), update this file on the same PR. Keep this guide accurate and helpful for future agents.

Quick Reference

Task Command
Install dependencies pnpm install
Format code pnpm format
Run linters pnpm lint
Build all packages pnpm build
Run tests pnpm test
Run e2e smoke tests pnpm test:e2e
Create e2e dataset pnpm e2e:setup
Cleanup e2e datasets pnpm e2e:cleanup
Add changeset pnpm changeset add
Start dev server pnpm dev

Environment Setup

Node.js Version

Use the latest LTS release of Node.js.

pnpm Version

The exact pnpm version is managed via the packageManager field in root package.json. You only need pnpm v10 or later installed globally—corepack or pnpm itself will auto-install the exact version specified.

# Enable corepack to automatically use the correct pnpm version
corepack enable

# Install all dependencies
pnpm install

Before Submitting a PR

Run these commands in order. All must pass or CI will fail:

# 1. Format code (oxfmt)
pnpm format

# 2. Run linters (oxlint)
pnpm lint

# 3. Check for unused files, dependencies and exports (knip)
pnpm knip

# 4. Build all packages
pnpm build

# 5. Run tests
pnpm test

Required: Add Changesets

Every PR that changes published packages must include changesets. Important: Create a separate changeset file for each plugin you modify.

Why Separate Changesets?

Each plugin has its own changelog that consumers read. A combined changeset would pollute individual plugin changelogs with irrelevant information. For example, @sanity/code-input's changelog should not mention workflow-specific changes.

How to Add Changesets

Option 1: Manual Creation (Recommended for Multiple Plugins)

Create individual .md files in .changeset/ directory:

---
'plugin-name': patch
---

Brief description of the change specific to this plugin

Example: If you modified 3 plugins, create 3 files:

# .changeset/code-input-fix.md
---
"@sanity/code-input": patch
---

Fix input border styling with v2 theme API

# .changeset/workflow-hooks.md
---
"sanity-plugin-workflow": patch
---

Replace deprecated useTimeAgo hook with useRelativeTime

# .changeset/markdown-theme.md
---
"sanity-plugin-markdown": patch
---

Migrate to v2 theme API for color properties

Option 2: Interactive CLI (For Single Plugin)

pnpm changeset add

Follow the prompts to:

  1. Select one package that changed
  2. Choose the version bump type (patch/minor/major)
  3. Write a summary of changes specific to that package
  4. Repeat for each modified package

Changeset Guidelines

  • Scope: Each changeset should only mention changes relevant to that specific plugin
  • Clarity: Write from the consumer's perspective—what do they need to know?
  • Version bumps:
    • patch: Bug fixes, dependency updates, internal refactors
    • minor: New features, non-breaking API additions
    • major: Breaking changes
  • Format: Use clear, concise language without technical jargon when possible

Bad vs Good Examples

Bad (combined changeset):

---
'@sanity/code-input': patch
'sanity-plugin-workflow': patch
---

- Migrated to v2 theme APIs
- Replaced useTimeAgo with useRelativeTime
- Fixed input styling

Problem: Workflow's changelog will say "Fixed input styling" which is irrelevant

Good (separate changesets):

# .changeset/code-input-theme.md

---

## "@sanity/code-input": patch

Migrate to v2 theme API for input styling

# .changeset/workflow-hooks.md

---

## "sanity-plugin-workflow": patch

Replace deprecated useTimeAgo hook with useRelativeTime

Each plugin's changelog only shows relevant changes

Crediting Original Authors (Ported/Migrated PRs)

When you port or migrate someone else's PR (for example, bringing a community PR from a legacy single-plugin repo into this monorepo), make sure the original author gets the credit in the changelog — not the bot or whoever opened the port PR.

Two things are required:

  1. Author the commits as the original author so Git authorship is preserved:

    git commit --author="their-name <their-github-email>" -m "..."
  2. Add author: directives to the changeset summary. Changelogs are generated by @changesets/changelog-github, which otherwise credits the PR author — the 🤖 bot for a ported PR. An author: (or user:) line on its own line overrides this and is stripped from the rendered text. Add one line per contributor to thank everyone who worked on the plugin, not just the latest author (multiple authors are supported). Use GitHub usernames and always include the leading @:

    ---
    '@sanity/table': minor
    ---
    
    author: @stipsan
    author: @rexxars
    
    Port @sanity/table to the Sanity plugins monorepo

CI Checks

The CI pipeline runs on every PR:

Job What it checks
build pnpm build - All packages compile successfully
lint pnpm lint --format github - Code passes oxlint
knip pnpm knip - No unused files, dependencies or exports
test pnpm test - All tests pass (runs after build + lint)

Note on knip: in-file usage keeps an exported type "used" (ignoreExportsUsedInFile), so removing a type assertion or annotation that was the last reference to an exported type will make knip start flagging that export. Run pnpm knip after refactors that remove type references.

Lint Specifics

  • oxlint: Type-aware linting with --deny-warnings (warnings are errors). React Compiler rules run via the react-hooks-js plugin.
  • TypeScript type checking is included in pnpm lint via oxlint — no separate tsc needed
  • Run pnpm lint:fix to auto-fix issues when possible

Testing

The monorepo uses Vitest v4 for testing.

Running Tests

# Run all tests (non-watch mode)
pnpm test run

# Run tests in watch mode (default vitest behavior)
pnpm test

# Update snapshots
pnpm test -u

Writing Tests

Tests are co-located with source code in the src/ directory:

  • Test files use .test.ts or .spec.ts extensions
  • Each plugin has a minimal vitest.config.ts that inlines vitest-package-exports
  • All plugins include a package exports test using vitest-package-exports to verify all exports are valid

When creating a new plugin with pnpm generate, test files are automatically created. The root vitest.config.ts uses glob patterns to automatically discover all plugins, so new plugins are included without manual configuration.

Example test file (src/index.test.ts):

import {fileURLToPath} from 'node:url'
import {expect, test} from 'vitest'
import {getPackageExportsManifest} from 'vitest-package-exports'

test('package exports', {timeout: 30_000}, async () => {
  const manifest = await getPackageExportsManifest({
    importMode: 'dist',
    cwd: fileURLToPath(import.meta.url),
  })

  expect(manifest.exports).toMatchInlineSnapshot()
})

Test Timeouts

Always use the options object syntax for timeouts, placing it as the second argument:

// Correct - options object as second argument
test('my test', {timeout: 30_000}, async () => {
  // ... test code
})

// Wrong - timeout as third argument (deprecated style)
test('my test', async () => {
  // ...
}, 30000)

Use numeric separators (30_000 instead of 30000) for readability.

Test Configuration

  • Root vitest.config.ts uses glob patterns ['plugins/@sanity/*', 'plugins/sanity-plugin-*'] to automatically find all plugin projects
  • Individual plugins have minimal vitest.config.ts starting with just inline deps configuration for vitest-package-exports
  • Plugins can expand their vitest configs and test suites over time as needed (unit tests, integration tests, etc.)
  • Tests run against built dist/ output after pnpm build
  • Snapshots are generated with pnpm test -u
  • Root Vitest sets SC_DISABLE_SPEEDY=false so styled-components keeps its fast CSSOM injection path under jsdom (upstream disables it when NODE_ENV !== 'production', which makes first mounts of styled-heavy trees slow enough to trip default timeouts). Same approach as sanity#13675.

Pull Request Workflow

1. Create as Draft PR First

Always open PRs as draft initially. The prompter (person who requested the work) reviews first before marking ready for team review.

2. Apply the "🤖 bot" Label

All PRs created by AI agents must be labeled with "🤖 bot". This label helps the team identify bot-created PRs and ensures proper tracking and review workflows.

When creating or updating a PR, always ensure the 🤖 bot label is applied.

3. Move Out of Draft

Once the prompter approves, convert from draft to ready-for-review so the team can review.

4. Merge Process

After approval, PRs merge to main. The release workflow automatically:

  1. Creates a "Version Packages" PR that bumps versions
  2. When that PR merges, packages publish to npm with provenance

Development Server

Starting the Test Studio

pnpm dev

This starts the Sanity Studio at http://localhost:3333.

⚠️ Authentication Required

The test studio is a real Sanity Studio that connects to Sanity APIs. You must:

  1. Have a Sanity account
  2. Log in via the browser when prompted
  3. Have access to the configured project (ppsg7ml5 by default)

Simply accessing http://localhost:3333 is not enough—the studio requires browser-based Sanity authentication to function.

Inspecting Production Builds with Vite DevTools

The test studio can run with Vite DevTools enabled, which lets you inspect the output of sanity build runs (module graph, chunks, plugin timings, bundle treemaps, session diffing) from inside a long-running sanity dev server—no restart needed.

# Builds the test studio with devtools enabled, then starts the dev server
# (so there's a build session to inspect right away)
pnpm devtools:test-studio

Open http://localhost:3333 and use the Vite DevTools dock to explore the recorded Rolldown build session, or open the full-page DevTools UI directly at http://localhost:3333/__devtools/. See the DevTools for Rolldown features guide for how to use the module graph, chunk, asset, and plugin panels.

The first time a browser connects, the dock asks for a one-time permission grant (a prompt in the sanity dev terminal, or a manual auth URL printed there). Trusted clients are remembered in ~/.vite/devtools/auth.json.

To inspect a new build after making changes—while pnpm devtools:test-studio is still running—run in a second terminal:

# Creates a fresh build session that shows up in the running DevTools dock
pnpm devtools:test-studio:build

Builds are not hooked into HMR; sanity build must be invoked manually (via the command above) each time you want a new session to inspect. Sessions can be compared against each other in the DevTools UI to diff bundle changes.

How it works:

  • Both commands set ENABLE_VITE_DEVTOOLS=true, which makes dev/test-studio/sanity.cli.ts add the DevTools() Vite plugin and enable build.rolldownOptions.devtools
  • Build sessions are written to dev/test-studio/node_modules/.rolldown (gitignored)
  • The flag is declared in dev/test-studio/turbo.jsonc so turbo-cached builds are invalidated when it changes (and so turbo's strict env mode passes it through to pnpm dev)
  • Enabling devtools makes sanity build noticeably slower; that's why it's opt-in via the env flag

React production profiling on Vercel previews

dev/test-studio/sanity.cli.ts enables React production profiling when VERCEL_ENV === "preview" (Vercel PR deployments): it aliases react-dom/clientreact-dom/profiling, emits production source maps, and disables identifier mangling so React DevTools can profile and show readable component names. It also sets deployment.autoUpdates: false on those previews, because auto-updates vendor builds hardcode react-dom-client.production.js and would bypass the profiling alias. Production builds (including plugins-studio.sanity.dev via sanity deploy, where VERCEL_ENV is unset) keep auto-updates on and skip profiling to avoid the overhead. VERCEL_ENV is listed in dev/test-studio/turbo.jsonc so Turbo cache keys differ between preview and production.

Creating a New Plugin

Use the generator:

pnpm generate "new plugin"

When adding a new plugin, also update the root README.md ## Current Plugins table so the monorepo plugin list stays current.

Or to migrate an existing plugin:

pnpm generate "copy plugin"

For agent-specific transfer guidance, use the plugin-transfer skill in .agents/skills/plugin-transfer/SKILL.md.

When migrating a plugin, agents should ensure:

  • README.md from the original repository is preserved, but cleaned up for the monorepo:
    • Remove stale standalone-repo sections such as ## Develop & test (usually references @sanity/plugin-kit) and ### Release new version (references the original repo's release workflow / semantic-release). Building, testing, and releasing are handled centrally by the monorepo.
    • Remove links to old/forked versions (e.g. "for the v2 version, see this other repo").
    • Do not reference a specific Sanity Studio major: drop mentions of the current latest major (e.g. "Sanity Studio v6", which ages quickly) and of long-gone majors like v3. Reword to be version-agnostic (e.g. "maintained by Sanity.io"); only mention a version when truly necessary, at most as v2 - legacy, and usually not at all.
  • LICENSE from the original repository is preserved when it credits authors beyond Sanity.io alone (the copy-plugin generator removes it by default); if kept, update the copyright year(s) to the current year
  • A test-studio example is present and wired in dev/test-studio/sanity.config.ts
  • .github/CODEOWNERS is not updated unless explicitly requested
  • The transfer includes a major changeset
  • Only monorepo-required plugin config files are maintained (package.json, tsdown.config.ts, tsconfig.json, vitest.config.ts)

See CONTRIBUTING.md for detailed instructions on:

  • Setting up npm trusted publishing (different process for new vs existing packages)
  • Creating initial release changesets
  • Migrating existing plugins

Trusted Publishing Quick Reference

For brand new packages (not yet on npm):

  • Use the "Setup a new npm package with Trusted Publishing" GitHub Actions workflow
  • Then run locally: npm trust github <package-name> --file=release.yml --repository=sanity-io/plugins (requires npm >= 11.10.0)

For existing packages (already on npm):

  • ⚠️ DO NOT use the setup workflow
  • Run: npm trust github <package-name> --file=release.yml --repository=sanity-io/plugins (requires npm >= 11.10.0)

Code Style

Dependencies

Declare peerDependencies with the peer catalog

Peer dependency ranges are centralized in the peer named catalog in pnpm-workspace.yaml. In a package's peerDependencies, reference them as catalog:peer:

"peerDependencies": {
  "react": "catalog:peer",
  "react-dom": "catalog:peer",
  "sanity": "catalog:peer"
}

The catalog only holds peers shared across many packages (react, react-dom, sanity, styled-components). Keep explicit ranges instead of adding catalog entries for:

  • Niche one-off peers used by a single package (e.g. easymde in sanity-plugin-markdown)
  • Peers on other workspace packages, which keep the workspace:^ protocol (e.g. @sanity/dashboard in the dashboard widgets) or an explicit range when older majors are intentionally supported (e.g. sanity-plugin-internationalized-array in @sanity/sfcc) — changesets can't track dependents through catalog: references

Exception: peers that must stay in lockstep with the version we develop against reference the default catalog (catalog:) instead of catalog:peer — e.g. @sanity/pkg-utils, oxfmt and oxlint in @sanity/plugin-kit, where the shared configs may rely on rules and features that ship in a new minor. A wider catalog:peer range would let consumers pair plugin-kit with versions missing those features.

Note that the peer catalog entries are intentionally wider than the default catalog's (e.g. react: ^19.2 vs ^19.2.7): the default catalog pins what we develop against, the peer catalog declares what consumers may use. Renovate is configured (a packageRules entry in .github/renovate.json disables the pnpm.catalog.peer depType) to never rewrite these ranges — changing a peer range is a deliberate, manual decision.

Always use lodash-es instead of lodash

When working with lodash utility functions, always use the lodash-es package instead of lodash. The lodash-es package is the ES module version that supports tree-shaking and works correctly with modern build tools.

# Correct
pnpm add lodash-es

# Wrong
pnpm add lodash

Catalog shared dependencies

When a dependency is used by more than one package (two or more), manage its version in the pnpm catalog instead of repeating a literal range in each package.json:

  1. Add the dependency and its version range to the default catalog: in pnpm-workspace.yaml.
  2. Reference it from each package.json as "dep-name": "catalog:".
  3. Shared studio peers (react, react-dom, sanity, styled-components) use the named peer catalog (catalog:peer) — see above. For other cases where one package must stay on a different major than the rest, add a named catalog and reference it with catalog:<name>.

Leave niche one-off peers and workspace: protocol deps as they are. pnpm add runs with catalogMode: prefer (set in pnpm-workspace.yaml), so adding a dependency that already exists in a catalog reuses the catalog version automatically. pnpm has no built-in "used N times" enforcement, so apply this rule whenever you add or move shared dependencies.

Upgrading the Sanity Studio monorepo (sanity, @sanity/*, groq)

When bumping the Studio stack (e.g. ^6.5.0^6.6.0):

  1. Update the catalog entries for sanity, @sanity/mutator, @sanity/schema, @sanity/util, @sanity/vision, and groq together.
  2. Keep pnpm-workspace.yaml overrides for @sanity/mutator, @sanity/schema, @sanity/util, @sanity/types, groq, and sanity pointed at that version (via catalog: or an explicit range for packages not in the catalog). Catalog-only bumps leave transitive deps (CLI / migrate / portabletext) free to keep an older @sanity/types copy, which breaks dts emit with TS2883 portable-type errors.
  3. After pnpm install, confirm the lockfile has a single @sanity/types@X.Y.Z (and matching mutator/schema/util) — not both the old and new minor.
  4. Fix any new document-operation disable reasons (e.g. TARGET_NOT_FOUND) by mapping them to Sanity's structure locale keys (action.*.disabled.*), not by reusing an unrelated tooltip string.
  5. Add a separate changeset per published package whose package.json or runtime code changed.

date-fns: v4 via the catalog, subpath imports, official @date-fns/tz

All date handling matches sanity core (sanity-io/sanity), so plugins dedupe against the date-fns instance that sanity itself ships:

  • Depend on date-fns via catalog: (v4) — never pin an older major
  • Import from subpaths, e.g. import {format} from 'date-fns/format' — the date-fns barrel import is banned by lint
  • For time zone work use the official @date-fns/tz package (TZDate, tz, tzOffset, via catalog:) together with date-fns v4's in context option — the community date-fns-tz package is banned by lint. Prefer Intl.supportedValuesOf('timeZone')/Intl.DateTimeFormat for listing time zones instead of static time zone database packages (e.g. @vvo/tzdb)

Formatting

We use oxfmt:

pnpm format

The formatter settings live in the shared @sanity/plugin-kit/oxfmt preset (packages/@sanity/plugin-kit/src/oxfmt.ts), which the root oxfmt.config.ts extends with workspace-specific ignorePatterns (for example turbo/**/*.hbs). Standalone plugins scaffolded with plugin-kit init reuse the same preset. Note that loading the TypeScript config requires Node ^20.19 || >=22.18.

Linting

We use oxlint for all linting (type-aware, includes TypeScript type checking and React Compiler rules via the react-hooks-js plugin):

pnpm lint        # Run the linter (includes type checking)
pnpm lint:fix    # Auto-fix what's possible

The shared rules (plugins, options, categories, rules) live in the @sanity/plugin-kit/oxlint config (packages/@sanity/plugin-kit/src/oxlint.ts), which the root oxlint.config.ts extends; only workspace-specific ignores and overrides belong in the root config. Standalone plugins scaffolded with plugin-kit init re-export the same shared config. Note that ignorePatterns do not propagate through extends, so the root config spreads the shared patterns before adding its own. Like pnpm format, pnpm lint requires Node >=22.18 to load the TypeScript config.

Project Structure

plugins/
├── dev/test-studio/      # Test Sanity Studio (localhost:3333)
├── packages/@repo/       # Internal shared packages
├── packages/@sanity/     # Published tooling packages (e.g. @sanity/plugin-kit)
├── plugins/              # Published plugins
│   ├── @sanity/          # @sanity/* scoped packages
│   └── sanity-plugin-*   # Community-style packages
├── turbo/generators/     # Plugin scaffolding templates
└── .changeset/           # Changeset files for releases

Common Issues

Build fails with "tsgo did not generate dts file"

Declarations are generated with tsgo (tsdown enables dts generation automatically — packages either declare types in package.json or inherit declaration: true from the shared @sanity/tsconfig presets — and picks the tsgo generator because the installed typescript is v7+), which requires exported types to be portable. A TS2883 error printed above the failure means an inferred exported type references a module that is not publicly addressable (for example a deep .pnpm or dts-chunk path). Fix it by adding an explicit type annotation at the reported site so the emitted declaration can use a locally imported name (see the actor annotations in plugins/sanity-plugin-dashboard-widget-vercel/src/machines/form.ts for an example). Note that test files are part of the declaration program too, since the single tsconfig.json includes them.

Lint Errors About Missing Types

Run pnpm build first—some packages need to be built for type information to be available.

"Command not found: pnpm"

Ensure you have pnpm v10+ installed, then run:

corepack enable

Test Studio Won't Load

  1. Check you're logged into Sanity in the browser
  2. Verify you have access to the project
  3. Check the browser console for specific errors

Cursor Cloud specific instructions

Services

Service Port Purpose
Test Studio (pnpm dev) 3333 Local Sanity Studio for manual plugin testing

No Docker, databases, or other local services are required. CI-style verification (pnpm lint, pnpm build, pnpm test run) runs entirely in-process.

Starting the dev server

The test studio dev script uses sanity dev --no-auto-updates, so pnpm dev starts non-interactively (no version-upgrade prompt). The studio serves at http://localhost:3333.

Sanity authentication

The test studio connects to Sanity Cloud (project ppsg7ml5, dataset plugins by default). For cloud agents, use the injected STUDIO_AUTH_TOKEN secret instead of interactive login.

Interactive / manual browser: navigate with the token in the URL hash (Sanity consumes it on load and removes it from the address bar):

http://localhost:3333/home#token=<STUDIO_AUTH_TOKEN>

Build the URL from the secret:

node -e "const t=process.env.STUDIO_AUTH_TOKEN; console.log('http://localhost:3333/home#token=' + encodeURIComponent(t))"

Open that URL in the browser to authenticate and land directly in the Home workspace (the merged "kitchen sink"). Without a token, workspaces show as "Signed out".

Playwright e2e: do not use the #token= hash. Tests seed __studio_auth_token_<projectId> via Playwright storageState (same underlying Studio auth). Requires SANITY_E2E_SESSION_TOKEN, SANITY_E2E_PROJECT_ID, and dataset env (SANITY_E2E_DATASET and/or SANITY_E2E_DATASET_CHROMIUM / SANITY_E2E_DATASET_FIREFOX). See e2e/README.md.

E2E smoke tests

# Browsers (once)
pnpm --filter e2e exec playwright install --with-deps chromium firefox

# Required: SANITY_E2E_SESSION_TOKEN, SANITY_E2E_PROJECT_ID, SANITY_E2E_DATASET
# (or copy e2e/.env.example → e2e/.env.local)
pnpm e2e:setup   # optional locally; CI creates ephemeral pr-*/main-* datasets
pnpm test:e2e

Tests run against the dedicated bare-bones dev/e2e-studio (workspaces /chromium and /firefox; plugins are added there together with their e2e tests) — not dev/test-studio. Locally Playwright uses sanity dev; CI creates ephemeral per-browser datasets, deploys the e2e studio to Vercel (vercel build + deploy --prebuilt), then runs Chromium and Firefox as parallel matrix jobs against the preview URL. Auth preflight calls /users/me before the suite runs — missing or wrong tokens fail fast (do not use SANITY_DEPLOY_TOKEN). CI also requires SANITY_E2E_PROJECT_ID and Vercel secrets (VERCEL_E2E_REPORT_*, shared by the studio preview and report deploys) — see e2e/README.md.

Node.js version notes

dev/test-studio declares engines.node: "24"; the monorepo otherwise targets latest LTS. Node 24 is preferred when available, and Node >= 22.18 is required for a full pnpm build: the @repo/generators build runs tsdown, which loads its .mts config through Node's native TypeScript support. On older Node 22.x (e.g. the v22.14.0 that may be the VM default) that build fails with Failed to import module "unrun". pnpm format and pnpm lint also require Node >= 22.18 (oxfmt and oxlint load the TypeScript oxfmt.config.ts / oxlint.config.ts through the same mechanism). A new enough runtime is usually available via nvm (e.g. export PATH="$HOME/.nvm/versions/node/v22.22.2/bin:$PATH"). pnpm test works on older Node 22 too.

Lint / build / test

Standard commands from the Quick Reference table apply. Run pnpm build before pnpm lint if type errors reference missing dist/ output. pretest automatically builds all packages except dev/* before Vitest runs.

Related Documentation