Skip to content

Latest commit

 

History

History
135 lines (92 loc) · 15.7 KB

File metadata and controls

135 lines (92 loc) · 15.7 KB

Contributing

Welcome, and thanks for your interest in contributing! Please take a moment to review the following:

Project Goals

Keep these principles in mind when proposing changes — they help keep cva focused:

  • Performance & minimal footprint – keep the runtime tiny and dependency-light.
  • Strongly typed – first-class TypeScript; let the types guide usage and catch mistakes.
  • Simplicity – a small, predictable API surface that's easy to reason about.
  • Easy to extend – compose cleanly with the wider ecosystem (e.g. Tailwind CSS-specific tooling).
  • Avoid reinventing the wheel – lean on proven primitives rather than rebuilding them.
  • The perfect tool for design systems – optimise for the people building and maintaining them.

Style Guide

  • Commits follow the "Conventional Commits" specification. This allows for changelogs to be generated automatically upon release.
  • Code is formatted via Prettier
  • JavaScript is written as TypeScript where possible.
  • packages/cva's type exports: any type that can appear named (not structurally expanded) in a consumer's generated .d.ts when they compile with declaration: true must be exported from the public entry point that exposes the signature (cva, cva/config, or cva/utils), even if it's not meant for direct use — an unexported-but-nameable type breaks their build with a TS4023/TS2459-family error even though cva's own build stays green. Not every type reachable from a public signature needs this: TypeScript structurally expands some of them (e.g. the call-signature parameter helpers) instead of naming them, so those stay unexported on purpose — export the minimum that a real consumer build fails to compile without (see AGENTS.md's Learnings for how to check). These exports exist for that portability reason only, not as a feature we want people to reach for directly, so mark them with a short JSDoc saying so and don't add docs-site coverage for them. packages/cva/src/index.test.ts pins the current set, but only catches losing one of these exports, not a new type that newly needs one.

Getting Started

Setup

  1. Fork the repo and clone to your machine.
  2. Create a new branch with your contribution.
  3. In the repo, prior to any other installation steps, run:
    corepack enable
  4. Install dependencies:
    pnpm i
    Installing also registers the git pre-commit hook: the prepare:hooks script sets core.hooksPath to .github/hooks, whose pre-commit runs pnpm lint-staged against your staged files on every commit. Don't bypass it (--no-verify) — if it didn't fire, re-run pnpm i and check git config core.hooksPath prints .github/hooks.
  5. Voilà, you're ready to go!

Node.js versions

The repo targets the Node.js version in .node-version (the canonical version file, read by fnm, mise, actions/setup-node, and others). .nvmrc is a symlink to .node-version kept for compatibility with nvm users — nvm has declined to support .node-version directly, so the symlink keeps nvm-using contributors working from a single source of truth. The two must not be split into separate files. The root package's engines.node is the single source of truth, and docs follows it. The published library packages (packages/cva, packages/class-variance-authority) intentionally omit engines.node so they don't constrain consumers — cva runs on any reasonable Node, and a pin would just emit EBADENGINE warnings for anyone on an older Node. The dev/CI Node requirement is enforced by .node-version, the root engines.node, and CI, not by the libraries.

syncpack keeps the declared versions aligned via a custom type (configured under the syncpack key in the root package.json): any non-example package that declares engines.node snaps to the root package's value, while the examples use their own ">=22" pin. The field stays optional — packages without an engines.node (including the published libraries) aren't flagged. When you bump Node, update .node-version and the root engines.node together (everything else follows; .nvmrc will automatically resolve to the updated version; only the examples' ">=22" pin is separate), then run pnpm syncpack:fix.

The framework demos under examples/ are the exception. They're embedded in the docs as live StackBlitz playgrounds, and StackBlitz runs them in a WebContainer — an in-browser Node.js that ships a single, non-configurable version (Node 22 at time of writing; Node 24 is not yet supported). Pinning an example's engines.node to a version the WebContainer can't provide makes StackBlitz emit EBADENGINE "unsupported engine" warnings on install, so the examples deliberately use a permissive range (">=22") that the WebContainer's Node satisfies. Don't raise the examples' engines.node above what StackBlitz can run — keep it as a lower-bound range, not an exact pin, until WebContainers ship the newer version.

Scripts

Run these from the repo root:

  • pnpm dev – runs vitest, watching for file changes
  • pnpm test – runs the test suite with coverage
  • pnpm build – production build of the packages
  • pnpm check – type checks every package
  • pnpm bundlesize – verifies bundle size limits (size-limit)
  • pnpm bench – builds the packages, then runs the vitest bench performance scenarios against each built package (add BENCH_BASELINES_DIR=<dir> after running pnpm bench:baselines --out <dir> to also benchmark published npm baselines alongside your local changes)
  • pnpm bench:compare – renders a markdown comparison table from the test/bench/.output/benchmark-*.json files produced by pnpm bench
  • pnpm bench:preview – one command that installs the npm baselines, runs pnpm bench against them, and writes the rendered comparison table to test/bench/.output/preview.md (see Benchmarks below)
  • pnpm bench:check – type checks the test/bench/ scripts
  • pnpm prettier --check . – checks formatting (--write to fix)
  • pnpm syncpack:lint – checks dependency-version consistency (pnpm syncpack:fix to fix)
  • pnpm lint:skills – validates the agent skills in .agents/skills (skill-check, strict mode)
  • pnpm lint-staged – runs the pre-commit checks against currently staged files (exactly what the pre-commit hook runs)

To scope a package script, use a pnpm filter with one it defines, e.g. pnpm --filter cva check. cva has no test script, so pnpm --filter cva test succeeds without running tests. To run its runtime tests, use pnpm exec vitest run --config .config/vitest.config.ts packages/cva; this scoped command does not collect coverage. Run pnpm test for the full coverage gate and pnpm check separately for compile-time type assertions.

CI gates on build, bundlesize, check, prettier, skills, syncpack, and test, so run the matching scripts locally before opening a PR. CI also runs an informational benchmark job, which posts its results as a PR comment.

Build & publish (packages/*)

Both published packages (packages/cva and packages/class-variance-authority) build with tsdown. The shared options live in .config/tsdown.base.mts (alongside the repo's other shared tool config), and each package's tsdown.config.mts spreads that base and adds only its genuine deltas — entry points, sourcemaps, and output extensions. One pnpm --filter <package> build emits the whole dual-format output to dist/. The base uses tsdown's explicit extensions (fixedExtension: true), so cva ships index.cjs + index.d.cts (CommonJS) and index.mjs + index.d.mts (ESM); class-variance-authority overrides fixedExtension to keep the index.js + index.d.ts CommonJS layout it has always published, in case anything in the wild references those dist/ paths directly.

How the packages transform for publish

The exports and publishConfig.exports blocks in each package's package.json are machine-generated: the config's exports: { devExports: true } makes tsdown rewrite both on every build. Never hand-edit them — the next build silently overwrites your change; adjust that package's tsdown.config.mts (or the shared base) instead. The hand-maintained exception is publishConfig.typesVersions: class-variance-authority has a node10 fallback for ./types, and cva has fallbacks for ./config and ./utils. tsdown preserves them but does not generate them. The two blocks implement a dev/publish split:

  • The top-level exports points at ./src/*.ts, so workspace consumers (tests, examples, docs) always resolve the raw TypeScript source with no build step in between.
  • publishConfig.exports points at dist/. pnpm applies publishConfig when packing or publishing (pnpm pack / pnpm publish — never npm pack, which skips the rewrite entirely), so the tarball people install resolves the built output.

Two details of the published map are deliberate:

  • There are no explicit types conditions — TypeScript auto-pairs index.mjsindex.d.mts and index.jsindex.d.ts, which means import-ing consumers get true ESM declarations rather than a shared CommonJS-flavoured .d.ts. The attw gate (below) verifies all four resolution modes stay green.
  • "./package.json": "./package.json" is exported because declaring an exports map encapsulates every unlisted subpath, which would break tooling that reads a dependency's package.json directly (bundler plugins, Metro, framework CLIs doing version detection). tsdown adds the line by default; it exposes nothing new — the file ships in every tarball regardless.

The config, option by option

.config/tsdown.base.mts sets the shared defaults: dual esm/cjs output, platform: "neutral" (keeps the packages browser/Node/edge-portable), es2019 target, tsc-generated dts, the regenerated exports map, and the publint/attw/unused gates — with brief inline notes above the non-obvious ones. Each package's tsdown.config.mts spreads the base and overrides only genuine deltas. Note the attw gate needs every consuming package to declare its own @arethetypeswrong/core devDependency — it's an optional tsdown peer, so a missing one makes tsdown skip attw silently rather than fail.

What tsdown does not own: size-limit remains the bundle-size budget (tsdown's per-file gzip size report is informational only), the tsc --noEmit check remains the source type check, and version bumps stay manual per Releases.

Day to day: pnpm --filter <package> dev runs the build in watch mode, and because the root prepare:packages script builds on every pnpm install, the publish-shape gates run then too — a broken manifest fails fast on your machine rather than in CI. If that per-install cost ever becomes a problem, attw: 'ci-only' in the config confines the slowest gate to CI.

Benchmarks

Every PR runs a benchmark CI job that benchmarks each package's local build (test/bench/scripts/*.bench.ts, run via vitest bench) alongside published npm baselines for that same package (resolved per package from npm dist-tags — beta for cva, latest for class-variance-authority — then installed outside the workspace by test/bench/scripts/baselines.ts; the workspace's pnpm-workspace.yaml overrides pin cva/class-variance-authority to workspace:*, so baselines can't be installed inside the workspace without being silently overridden). A missing dist-tag, placeholder version, or baseline installation failure is recorded as skipped, so the local benchmark still runs.

Results are posted as a sticky PR comment (updated in place on every push, including from forks) by a separate pr-comment workflow — see that file's header comment for why it's split out, how the untrusted artifact it reads is validated, and how to add a section of your own to the same comment from a future CI step. The job summary is written only for trusted pushes to main; PRs rely on the fresh-runner comment so PR code cannot inject markdown into a run summary. This is informational only: a regression doesn't fail CI, so treat it as a signal to investigate, not a gate. The comment renderer validates artifact shape and escapes untrusted strings, but it does not authenticate benchmark metrics — a PR author can upload fabricated ops/s numbers on their own branch, so large swings are worth reproducing locally rather than taken as proof.

To reproduce that table locally before pushing, run pnpm bench:preview. It installs the baselines, benchmarks your build against them, and writes the rendered markdown to test/bench/.output/preview.md — produced by the same test/bench/scripts/compare.ts that renders the PR comment, so the preview matches what a PR would show. Offline (no npm access) it falls back to a local-only table with no baseline columns.

benchmark-release attaches a benchmark-<package>.json file to every published GitHub release (workflow_dispatch with a tag input can also run it on demand). test/bench/scripts/report.ts writes that schema and test/bench/scripts/compare.ts reads it — if you change the shape of one, bump schemaVersion and update the other in the same change.

Releases

A trade-off with using a personal repo is that permissions are fairly locked-down. In the mean-time releases will be made manually by the project owner.

Version bumps (the version field in packages/*/package.json) are part of that manual release process — they happen only on main, cut by the project owner, as their own commit separate from any feature/fix work. Don't include a version bump in a feature or fix branch/PR, even if you're an agent implementing a versioned change like "cut vX.Y.Z" — leave that step to the owner on main.

Publishing a release (project owner)

Release one package at a time, from main. For a package <package> (cva or class-variance-authority) at a new <version>:

  1. Check out main and make sure it's up to date: git checkout main && git pull.

  2. Bump the version field in that package's package.json — the only place the version changes.

  3. Commit the bump on its own, using the version as the message: git commit -am "<package>@<version>" (e.g. cva@1.0.0-beta.7).

  4. Push main: git push origin main.

  5. Tag the commit v<version> and push the tag:

    git tag v<version>          # e.g. v1.0.0-beta.7
    git push origin v<version>
  6. Publish from the package: pnpm --filter <package> publish. prepublishOnly runs the tsdown build first, so the publish-shape gates (attw, publint, unused) must pass or the publish aborts. Publish the beta package under the beta dist-tagpnpm --filter cva publish --tag beta — so the prerelease doesn't overwrite latest; the stable package publishes to the default latest.

  7. Create the matching GitHub release for the v<version> tag.

The commit message (<package>@<version>) and tag (v<version>) formats match the existing release history — keep them consistent so the two packages' releases stay legible in a shared tag namespace.