feat: add ui-shadcn-base, ui-shadcn-radix, and ui-tailwind modules; rename styling category to ui

- Rename `styling` → `ui` across the entire codebase (CATEGORIES array, docs, AGENTS.md, CLI tests, resolver, template tests, codemod runner); `ui` is `home: package` so shadcn installs into `packages/ui/` as a workspace dep rather than wiring directly into the app shell
- Add `ui-shadcn-base` (Tailwind v4 / CSS-first, no Radix peer) and `ui-shadcn-radix` (Tailwind v4 + Radix primitives) with full per-framework template sets for Next.js and TanStack Start: `components.json`, `globals.css`, `postcss.config.mjs`, `theme-provider.tsx`, shared `Button` + `utils.ts`; light/dark logos inlined from svgl
- Rename `styling-tailwind` → `ui-tailwind` to match the new category id; update its module definition to `category: "ui"` and `home: package`
- Add four new codemod builtins with tests: `add-array-entry-in-call` (inserts an element into a named array argument of any `CallExpression`), `replace-import` (rewrites a specifier in an existing import declaration), `set-html-attributes` (sets attributes on an HTML/JSX element by tag name or selector), `set-tsconfig-paths` (merges `compilerOptions` entries into a `tsconfig.json` AST node); register all four in the builtins index and export from `@stanza/codemods`
- Add `wrap-root-layout` builtin tests
- Update registry.mdx, concepts.mdx, cli.mdx, getting-started.mdx, agents.mdx, index.mdx, AGENTS.md, README.md, TODO.md, and SKILL.md to reflect the category rename and new modules
This commit is contained in:
jake committed 2026-05-25 15:26:08 -04:00
1 parent 6767362641
commit 678b5bc1af
68 files changed
+3610 -312

No files matched your search

+6 -6
View File
@@ -1,6 +1,6 @@
# Stanza
Shadcn-style CLI for assembling modular full-stack TS monorepos. Currently ships `init`, `add`, `remove`, `list`, `search` against these **categories**: `framework`, `styling`, `db`, `orm`, `auth`, `tooling`, `testing`. `swap` + `update` verbs and more categories (api, ai, ui, payments, deploy, email, monorepo) are planned — the manifest already reserves the fields they'll need (`modules[category][].version`, `regions`). See the [module registry](apps/web/content/docs/registry.mdx) for the module roadmap and [TODO.md](TODO.md) for active work.
Shadcn-style CLI for assembling modular full-stack TS monorepos. Currently ships `init`, `add`, `remove`, `list`, `search` against these **categories**: `framework`, `ui`, `db`, `orm`, `auth`, `tooling`, `testing`. `swap` + `update` verbs and more categories are planned — the manifest already reserves the fields they'll need (`modules[category][].version`, `regions`). See the [module registry](apps/web/content/docs/registry.mdx) for the module roadmap and [TODO.md](TODO.md) for active work.
Three things differentiate Stanza from other scaffolders:
@@ -19,7 +19,7 @@ Three things differentiate Stanza from other scaffolders:
A generated project has one or more **apps** declared in `stanza.json` as `apps: [{ id, dir, kind }]` (e.g. `{ id: "web", dir: "apps/web", kind: "web" }`). `AppKind = "web" | "native"` is a closed enum, and framework modules declare an `appKind` so the runner can validate that the framework matches its target app. `stanza init` scaffolds a single web app today (`defaultWebApp()`); multi-app init is a planned follow-up, but schema and runtime are already multi-app-shaped — `applyModule` threads a `targetApps: AppSpec[]` per call, and the web builder's `synthesize*` functions all take an `apps` array.
In a **generated project**, a module's output lands per its category's `home` (in the canonical [`CATEGORIES`](packages/registry/src/module.ts) array): `auth`/`db`/`orm` (`home: package`) install into an internal workspace package at `packages/<dir>/` (named `@<manifest.name>/<dir>`, consumed via `workspace:*` by every consuming app; `db` + `orm` share `packages/db/`); `framework`/`styling`/`testing` (`home: app`) wire the chosen app's shell; `tooling` (`home: repo`) writes config at the repo root + scripts in the root `package.json`. The app a record targets is encoded as `apps: ["<id>", …]` on `StanzaModuleRecord` — **required** for `home: app`, **optional** for `home: package` (omitted means "ship app-scoped shims into every app"; an explicit list restricts), and **forbidden** for `home: repo`. The app `id` doubles as the workspace package suffix: `@<manifest.name>/<app.id>` (so `id: "web"` → `@acme/web`, regardless of `dir`). `categoryHome(id)`, `PACKAGE_DIRS`, and `categoryLabel(id)` are all derived from the `CATEGORIES` array.
In a **generated project**, a module's output lands per its category's `home` (in the canonical [`CATEGORIES`](packages/registry/src/module.ts) array): `auth`/`db`/`orm`/`ui` (`home: package`) install into an internal workspace package at `packages/<dir>/` (named `@<manifest.name>/<dir>`, consumed via `workspace:*` by every consuming app; `db` + `orm` share `packages/db/`); `framework`/`testing` (`home: app`) wire the chosen app's shell; `tooling` (`home: repo`) writes config at the repo root + scripts in the root `package.json`. The app a record targets is encoded as `apps: ["<id>", …]` on `StanzaModuleRecord` — **required** for `home: app`, **optional** for `home: package` (omitted means "ship app-scoped shims into every app"; an explicit list restricts), and **forbidden** for `home: repo`. The app `id` doubles as the workspace package suffix: `@<manifest.name>/<app.id>` (so `id: "web"` → `@acme/web`, regardless of `dir`). `categoryHome(id)`, `PACKAGE_DIRS`, and `categoryLabel(id)` are all derived from the `CATEGORIES` array.
The repo runs on the **Vite+ toolchain** (`vp`) — one CLI for dev/build/test/lint/fmt/check/pack, all configured by the root [`vite.config.ts`](vite.config.ts) (`test`/`lint`/`fmt`/`staged` blocks). There is **no** `turbo.json`, `.oxlintrc.json`, `.oxfmtrc.json`, or per-package `vitest.config.ts` — that config now lives in `vite.config.ts`. `vp run <script>` runs a `package.json` script; `vp run -r <task>` fans out across the workspace (topological); `vp run <pkg>#<task>` targets one package. `vp install`/`add`/`remove` delegate to pnpm (the declared `packageManager`). `vite`/`vitest` are catalog-aliased to `@voidzero-dev/vite-plus-core`/`@voidzero-dev/vite-plus-test` in `pnpm-workspace.yaml` — **keep those aliases; never add standalone `vite`/`vitest`.** Source imports use `vite-plus` and `vite-plus/test` (template files under `registry/modules/*/templates/` stay on stock `vite`/`vitest` — they target generated user projects). Git hooks are managed by `vp config` → `.vite-hooks/`; CI bootstraps via `voidzero-dev/setup-vp@v1`.
@@ -69,11 +69,11 @@ Core workflow:
- **Adding a generic codemod**: drop `<id>.ts` under [packages/codemods/src/builtins/](packages/codemods/src/builtins/), default-export a `Codemod<TArgs>`, and register it in [packages/codemods/src/builtins/index.ts](packages/codemods/src/builtins/index.ts). Codemods that bake in module-specific identifiers don't belong — factor them into args
- **Third-party codemods**: deferred. Third-party HTTP-loaded modules can use the existing catalog codemods (pass `{ id, args }` from their manifest) but can't add new ones until we land a proper sandboxed-execution + signing model
- **apps/web previews are server-rendered**: Shiki runs in `apps/web/src/server/highlighter.ts` (module-singleton, kept warm). The builder loader (`createServerFn` in `apps/web/src/server/builder-state.ts`) computes selected files from URL search params, pre-renders Shiki HTML for each, and ships `Record<path, { light, dark }>` to the client. `shiki` must NEVER be imported from a client component — verify after `vp run @stanza/web#build` by grepping `.output/public/assets/*.js` for the shiki **runtime** (`createHighlighter`/`codeToHtml`/`loadWasm`), which should be absent. A bare `grep shiki` now yields false positives: the MDX docs pages ship statically-rendered code blocks with `class="shiki"` + `--shiki-*` CSS vars, which is fine — only the runtime must stay server-side
- **Category taxonomy** (`framework | styling | db | orm | auth | tooling | testing`, more planned). One unified concept: a `Module` carries a single `category` field (no `kind`/`slot` discriminator). A category has two orthogonal, explicit properties — **`cardinality`** (`"one"` single-choice / `"many"` coexisting) and **`home`** (`app`/`repo`/`package`). Both live on the `Category` entry in `CATEGORIES`; `CategoryId`, `KNOWN_CATEGORIES`, `PEER_CATEGORIES`, `PACKAGE_DIRS` all derive from it, so adding a category is a **one-line edit** (`{ id, label, description, cardinality, home }`). Constraint-bearing is **emergent**: a category is a peer candidate only if some module declares `peers`/`match` against it, and the resolver iterates **`PEER_CATEGORIES`** (the `cardinality: "one"` ids) only — so a `many` category like `testing` never participates in others' dispatch. A module _can_ declare a one-way `peers` (e.g. `{ framework: [...] }`) + framework-varying adapters regardless of its own cardinality. `CATEGORIES` order is topological (a category appears after everything it peers on; `many` categories last)
- **Category taxonomy** (`framework | ui | db | orm | auth | tooling | testing`, more planned). One unified concept: a `Module` carries a single `category` field (no `kind`/`slot` discriminator). A category has two orthogonal, explicit properties — **`cardinality`** (`"one"` single-choice / `"many"` coexisting) and **`home`** (`app`/`repo`/`package`). Both live on the `Category` entry in `CATEGORIES`; `CategoryId`, `KNOWN_CATEGORIES`, `PEER_CATEGORIES`, `PACKAGE_DIRS` all derive from it, so adding a category is a **one-line edit** (`{ id, label, description, cardinality, home }`). Constraint-bearing is **emergent**: a category is a peer candidate only if some module declares `peers`/`match` against it, and the resolver iterates **`PEER_CATEGORIES`** (the `cardinality: "one"` ids) only — so a `many` category like `testing` never participates in others' dispatch. A module _can_ declare a one-way `peers` (e.g. `{ framework: [...] }`) + framework-varying adapters regardless of its own cardinality. `CATEGORIES` order is topological (a category appears after everything it peers on; `many` categories last)
- **Manifest stores selections in one `modules` record** keyed by `CategoryId`, each an array (`Partial<Record<CategoryId, StanzaModuleRecord[]>>`); records carry an optional `apps?: string[]` field (see the apps-routing paragraph above). `cardinality: "one"` enforcement is **per-app** for `home: app` categories (`selectedOne(m, cat, appId)` filters; `add` rejects a second pick _for that app_) and per-project for the rest. Read via `selectedOne(m, cat, appId?)` / `selectedAll(m, cat, appId?)`. Region ownership keys on `module.id`, so two modules writing the same file at disjoint dot-paths (vitest `scripts.test` vs playwright `scripts.test:e2e`) never conflict; per-app writes use distinct file paths (`apps/web/...` vs `apps/native/...`), so app-scoped region claims naturally disambiguate
- **Adapter keys** encode peer choices (e.g., `next+drizzle`); the resolver picks the most specific match
- **Module-level install fields**: `dependencies`, `devDependencies`, `env`, `scripts`, and `consumesPackages` are declared at the module level when shared across adapters. Adapter-level fields override per-key (`env` merges by `name`). This avoids re-declaring the same `dependencies` and `env` block in every adapter of a multi-framework module (cf. Better Auth — 6 adapters, but `better-auth` dep and the two env vars are declared once)
- **Install home routing**: a module's templates/deps/scripts route by its category's `home` (the single decision point — `categoryHome(id)`, read by both the CLI runner and the web's `synthesizePackageJsons`). `home: package` (`auth`/`db`/`orm`) → `packages/<dir>/` workspace package (named `@<manifest.name>/<dir>`), templates `scope: "package"`; every _consuming_ app (per the record's `apps` field, or all apps when omitted) gets a `workspace:*` dep. `home: repo` (`tooling`) → `scope: "repo"` config at the repo root + deps/scripts in the root `package.json`. `home: app` (`framework`/`styling`/`testing`) → the app the record targets; `scope: "app"` templates emit per-target-app (so a package-home module that ships shims into both web and native fans out cleanly). Package bootstrap files (the package's `package.json` + `tsconfig.json` and each consuming app's workspace dep) are **system-owned** — not tracked in `regions`. `stanza remove`'s sweep (over `PACKAGE_DIRS`) deletes them when no claims remain under `packages/<dir>/` and strips the workspace dep from _every_ app's `package.json`
- **Install home routing**: a module's templates/deps/scripts route by its category's `home` (the single decision point — `categoryHome(id)`, read by both the CLI runner and the web's `synthesizePackageJsons`). `home: package` (`auth`/`db`/`orm`/`ui`) → `packages/<dir>/` workspace package (named `@<manifest.name>/<dir>`), templates `scope: "package"`; every _consuming_ app (per the record's `apps` field, or all apps when omitted) gets a `workspace:*` dep. `home: repo` (`tooling`) → `scope: "repo"` config at the repo root + deps/scripts in the root `package.json`. `home: app` (`framework`/`testing`) → the app the record targets; `scope: "app"` templates emit per-target-app (so a package-home module that ships shims into both web and native fans out cleanly). Package bootstrap files (the package's `package.json` + `tsconfig.json` and each consuming app's workspace dep) are **system-owned** — not tracked in `regions`. `stanza remove`'s sweep (over `PACKAGE_DIRS`) deletes them when no claims remain under `packages/<dir>/` and strips the workspace dep from _every_ app's `package.json`
- **Generated projects don't share a tsconfig base**: every `apps/*/tsconfig.json` and `packages/*/tsconfig.json` is self-contained. The framework module ships the app's tsconfig; the runner's `ensureSlotPackage` writes a matching self-contained config when bootstrapping a slot package. `tsconfig.json` lives in the Stanza repo only; do not emit it in generated trees
- **Cross-package wiring**: when a module's source code imports from another internal package (e.g. `better-auth`'s `auth.ts` reads `db` from the orm package), declare `consumesPackages: ["db"]` at the module level. The runner adds `@<project>/db: workspace:*` to this module's own package. Module-level (not adapter-level) because the source code is shared infrastructure — adapters vary in templates/codemods, not in what they import. Templates can reference other packages via `{{packages.<dir>.name}}` substitution (e.g. `{{packages.db.name}}` → `@my-app/db`) — substitution runs over both template bodies (when `template: true`) and codemod-invocation `args` string values. The full template context is `{ project: { name, appDir }, app: { id, dir, kind }, package: { name }, packages: Record<dir, { name }> }`; `app.*` and the legacy `project.appDir` alias are bound to the _currently-applying app_ (the runner re-builds context per target app in the loop). `renderTemplate` + `buildRenderContext` live in `@stanza/registry` so the CLI's apply path and the web preview's `synthesizeTemplates` produce byte-identical output
- **Region ownership** in `stanza.json` is the source of truth for `stanza remove`. Two modules claiming the same region is a hard error (`RegionConflictError`). Note: regions are _not yet sufficient for `swap`_ — that verb additionally needs old-adapter-region → new-adapter-region mapping; design pending
@@ -89,8 +89,8 @@ Core workflow:
- Templates go in `templates/`, referenced by `src` path. `scope` decides where `dest` lands:
- `"app"` (default) → each targeted app's `dir` (the runner emits once per consuming app, so the same `dest` lands in `apps/web/...` and `apps/native/...` when both target the module)
- `"repo"` → repo root
- `"package"` → `packages/<dir>/` where `<dir>` is the active category's `home.dir` — only valid for categories with `home.kind === "package"` (`auth`, `db`, `orm`)
- For `auth`/`db`/`orm` modules, default to `scope: "package"` for everything that can live inside the package boundary. Reach for `scope: "app"` only when a framework convention forces the file to sit at the app root (e.g. Next's `middleware.ts`, App Router API routes). App-scoped files should be thin shims that `import` from `{{package.name}}`; set `template: true` and the runner runs mustache substitution
- `"package"` → `packages/<dir>/` where `<dir>` is the active category's `home.dir` — only valid for categories with `home.kind === "package"` (`auth`, `db`, `orm`, `ui`)
- For `auth`/`db`/`orm`/`ui` modules, default to `scope: "package"` for everything that can live inside the package boundary. Reach for `scope: "app"` only when a framework convention forces the file to sit at the app root (e.g. Next's `middleware.ts`, App Router API routes). App-scoped files should be thin shims that `import` from `{{package.name}}`; set `template: true` and the runner runs mustache substitution
- For cross-package imports (e.g. `auth` reading `db`), declare `consumesPackages: ["<dir>"]` **at the module level** (not on each adapter). The runner adds the workspace dep on first apply. Write the import as `{{packages.<dir>.name}}` (e.g. `import { db } from "{{packages.db.name}}";`)
- **Hoist shared install fields** (`dependencies`, `devDependencies`, `env`, `scripts`) to the module level when they don't vary across adapters. Adapter-level values still work for true variations and override per-key. Example: Better Auth's `dependencies: { "better-auth": "^1.6.11" }` and the two env vars are declared once at module level; only the per-(framework, orm) templates and codemods sit in the adapter blocks
- Framework modules MUST NOT ship a `package.json.tpl` — it collides with `addPackageDependency`. Let the runner merge deps into the host's package.json. The same rule applies to **package-scoped** modules: don't ship a `packages/<dir>/package.json` template — the runner's `ensureSlotPackage` bootstraps one and merges deps in
+2 -2
View File
@@ -6,7 +6,7 @@ Modular monorepo template CLI — aka shadcn for full-stack TypeScript projects.
npm init stanza my-revolutionary-app
```
Pick a framework, ORM, database, auth provider, and styling — get a clean monorepo with idiomatic code, vendored into your repo. Layer in more modules later with `stanza-cli add`.
Pick a framework, ORM, database, auth provider, and UI — get a clean monorepo with idiomatic code, vendored into your repo. Layer in more modules later with `stanza-cli add`.
> [!WARNING]
> 🚧 **Major work in progress!** See the [module registry](https://stanza.tools/docs/registry) for the module roadmap, and [TODO.md](./TODO.md) for active work.
@@ -49,7 +49,7 @@ packages/
codemods/ # ts-morph helpers for region-aware patching
create-stanza/ # `npm init stanza` template shim
registry/
modules/ # first-party modules (framework, orm, db, auth, styling, tooling, testing)
modules/ # first-party modules (framework, orm, db, auth, ui, tooling, testing)
```
## License
+4 -4
View File
@@ -105,8 +105,8 @@ Functional but a few real issues to fix.
- [x] `auth-better-auth` VITE_BETTER_AUTH_URL: stale — auth-client.ts defaults to current origin; server reads `BETTER_AUTH_URL` automatically. No template references VITE_BETTER_AUTH_URL.
- [x] `auth-better-auth` drizzle `auth.ts` hardcoded `provider: "pg"` — split into per-db templates (`auth.drizzle.postgres.ts` + `auth.drizzle.sqlite.ts`) so sqlite gets `provider: "sqlite"`
- [ ] `auth-better-auth` sqlite schema variant exists (`shared/auth-schema.drizzle-sqlite.ts`); confirm it matches what better-auth actually expects on SQLite end-to-end
- [x] `styling-tailwind` + `framework-next` `app/globals.css` conflict — fixed by switching styling-tailwind's next adapter to prepend `@import "tailwindcss";` via the `append-to-file` codemod's new `position: "start"` mode. Framework retains ownership of base styles; revert restores cleanly
- [x] tanstack-start globals.css orphan — fixed symmetrically to Next. framework-tanstack-start now ships `src/globals.css` and `__root.tsx` imports it; styling-tailwind's tanstack adapter prepends `@import "tailwindcss";` via `append-to-file` instead of writing the file. No new codemod needed; pattern is now identical across both framework adapters.
- [x] `ui-tailwind` + `framework-next` `app/globals.css` conflict — fixed by switching ui-tailwind's next adapter to prepend `@import "tailwindcss";` via the `append-to-file` codemod's new `position: "start"` mode. Framework retains ownership of base styles; revert restores cleanly
- [x] tanstack-start globals.css orphan — fixed symmetrically to Next. framework-tanstack-start now ships `src/globals.css` and `__root.tsx` imports it; ui-tailwind's tanstack adapter prepends `@import "tailwindcss";` via `append-to-file` instead of writing the file. No new codemod needed; pattern is now identical across both framework adapters.
- [ ] Authoring guide: docs page covering `defineModule`, slot/peer/capability semantics, template vs. codemod choice, region ownership, and the `scope: "package"` + `consumesPackages` story
## Registry expansion
@@ -114,7 +114,7 @@ Functional but a few real issues to fix.
The full first-party module roadmap lives in the [module registry](apps/web/content/docs/registry.mdx). These are the schema/resolver changes needed before most of those modules can land.
- [x] **Unified `Category` taxonomy** — collapsed the slot/add-on split into one `Category` concept with two orthogonal, explicit axes: `cardinality` (`"one" | "many"`) and `home` (`app | repo | package`, replacing `packageDir`+`repoScoped`). `Module` is no longer a discriminated union — it carries a single `category` field. The manifest unified to one `modules: Partial<Record<CategoryId, StanzaModuleRecord[]>>` (arrays everywhere; `"one"` enforced ≤1 by `add`/`init`), bumping `CURRENT_MANIFEST_VERSION` 0.1→0.2 (clean break, no migration). Constraint-bearing is now emergent — the resolver iterates `PEER_CATEGORIES` (the `"one"` ids) only. Deleted `isAddon`/`moduleGroup`/`SlotModule`/`AddonModule`/`slotLabel`/`addonLabel`/`groupLabel`/`SLOT_PACKAGE_DIR`/`SLOT_REPO_SCOPED`/`ADDON_PACKAGE_DIR`/`KNOWN_SLOTS`/`KNOWN_ADDONS`; added `CATEGORIES`/`KNOWN_CATEGORIES`/`PEER_CATEGORIES`/`PACKAGE_DIRS`/`categoryHome`/`categoryCardinality`/`isMulti`/`categoryLabel`/`selectedOne`/`selectedAll`. Routing decided once in `categoryHome`, shared by the CLI runner + web `synthesizePackageJsons`. Wired through resolver/runner/CLI (add/remove/init/list/wizard, unified `--<category>` flags) + web builder (cards render single- vs multi-select off `isMulti`). All 14 modules migrated `slot`→`category`
- [ ] Add new categories to `CATEGORIES`: `api`, `ai`, `ui`, `payments` (all `cardinality: one`). Decide topological order for the wizard prompts (api/ai after framework)
- [ ] Add new categories to `CATEGORIES`: `api`, `ai`, `payments` (all `cardinality: one`). Decide topological order for the wizard prompts (api/ai after framework)
- [ ] Capability tag expansion — current set is `web | native | react | node | edge | ssr | rsc`. Adding Nuxt/Svelte/Solid needs `vue`, `svelte`, `solid` capabilities; existing React-only modules must add `requires: ['react']` so the resolver filters correctly
- [ ] `monorepo` is hardcoded to Turborepo in `bootstrapShell` — promote to a real category when a second option lands
- [ ] `packageManager` Yarn support — different workspace/lockfile semantics than pnpm/bun/npm; needs its own bootstrap branch
@@ -123,7 +123,7 @@ The full first-party module roadmap lives in the [module registry](apps/web/cont
## Infrastructure
- [x] GitHub Actions CI: lint, format check, registry build, web build (generates routeTree.gen.ts), typecheck (every workspace via turbo), tests, CLI bundle + smoke. Single workflow at `.github/workflows/ci.yml`
- [ ] Golden snapshot tests per module combination (per the plan's verification section) — for each valid `(framework, orm, db, auth, styling, pm)` tuple, run `stanza init` headless, snapshot the tree, compare against fixture
- [ ] Golden snapshot tests per module combination (per the plan's verification section) — for each valid `(framework, orm, db, auth, ui, pm)` tuple, run `stanza init` headless, snapshot the tree, compare against fixture
- [ ] Integration test for the canonical stack — Docker Postgres + Playwright sign-up flow
- [ ] Registry deploy pipeline — on push to main, build `dist/registry/` and push to Vercel/CF
- [x] npm publish workflow — Changesets configured (`.changeset/config.json`, public access, `@changesets/changelog-github` for PR links). [Release workflow](.github/workflows/release.yml) runs `changesets/action@v1` on push to main: opens a "Version Packages" PR when changesets queue up; merging publishes via `pnpm release` (build CLI + create-stanza, then `changeset publish`). Provenance attestations enabled. Tarball dry-run verified: `stanza-cli` is 20 KB, `create-stanza` is 1.7 KB; both `npm install`-able from a clean tmpdir and `--version` / `--help` work. Repo needs `NPM_TOKEN` secret before the first publish
+3 -3
View File
@@ -51,7 +51,7 @@ describe("cmdInit --yes", () => {
name: "app",
yes: true,
framework: "next",
styling: "tailwind",
ui: "tailwind",
db: "postgres",
orm: "drizzle",
auth: "better-auth",
@@ -63,7 +63,7 @@ describe("cmdInit --yes", () => {
expect(fs.existsSync(path.join(projectRoot, "stanza.json"))).toBe(true);
const manifest = JSON.parse(fs.readFileSync(path.join(projectRoot, "stanza.json"), "utf8"));
expect(manifest.modules.framework[0].id).toBe("next");
expect(manifest.modules.styling[0].id).toBe("tailwind");
expect(manifest.modules.ui[0].id).toBe("tailwind");
expect(manifest.modules.db[0].id).toBe("postgres");
expect(manifest.modules.orm[0].id).toBe("drizzle");
expect(manifest.modules.auth[0].id).toBe("better-auth");
@@ -85,7 +85,7 @@ describe("cmdInit --yes", () => {
const manifest = JSON.parse(fs.readFileSync(path.join(tmp, "minimal", "stanza.json"), "utf8"));
expect(manifest.modules.framework?.[0]?.id).toBe("next");
expect(manifest.modules.styling).toBeUndefined();
expect(manifest.modules.ui).toBeUndefined();
expect(manifest.modules.auth).toBeUndefined();
});
+32 -25
View File
@@ -390,31 +390,32 @@ function ensureSlotPackage(args: {
if (!dryRun) {
// Self-contained tsconfig — generated projects don't share a base, so
// each app and package stands on its own. Mirrors the shape framework
// modules ship for app `tsconfig.json`.
// modules ship for app `tsconfig.json`. The `ui` package adds JSX +
// a path alias to its own `./src/*` so internal subpath self-imports
// (e.g. button.tsx → `@<name>/ui/lib/utils`) resolve at type-check time.
const compilerOptions: Record<string, unknown> = {
target: "ES2022",
lib: ["dom", "dom.iterable", "esnext"],
module: "esnext",
moduleResolution: "bundler",
strict: true,
noUncheckedIndexedAccess: true,
skipLibCheck: true,
esModuleInterop: true,
resolveJsonModule: true,
isolatedModules: true,
noEmit: true,
types: ["node"],
};
if (packageDir === "ui") {
compilerOptions.jsx = "react-jsx";
compilerOptions.baseUrl = ".";
compilerOptions.paths = { [`${packageName}/*`]: ["./src/*"] };
}
fs.writeFileSync(
tsconfigPath,
JSON.stringify(
{
compilerOptions: {
target: "ES2022",
lib: ["dom", "dom.iterable", "esnext"],
module: "esnext",
moduleResolution: "bundler",
strict: true,
noUncheckedIndexedAccess: true,
skipLibCheck: true,
esModuleInterop: true,
resolveJsonModule: true,
isolatedModules: true,
noEmit: true,
types: ["node"],
},
include: ["src"],
exclude: ["node_modules"],
},
null,
2,
) + "\n",
JSON.stringify({ compilerOptions, include: ["src"], exclude: ["node_modules"] }, null, 2) +
"\n",
"utf8",
);
}
@@ -473,13 +474,19 @@ function renderArgs(
if (Array.isArray(value)) return value.map(visit);
if (value && typeof value === "object") {
const out: Record<string, JsonValue> = {};
for (const [k, v] of Object.entries(value)) out[k] = visit(v);
// Render keys too — `set-tsconfig-paths` and similar codemods use
// mustache-substituted strings as object keys (e.g. `"@<name>/ui/*"`).
// Keys without `{{ }}` pass through Handlebars unchanged, so this is
// safe for the other codemods whose keys are stable identifiers.
for (const [k, v] of Object.entries(value)) {
out[renderTemplate(k, context)] = visit(v);
}
return out;
}
return value;
};
const out: Record<string, JsonValue> = {};
for (const [k, v] of Object.entries(args)) out[k] = visit(v);
for (const [k, v] of Object.entries(args)) out[renderTemplate(k, context)] = visit(v);
return out;
}
+2 -2
View File
@@ -15,13 +15,13 @@ const main = defineCommand({
name: "stanza",
version,
description:
"Modular monorepo template CLI — shadcn for stacks.\n\n" +
"Modular monorepo scaffolding CLI.\n\n" +
"Examples\n" +
" stanza init my-app --yes --framework=next --orm=drizzle --db=postgres --testing=vitest,playwright\n" +
" stanza add auth better-auth\n" +
" stanza add testing vitest\n" +
" stanza remove testing vitest\n" +
" stanza remove styling\n\n" +
" stanza remove payments\n\n" +
"Docs: https://stanza.tools",
},
subCommands: { init, add, remove, list, search },
+18 -38
View File
@@ -3,53 +3,35 @@ title: Agents
description: Drive Stanza from Claude Code, Codex, Cursor, or any other coding agent.
---
Stanza is built to be agent-friendly: every verb has a non-interactive form, the
manifest is declarative state, and `--dry-run` previews any mutation before it
runs. An agent that follows a handful of rules can scaffold and modify a Stanza
project as reliably as a human at a prompt.
Stanza is built to be agent-friendly: every verb has a non-interactive form, the manifest is declarative state, and `--dry-run` previews any mutation before it runs. An agent that follows a handful of rules can scaffold and modify a Stanza project as reliably as a human at a prompt.
## SKILL.md
For Claude Code, Codex, and other agent runtimes that load skills, Stanza ships
a self-contained skill bundle. Install it with the
[Skills CLI](https://skills.sh):
For Claude Code, Codex, and other agent runtimes that load skills, Stanza ships a self-contained skill bundle. Install it with the [Skills CLI](https://skills.sh):
```sh
npx skills add https://github.com/jakejarvis/stanza --skill stanza-cli
```
That drops the skill into your agent's skill directory; the agent picks it up
on the next prompt that matches its triggers (running `stanza-cli`, creating a
project, adding modules, etc.). The skill is designed to work without access to
this source repo — it encodes the published CLI contract alone.
That drops the skill into your agent's skill directory; the agent picks it up on the next prompt that matches its triggers (running `stanza-cli`, creating a project, adding modules, etc.). The skill is designed to work without access to this source repo — it encodes the published CLI contract alone.
The source lives at
[`skills/stanza-cli/`](https://github.com/jakejarvis/stanza/tree/main/skills/stanza-cli)
if you'd rather vendor it directly.
<Callout type="info">
The source lives at
[`skills/stanza-cli/`](https://github.com/jakejarvis/stanza/tree/main/skills/stanza-cli) if you'd
rather vendor it directly.
</Callout>
## Agent guidance
A few rules on top of the [CLI surface](/docs/cli) keep agent runs predictable:
- **Discover before assuming.** The registry evolves — confirm any module id
with [`npx -y stanza-cli@latest search`](/docs/cli#search) before passing it
to `add`. Use the printed `category/id` value, not the display label.
- **Prefer `npx` for automation.** `npx -y stanza-cli@latest <verb>` avoids the
package-manager argument-forwarding ambiguity that `pnpm create` / `npm
create` / `bun create` introduce.
- **Preview with [`--dry-run`](/docs/cli#global-flags)** before mutating
commands when you want to show the user what's about to happen.
- **Respect the [dirty-worktree refusal](/docs/cli#global-flags).** Commit or
stash first. Only reach for `--dangerously-allow-dirty` with the user's
explicit approval.
- **Pass [`--app=<id>`](/docs/cli#add)** to `add`/`remove` in multi-app
projects. Single-app projects auto-target, but if `stanza.json`'s `apps`
array has more than one entry, scripts should pass the flag explicitly
rather than relying on cwd inference.
- **`stanza.json` is generated state.** Don't hand-edit it; use the verbs that
maintain it (`add`, `remove`).
- **Generated files are user-owned source.** Once Stanza writes a template,
edit it like any other file in the repo.
- **Discover before assuming.** The registry evolves — confirm any module id with [`npx -y stanza-cli@latest search`](/docs/cli#search) before passing it to `add`. Use the printed `category/id` value, not the display label.
- **Prefer `npx` for automation.** `npx -y stanza-cli@latest <verb>` avoids the package-manager argument-forwarding ambiguity that `pnpm create` / `npm create` / `bun create` introduce.
- **Preview with [`--dry-run`](/docs/cli#global-flags)** before mutating commands when you want to show the user what's about to happen.
- **Respect the [dirty-worktree refusal](/docs/cli#global-flags).** Commit or stash first. Only reach for `--dangerously-allow-dirty` with the user's explicit approval.
- **Pass [`--app=<id>`](/docs/cli#add)** to `add`/`remove` in multi-app projects. Single-app projects auto-target, but if `stanza.json`'s `apps` array has more than one entry, scripts should pass the flag explicitly rather than relying on cwd inference.
- **`stanza.json` is generated state.** Don't hand-edit it; use the verbs that maintain it (`add`, `remove`).
- **Generated files are user-owned source.** Once Stanza writes a template, edit it like any other file in the repo.
## Common errors
@@ -63,8 +45,7 @@ create` / `bun create` introduce.
## Putting it together
A minimal agent script that scaffolds a project, adds auth, and verifies
nothing else changed:
A minimal agent script that scaffolds a project, adds auth, and verifies nothing else changed:
```sh
# 1. Confirm the modules exist.
@@ -73,7 +54,7 @@ npx -y stanza-cli@latest search
# 2. Scaffold.
npx -y stanza-cli@latest init my-app --yes \
--framework=tanstack-start \
--styling=tailwind \
--ui=tailwind \
--db=postgres \
--orm=drizzle \
--pm=pnpm
@@ -90,5 +71,4 @@ pnpm install
npx -y stanza-cli@latest list
```
When reporting results back to the user, include the exact command run, whether
it wrote files, and any follow-up install needed.
When reporting results back to the user, include the exact command run, whether it wrote files, and any follow-up install needed.
+13 -43
View File
@@ -3,8 +3,7 @@ title: CLI
description: The Stanza command-line verbs, flags, and environment variables.
---
Stanza ships five verbs against the category taxonomy. Run any verb with
`--help` for its full flag list:
Stanza ships five verbs against the category taxonomy. Run any verb with `--help` for its full flag list:
```sh
npx stanza-cli add --help
@@ -12,8 +11,7 @@ npx stanza-cli add --help
## `init`
Scaffold a new monorepo. Without flags it launches an interactive wizard;
with `--yes` it takes every pick from flags.
Scaffold a new monorepo. Without flags it launches an interactive wizard; with `--yes` it takes every pick from flags.
```sh
npx stanza-cli init [name] --yes --framework=next --orm=drizzle --db=postgres --pm=pnpm
@@ -21,17 +19,10 @@ npx stanza-cli init [name] --yes --framework=next --orm=drizzle --db=postgres --
- `name` — project directory name (positional; prompted if omitted).
- `--yes` — non-interactive; take selections from category flags.
- `--<category>=<ids>` — pick modules for a category. One flag per category
(`--framework`, `--styling`, `--db`, `--orm`, `--auth`, `--tooling`,
`--testing`); single-choice categories take one id, multi-choice categories
take a comma-separated list. Omitted categories are skipped — `--yes` chooses
no defaults.
- `--<category>=<ids>` — pick modules for a category. One flag per category (`--framework`, `--ui`, `--db`, `--orm`, `--auth`, `--tooling`, `--testing`); single-choice categories take one id, multi-choice categories take a comma-separated list. Omitted categories are skipped — `--yes` chooses no defaults.
- `--pm=<pnpm|bun|npm>` — package manager recorded in the manifest.
`init` always scaffolds a **single web app** today — `{ id: "web", dir: "apps/web", kind: "web" }`
in `stanza.json` — and tags every app-home record with `apps: ["web"]`. Multi-
app init (scaffolding `apps/native` alongside `apps/web`, for example) is
planned; the schema and runtime already support it. See [Concepts → Apps](/docs/concepts#apps).
`init` always scaffolds a **single web app** today — `{ id: "web", dir: "apps/web", kind: "web" }` in `stanza.json` — and tags every app-home record with `apps: ["web"]`. Multi-app init (scaffolding `apps/native` alongside `apps/web`, for example) is planned; the schema and runtime already support it. See [Concepts → Apps](/docs/concepts#apps).
## `add`
@@ -41,17 +32,9 @@ Add one module to an existing project.
npx stanza-cli add <category> <module> [--app=<id>]
```
Resolves peers, selects the matching adapter, and writes the module's templates,
deps, env, and scripts to the right home. Adding a module to a single-choice
category that's already filled fails until you remove the existing one — and
that limit is **per app** for `home: "app"` categories (so picking a framework
for one app doesn't block a different framework for another).
Resolves peers, selects the matching adapter, and writes the module's templates, deps, env, and scripts to the right home. Adding a module to a single-choice category that's already filled fails until you remove the existing one — and that limit is **per app** for `home: "app"` categories (so picking a framework for one app doesn't block a different framework for another).
- `--app=<id>` — pick which app the install targets. Required for app-relevant
modules when the project has multiple apps and you're not running inside one
of them. Single-app projects auto-target; multi-app projects also auto-pick
when `cwd` is inside one app's directory. Interactive runs (TTY, no `--yes`)
fall back to a prompt; non-interactive runs error out asking for the flag.
- `--app=<id>` — pick which app the install targets. Required for app-relevant modules when the project has multiple apps and you're not running inside one of them. Single-app projects auto-target; multi-app projects also auto-pick when `cwd` is inside one app's directory. Interactive runs (TTY, no `--yes`) fall back to a prompt; non-interactive runs error out asking for the flag.
- The flag is meaningless for `home: "repo"` modules like `tooling`.
## `remove`
@@ -62,14 +45,9 @@ Remove a module and sweep the files (regions) it owns.
npx stanza-cli remove <category> [id] [--app=<id>]
```
For single-choice categories the `id` is optional; for multi-choice categories
(like `testing`) it's required. `--app=<id>` scopes removal in projects with
multiple apps — without it, `remove` looks across every app for a matching
record.
For single-choice categories the `id` is optional; for multi-choice categories (like `testing`) it's required. `--app=<id>` scopes removal in projects with multiple apps — without it, `remove` looks across every app for a matching record.
The package-dir sweep (when removing the last module under `packages/<dir>/`)
strips the `workspace:*` dep from _every_ app's `package.json`, not just the
first one.
The package-dir sweep (when removing the last module under `packages/<dir>/`) strips the `workspace:*` dep from _every_ app's `package.json`, not just the first one.
## `list`
@@ -100,24 +78,16 @@ Use the printed **id** (not the display label) when passing a module to `add`.
These apply to the mutating verbs (`init`, `add`, `remove`):
- `--dry-run` — print the actions that would be taken and write nothing.
- `--dangerously-allow-dirty` — allow a mutating command to run with a dirty git
working tree. By default Stanza refuses, so its edits never mix with
uncommitted changes. Commit or stash first when you can.
- `--dangerously-allow-dirty` — allow a mutating command to run with a dirty git working tree. By default Stanza refuses, so its edits never mix with uncommitted changes. Commit or stash first when you can.
- `--no-telemetry` — disable anonymous usage events for this invocation.
## Environment variables
- `STANZA_REGISTRY=<url-or-path>` — use a custom or self-hosted registry (HTTP
URL or filesystem path) instead of the default.
- `STANZA_NO_NPM_LOOKUP=1` — skip npm version lookups and write dependency ranges
verbatim.
- `STANZA_REGISTRY=<url-or-path>` — use a custom or self-hosted registry (HTTP URL or filesystem path) instead of the default.
- `STANZA_NO_NPM_LOOKUP=1` — skip npm version lookups and write dependency ranges verbatim.
- `STANZA_NPM_REGISTRY=<url>` — override the npm registry used for version lookups.
- `STANZA_TELEMETRY=0` / `DO_NOT_TRACK=1` — disable telemetry persistently.
Telemetry is also auto-skipped in CI.
- `STANZA_TELEMETRY=0` / `DO_NOT_TRACK=1` — disable telemetry persistently. Telemetry is also auto-skipped in CI.
## Dependency versioning
On `init` and `add`, Stanza bumps each `^`/`~` dependency range to the latest npm
version that satisfies it, keeping the modifier. Other ranges and `workspace:*`
specifiers are written as-is. When offline, it falls back to the range declared
in the module.
On `init` and `add`, Stanza bumps each `^`/`~` dependency range to the latest npm version that satisfies it, keeping the modifier. Other ranges and `workspace:*` specifiers are written as-is. When offline, it falls back to the range declared in the module.
+21 -66
View File
@@ -3,36 +3,25 @@ title: Concepts
description: The mental model behind Stanza — apps, categories, vendoring, peers, and the manifest.
---
A handful of ideas explain how Stanza decides what to write and where. Once they
click, the CLI's behavior is predictable.
A handful of ideas explain how Stanza decides what to write and where. Once they click, the CLI's behavior is predictable.
## Apps
A Stanza project contains one or more **apps** — each a buildable thing under
`apps/<dir>/` with its own framework, styling, and tests. An app is declared in
`stanza.json` as:
A Stanza project contains one or more **apps** — each a buildable thing under `apps/<dir>/` with its own framework and tests. An app is declared in `stanza.json` as:
```json
{ "id": "web", "dir": "apps/web", "kind": "web" }
```
- **`id`** — short, stable handle. Doubles as the workspace-package suffix
(`@<your-app>/web`) and as the value you pass to `--app=<id>` in the CLI.
- **`id`** — short, stable handle. Doubles as the workspace-package suffix (`@<your-app>/web`) and as the value you pass to `--app=<id>` in the CLI.
- **`dir`** — repo-relative directory the app lives in.
- **`kind`** — `web` or `native`. Lets Stanza reject obviously-wrong installs
(e.g. installing Next.js into a `kind: "native"` app).
- **`kind`** — `web` or `native`. Lets Stanza reject obviously-wrong installs (e.g. installing Next.js into a `kind: "native"` app).
`stanza init` scaffolds a single web app today (`{ id: "web", dir: "apps/web", kind: "web" }`).
Multi-app projects — a web app and an Expo native app sharing a `packages/db/`,
for example — are on the roadmap, and the schema is already shaped for them. App-
home module records carry an `apps: ["<id>"]` field so `add`/`remove` know which
app to target; package-home modules ship their core code once and route shims
into the apps that consume them.
`stanza init` scaffolds a single web app today (`{ id: "web", dir: "apps/web", kind: "web" }`). Multi-app projects — a web app and an Expo native app sharing a `packages/db/`, for example — are on the roadmap, and the schema is already shaped for them. App-home module records carry an `apps: ["<id>"]` field so `add`/`remove` know which app to target; package-home modules ship their core code once and route shims into the apps that consume them.
## Categories
Every module belongs to exactly one **category**. A category has two independent
properties that govern how its modules behave:
Every module belongs to exactly one **category**. A category has two independent properties that govern how its modules behave:
- **Cardinality** — `one` (single-choice) or `many` (coexisting).
- **Home** — where a module's output lands: `app`, `repo`, or `package`.
@@ -40,34 +29,26 @@ properties that govern how its modules behave:
| Category | Cardinality | Home |
| ----------- | ----------- | ------- |
| `framework` | one | app |
| `styling` | one | app |
| `ui` | one | package |
| `db` | one | package |
| `orm` | one | package |
| `auth` | one | package |
| `tooling` | one | repo |
| `testing` | many | app |
A `one` category holds at most one module — adding a second fails until you
remove the first. For app-home categories that limit is **per app**: a project
with two apps could pick `next` for the web app and `expo` for the native one.
A `many` category lets modules coexist (e.g. Vitest and Playwright side by
side).
A `one` category holds at most one module — adding a second fails until you remove the first. For app-home categories that limit is **per app**: a project with two apps could pick `next` for the web app and `expo` for the native one. A `many` category lets modules coexist (e.g. Vitest and Playwright side by side).
**Home** decides placement:
- `app` modules wire a specific app's shell (in `apps/web/`, say). They install
into the app(s) named in the module record's `apps` field.
- `app` modules wire a specific app's shell (in `apps/web/`, say). They install into the app(s) named in the module record's `apps` field.
- `repo` modules write config at the monorepo root.
- `package` modules install into their own internal workspace package under
`packages/<dir>/`, named `@<your-app>/<dir>`, which every consuming app
depends on via `workspace:*`. `db` and `orm` share a single `packages/db/`
so the ORM client sits next to the schema it queries.
- `package` modules install into their own internal workspace package under `packages/<dir>/`, named `@<your-app>/<dir>`, which every consuming app depends on via `workspace:*`. `db` and `orm` share a single `packages/db/` so the ORM client sits next to the schema it queries.
<Callout type="info">
**`framework` is one category, not many.** A module's `appKind` (`web` or `native`) pins it to a
platform — Next is `appKind: "web"`, a future Expo module would be `appKind: "native"`. Splitting
the category per platform would force every peer-aware category (styling, testing, …) to split
too, which would be a worse design. See the [registry](/docs/registry) for the roadmap.
the category per platform would force every peer-aware category (ui, testing, …) to split too,
which would be a worse design. See the [registry](/docs/registry) for the roadmap.
</Callout>
<Callout type="info">
@@ -79,29 +60,17 @@ side).
## Vendoring
Stanza copies a module's code into your repo verbatim. There's no shared runtime
package — the generated files are ordinary source you can read, edit, and commit.
That means upgrades never silently change your app's behavior, and you're never
locked into Stanza's abstractions.
Stanza copies a module's code into your repo verbatim. There's no shared runtime package — the generated files are ordinary source you can read, edit, and commit. That means upgrades never silently change your app's behavior, and you're never locked into Stanza's abstractions.
## Peers and adapters
Modules declare **peers** — the other categories they care about. When you `add`
a module, Stanza reads your current selections and picks the **adapter** that
matches. Better Auth, for instance, peers on both `framework` and `orm`, so
adding it to a Next + Drizzle app produces different wiring than a TanStack
Start + Prisma app. You pick the module; Stanza picks the right variant.
Modules declare **peers** — the other categories they care about. When you `add` a module, Stanza reads your current selections and picks the **adapter** that matches. Better Auth, for instance, peers on both `framework` and `orm`, so adding it to a Next + Drizzle app produces different wiring than a TanStack Start + Prisma app. You pick the module; Stanza picks the right variant.
Peers are scoped to the app being targeted: in a multi-app project, the
styling module installed for `app: "web"` peer-matches against the web app's
framework, not the native app's. That's how the same Tailwind module can ship a
`postcss.config.mjs` for a Next.js web app while a future NativeWind module
ships `tailwind.config.js` for an Expo native app.
Peers are scoped to the app being targeted: in a multi-app project, the `ui` module's adapters peer-match against the active app's framework. That's how the same Tailwind module can ship a `postcss.config.mjs` for a Next.js web app while a future NativeWind module ships `tailwind.config.js` for an Expo native app.
## The manifest (`stanza.json`)
The `stanza.json` file at your repo root is the source of truth for what's
installed:
The `stanza.json` file at your repo root is the source of truth for what's installed:
```json
{
@@ -125,32 +94,18 @@ installed:
}
```
`add`, `remove`, and `list` all read it. Treat it as generated state: don't
hand-edit it unless you're repairing something deliberately.
`add`, `remove`, and `list` all read it. Treat it as generated state: don't hand-edit it unless you're repairing something deliberately.
A module record's `apps` field tells Stanza which app(s) the install targets:
- **Required** for `home: "app"` modules (framework, styling, testing). One
record per app.
- **Optional** for `home: "package"` modules. Omitted means "ship app-scoped
shims into every app"; an explicit list restricts to those apps. The
package itself is always written once under `packages/<dir>/`.
- **Required** for `home: "app"` modules (framework, testing). One record per app.
- **Optional** for `home: "package"` modules. Omitted means "ship app-scoped shims into every app"; an explicit list restricts to those apps. The package itself is always written once under `packages/<dir>/`.
- **Forbidden** for `home: "repo"` modules — they're project-wide.
## Regions
A **region** is a claim on a file (or a section of one) by a specific module.
This is how `stanza remove` knows exactly what to delete and what to leave alone:
it sweeps only the regions the removed module owns. Because regions key on the
module, two modules can safely write disjoint parts of the same file — Vitest
owning the `test` script and Playwright owning `test:e2e`, for example, without
conflict. Per-app writes live at distinct paths (`apps/web/page.tsx` vs
`apps/native/app/(tabs)/index.tsx`), so they never collide either.
A **region** is a claim on a file (or a section of one) by a specific module. This is how `stanza remove` knows exactly what to delete and what to leave alone: it sweeps only the regions the removed module owns. Because regions key on the module, two modules can safely write disjoint parts of the same file — Vitest owning the `test` script and Playwright owning `test:e2e`, for example, without conflict. Per-app writes live at distinct paths (`apps/web/page.tsx` vs `apps/native/app/(tabs)/index.tsx`), so they never collide either.
## Open registry
A registry is just static JSON: an index plus one file per module, each carrying
its templates, dependencies, env vars, and codemod invocations. The CLI ships
with a default registry, but you can point it anywhere — a URL or a local path —
with `STANZA_REGISTRY`. That's the hook for self-hosting your own modules or
pinning a fixture in CI.
A registry is just static JSON: an index plus one file per module, each carrying its templates, dependencies, env vars, and codemod invocations. The CLI ships with a default registry, but you can point it anywhere — a URL or a local path — with `STANZA_REGISTRY`. That's the hook for self-hosting your own modules or pinning a fixture in CI.
+10 -28
View File
@@ -8,13 +8,11 @@ Scaffold a new monorepo, then layer in modules as you go.
## Prerequisites
- **Node.js** 20 or newer.
- A package manager — **pnpm** (recommended), **npm**, or **bun**. Stanza writes
a workspace-based monorepo, so a workspace-aware package manager is required.
- A package manager — **pnpm** (recommended), **npm**, or **bun**. Stanza writes a workspace-based monorepo, so a workspace-aware package manager is required.
## Create a project
The create command launches an interactive wizard that walks you through each
category:
The create command launches an interactive wizard that walks you through each category:
```sh
pnpm create stanza my-app
@@ -27,17 +25,11 @@ npm create stanza@latest -- my-app
bun create stanza@latest my-app
```
To run it without the wizard — in CI, for example — see
[Non-interactive setup](#non-interactive-setup) below.
To run it without the wizard — in CI, for example — see [Non-interactive setup](/docs/getting-started#non-interactive-setup) below.
## What you get
Stanza generates a clean monorepo: your app under `apps/web/` (one of
potentially many apps as the registry grows), internal packages under
`packages/`, and a `stanza.json` manifest at the root. The manifest records the
apps in the project, the modules you selected, and which files each one owns —
it's how `add` and `remove` know what's installed and what to clean up. You
generally don't edit it by hand.
Stanza generates a clean monorepo: your app under `apps/web/` (one of potentially many apps as the registry grows), internal packages under `packages/`, and a `stanza.json` manifest at the root. The manifest records the apps in the project, the modules you selected, and which files each one owns — it's how `add` and `remove` know what's installed and what to clean up. You generally don't edit it by hand.
## Install and run
@@ -49,19 +41,13 @@ pnpm dev
## Add a module
Every category — `framework`, `styling`, `db`, `orm`, `auth`, `tooling`,
`testing` — is one command away:
Every category — `framework`, `ui`, `db`, `orm`, `auth`, `tooling`, `testing` — is one command away:
```sh
npx stanza-cli add auth better-auth
```
Stanza resolves peer constraints, picks the adapter that matches your stack, and
writes the module's templates, dependencies, and environment variables into the
appropriate place. For `auth`, `db`, and `orm` that's an internal workspace
package (e.g. `packages/auth/`, named `@my-app/auth`) that your app consumes via
`workspace:*`. After adding a package-scoped module, re-run your package
manager's install so the new workspace package links:
Stanza resolves peer constraints, picks the adapter that matches your stack, and writes the module's templates, dependencies, and environment variables into the appropriate place. For `auth`, `db`, and `orm` that's an internal workspace package (e.g. `packages/auth/`, named `@my-app/auth`) that your app consumes via `workspace:*`. After adding a package-scoped module, re-run your package manager's install so the new workspace package links:
```sh
pnpm install
@@ -75,19 +61,16 @@ npx stanza-cli search drizzle # find modules in the registry by query
npx stanza-cli remove auth # remove a module and sweep the files it owns
```
`stanza search` prints each module's `category/id` pair — use the **id**, not the
display label, when passing it to `add`.
`stanza search` prints each module's `category/id` pair — use the **id**, not the display label, when passing it to `add`.
## Non-interactive setup
Pass `--yes` and select each category explicitly with a flag. Single-choice
categories take one id; multi-choice categories (like `testing`) take a
comma-separated list:
Pass `--yes` and select each category explicitly with a flag. Single-choice categories take one id; multi-choice categories (like `testing`) take a comma-separated list:
```sh
npx -y stanza-cli@latest init my-app --yes \
--framework=next \
--styling=tailwind \
--ui=tailwind \
--db=postgres \
--orm=drizzle \
--auth=better-auth \
@@ -95,5 +78,4 @@ npx -y stanza-cli@latest init my-app --yes \
--pm=pnpm
```
`--yes` never fills in defaults for categories you omit — any category without a
flag is simply skipped.
`--yes` never fills in defaults for categories you omit — any category without a flag is simply skipped.
+5 -19
View File
@@ -3,32 +3,18 @@ title: Introduction
description: Assemble modular full-stack TypeScript monorepos with Stanza.
---
Stanza is a shadcn-style CLI for assembling modular full-stack TypeScript
monorepos. Pick a framework, styling, database, ORM, and auth — Stanza vendors
idiomatic code directly into your repo, with no runtime dependency to carry
around.
Stanza is a [shadcn](https://ui.shadcn.com/docs)-style CLI for assembling modular full-stack TypeScript monorepos. Pick a framework, UI, database, ORM, and auth — Stanza vendors idiomatic code directly into your repo, with no additional runtime dependencies to maintain afterwards.
## Why Stanza
- **`add` works after `init`.** Run `stanza add` on an existing project. It reads
your `stanza.json` manifest, resolves peer constraints, and picks the right
adapter for your stack — so adding auth to a Next + Drizzle app writes
- **`add` works after `init`.** Run `stanza add` on an existing project. It reads your `stanza.json` manifest, resolves peer constraints, and picks the right adapter for your stack — so adding auth to a Next + Drizzle app writes
different code than adding it to a TanStack Start + Prisma app.
- **Your code, vendored.** Generated files land in your repo verbatim. There's no
`@stanza/runtime` package to install, and nothing to upgrade out from under
you — the code is yours to edit.
- **Open registry.** Modules are plain static JSON. The CLI ships with a default
registry, but you can point it at your own host with `STANZA_REGISTRY` and
serve custom modules.
- **Your code, vendored.** Generated files land in your repo verbatim. There's no `@stanza/runtime` package to install, and nothing to upgrade out from under you — the code is yours to edit.
- **Open registry.** Modules are plain static JSON. The CLI ships with a default registry, but you can point it at your own host with `STANZA_REGISTRY` and serve custom modules.
## How it fits together
You choose modules across a small set of **categories** — `framework`, `styling`,
`db`, `orm`, `auth`, `tooling`, `testing`. Stanza scaffolds a clean monorepo,
vendoring each module's templates, dependencies, and environment variables into
the right place: app shell code into your app, data and auth layers into their
own workspace packages. Later, `stanza add` and `stanza remove` layer modules in
and out without you hand-editing the wiring.
You choose modules across a small set of **categories** — `framework`, `ui`, `db`, `orm`, `auth`, `tooling`, `testing`, [and more](/docs/registry). Stanza scaffolds a clean monorepo, vendoring each module's templates, dependencies, and environment variables into the right place: app shell code into your app, data and auth layers into their own workspace packages. Later, `stanza add` and `stanza remove` layer modules in and out without you hand-editing the wiring.
## Next steps
+34 -55
View File
@@ -3,45 +3,33 @@ title: Registry
description: The roadmap of first-party modules Stanza ships — what's available today and what's planned.
---
Every module fills exactly one **category**. This page is the canonical roadmap
of the first-party modules Stanza ships: what's available today, and what's on
the way. Self-hosted registries can add their own modules — see
[the open registry](/docs/concepts#open-registry).
Every module fills exactly one **category**. This page is the canonical roadmap of the first-party modules Stanza ships: what's available today, and what's on the way. Self-hosted registries can add their own modules — see [the open registry](/docs/concepts#open-registry).
## Categories
A category has two independent properties: its **cardinality** — `one`
(single-choice, like `framework` or `auth`) or `many` (coexisting, like
`testing`) — and its **home**, where a module's output lands (`app`, `repo`, or
`package`). See [Concepts](/docs/concepts#categories) for the full mental model.
A category has two independent properties: its **cardinality** — `one` (single-choice, like `framework` or `auth`) or `many` (coexisting, like `testing`) — and its **home**, where a module's output lands (`app`, `repo`, or `package`). See [Concepts](/docs/concepts#categories) for the full mental model.
| Category | Cardinality | Home | What it covers |
| ----------- | ----------- | ------- | ---------------------------------------------------------- |
| `framework` | one | app | App shell + router (Next, TanStack Start, Expo, …) |
| `api` | one | package | Typed RPC layer between framework and services |
| `ai` | one | package | AI SDK + provider wiring |
| `auth` | one | package | Authentication (Better Auth, Clerk, …) |
| `db` | one | package | Database driver (Postgres, SQLite) |
| `orm` | one | package | Schema + query layer (Drizzle, Prisma) |
| `styling` | one | app | CSS toolchain (Tailwind for web, NativeWind for native, …) |
| `payments` | one | package | Checkout + webhooks (Stripe, Polar, …) |
| `email` | one | package | Transactional email (Resend) |
| `tooling` | one | repo | Lint/format toolchain |
| `testing` | many | app | Unit + e2e (Vitest, Playwright) |
| `deploy` | many | repo | Deploy targets (Vercel, Cloudflare, …) |
| `monorepo` | many | repo | Monorepo task runner (Turborepo) |
| Category | Cardinality | Home | What it covers |
| ----------- | ----------- | ------- | ----------------------------------------------------------- |
| `framework` | one | app | App shell + router (Next, TanStack Start, Expo, …) |
| `api` | one | package | Typed RPC layer between framework and services |
| `ai` | one | package | AI SDK + provider wiring |
| `auth` | one | package | Authentication (Better Auth, Clerk, …) |
| `db` | one | package | Database driver (Postgres, SQLite) |
| `orm` | one | package | Schema + query layer (Drizzle, Prisma) |
| `ui` | one | package | Styling system + component primitives (Tailwind, shadcn, …) |
| `payments` | one | package | Checkout + webhooks (Stripe, Polar, …) |
| `email` | one | package | Transactional email (Resend) |
| `tooling` | one | repo | Lint/format toolchain |
| `testing` | many | app | Unit + e2e (Vitest, Playwright) |
| `deploy` | many | repo | Deploy targets (Vercel, Cloudflare, …) |
| `monorepo` | many | repo | Monorepo task runner (Turborepo) |
Single-choice categories with `home: "app"` (currently `framework`, `styling`)
are enforced **per app** — a multi-app project can pick `next` for the web app
and `expo` for a native app side by side. See [Concepts → Apps](/docs/concepts#apps).
Single-choice categories with `home: "app"` (currently `framework`) are enforced **per app** — a multi-app project can pick `next` for the web app and `expo` for a native app side by side. See [Concepts → Apps](/docs/concepts#apps).
## Framework
Single-choice per app — the chosen framework wires the app shell, router, and
React (or future) runtime. Modules declare an **`appKind`** (`web` or `native`)
which the runner uses to validate they're installed into a compatible app: you
can't install Next.js into a `kind: "native"` app, or Expo into a `kind: "web"`
one. A multi-app project can pick one framework per app.
Single-choice per app — the chosen framework wires the app shell, router, and React (or future) runtime. Modules declare an **`appKind`** (`web` or `native`) which the runner uses to validate they're installed into a compatible app: you can't install Next.js into a `kind: "native"` app, or Expo into a `kind: "web"` one. A multi-app project can pick one framework per app.
| Module | `appKind` | Status | Notes |
| ---------------- | --------- | ----------------- | ---------------------------------------------------------------------------------------- |
@@ -54,8 +42,7 @@ one. A multi-app project can pick one framework per app.
## API
Single-choice, constraint-bearing. An optional layer between the framework and
the database or services, installed as its own workspace package.
Single-choice, constraint-bearing. An optional layer between the framework and the database or services, installed as its own workspace package.
| Module | Status | Notes |
| ------ | --------------- | --------------------------------- |
@@ -99,24 +86,21 @@ Single-choice, shares `packages/db/` with the database driver.
| `drizzle` | 🟢&nbsp;Available | Drizzle ORM 0.45. Peers on `db` (postgres, sqlite). |
| `prisma` | 🟢&nbsp;Available | Prisma 7. Peers on `db` (postgres, sqlite). |
## Styling
## UI
Single-choice per app. Adapters branch on the active app's `framework` peer, so
the same `tailwind` module ships PostCSS config for Next, a Vite plugin for
TanStack Start, etc.
Single-choice. Installs into `packages/ui/` so styles + component primitives are shared across every app via `workspace:*`. Adapters branch on the active app's `framework` peer (PostCSS for Next, Vite plugin for TanStack Start, …).
| Module | For | Status | Notes |
| -------------- | ------ | ----------------- | -------------------------------------------------- |
| `tailwind` | web | 🟢&nbsp;Available | Vanilla Tailwind v4, adapters per web framework. |
| `shadcn-radix` | web | 🟨&nbsp;Planned | Classic shadcn/ui (Radix primitives). |
| `shadcn-base` | web | 🟨&nbsp;Planned | shadcn on Base UI. |
| `nativewind` | native | 🟨&nbsp;Planned | Tailwind-style className API for React Native. |
| `unistyles` | native | 🟨&nbsp;Planned | Native StyleSheet with theme + breakpoint support. |
| Module | For | Status | Notes |
| -------------- | ------ | ----------------- | --------------------------------------------------------------------- |
| `tailwind` | web | 🟢&nbsp;Available | Bare Tailwind v4 in `packages/ui/`; apps re-export postcss config. |
| `shadcn-radix` | web | 🟢&nbsp;Available | shadcn/ui registry preset `radix-nova` (Radix primitives) + Tailwind. |
| `shadcn-base` | web | 🟢&nbsp;Available | shadcn/ui registry preset `base-nova` (Base UI) + Tailwind. |
| `nativewind` | native | 🟨&nbsp;Planned | Tailwind-style className API for React Native. |
| `unistyles` | native | 🟨&nbsp;Planned | Native StyleSheet with theme + breakpoint support. |
## Payments
Single-choice, constraint-bearing. Installs into `packages/payments/`, plus
a Better Auth plugin when auth is selected.
Single-choice, constraint-bearing. Installs into `packages/payments/`, plus a Better Auth plugin when auth is selected.
| Module | Status | Notes |
| -------- | ----------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
@@ -135,8 +119,7 @@ Single-choice, installs into `packages/email/`.
## Tooling
Single-choice, repo-scoped. The three toolchains are mutually exclusive
substitutes.
Single-choice, repo-scoped. The three toolchains are mutually exclusive substitutes.
| Module | Status | Notes |
| ----------------- | ----------------- | ------------------------------------------------------ |
@@ -146,8 +129,7 @@ substitutes.
## Testing
Coexisting, app-scoped. Vitest and Playwright are independent and routinely run
side by side.
Coexisting, app-scoped. Vitest and Playwright are independent and routinely run side by side.
| Module | Status | Notes |
| ------------ | ----------------- | ----------------------------------------------------------------- |
@@ -167,8 +149,7 @@ Coexisting, repo-scoped.
## Monorepo
Coexisting, repo-scoped. Currently hardcoded as Turborepo; becomes a real
configurable category when a second option lands.
Coexisting, repo-scoped. Currently hardcoded as Turborepo; becomes a real configurable category when a second option lands.
| Module | Status | Notes |
| ----------- | ----------------- | ------------------------------------------------------------------ |
@@ -177,9 +158,7 @@ configurable category when a second option lands.
## Package manager
Not a category — a top-level field in `stanza.json`
(`packageManager: "pnpm" | "bun" | "npm"`). The wizard prompts for it; codemods
only ever touch `package.json`, never lockfiles.
Not a category — a top-level field in `stanza.json` (`packageManager: "pnpm" | "bun" | "npm"`). The wizard prompts for it; codemods only ever touch `package.json`, never lockfiles.
| Manager | Status |
| ------- | --------------------------- |
@@ -0,0 +1,167 @@
import fs from "node:fs";
import os from "node:os";
import path from "node:path";
import { emptyManifest } from "@stanza/registry";
import { afterEach, beforeEach, describe, expect, it } from "vite-plus/test";
import { type CodemodContext, openProject } from "../index";
import addArrayEntryInCall from "./add-array-entry-in-call";
let tmp: string;
beforeEach(() => {
tmp = fs.mkdtempSync(path.join(os.tmpdir(), "stanza-aae-"));
fs.mkdirSync(path.join(tmp, "apps/web/src/routes"), { recursive: true });
});
afterEach(() => {
fs.rmSync(tmp, { recursive: true, force: true });
});
function setup(filePath: string, initial: string) {
const abs = path.join(tmp, "apps/web", filePath);
fs.mkdirSync(path.dirname(abs), { recursive: true });
fs.writeFileSync(abs, initial, "utf8");
const project = openProject(path.join(tmp, "apps/web"));
const manifest = emptyManifest({ name: "acme" });
const ctx: CodemodContext = {
projectRoot: tmp,
app: manifest.apps[0]!,
appRoot: path.join(tmp, "apps/web"),
project: () => project,
manifest,
owner: { category: "ui", module: "shadcn-radix" },
adapter: "tanstack-start",
claimRegion() {},
releaseRegion() {},
};
return { ctx, abs, project };
}
const ROOT_WITH_HEAD = `import { createRootRoute } from "@tanstack/react-router";
export const Route = createRootRoute({
head: () => ({
meta: [{ title: "App" }],
links: [],
}),
component: () => null,
});
`;
const ROOT_WITHOUT_HEAD = `import { Outlet, createRootRoute } from "@tanstack/react-router";
export const Route = createRootRoute({
component: () => (
<html lang="en">
<body>
<Outlet />
</body>
</html>
),
});
`;
describe("add-array-entry-in-call", () => {
it("splices an entry into a nested array via arrow-returned object", () => {
const { ctx, abs, project } = setup("src/routes/__root.tsx", ROOT_WITH_HEAD);
const result = addArrayEntryInCall.apply(ctx, {
file: "src/routes/__root.tsx",
callee: "createRootRoute",
property: "head().links",
entry: '{ rel: "stylesheet", href: appCss }',
imports: [{ from: "@acme/ui/globals.css?url", default: "appCss" }],
}) as { touchedFiles: string[] };
expect(result.touchedFiles).toEqual(["apps/web/src/routes/__root.tsx"]);
project.saveSync();
const text = fs.readFileSync(abs, "utf8");
expect(text).toContain('import appCss from "@acme/ui/globals.css?url"');
expect(text).toContain('{ rel: "stylesheet", href: appCss }');
});
it("creates missing intermediate `head: () => ({})` + `links: []`", () => {
const { ctx, abs, project } = setup("src/routes/__root.tsx", ROOT_WITHOUT_HEAD);
addArrayEntryInCall.apply(ctx, {
file: "src/routes/__root.tsx",
callee: "createRootRoute",
property: "head().links",
entry: '{ rel: "stylesheet", href: appCss }',
imports: [{ from: "@acme/ui/globals.css?url", default: "appCss" }],
});
project.saveSync();
const text = fs.readFileSync(abs, "utf8");
expect(text).toContain("head: () => ({");
expect(text).toContain('links: [{ rel: "stylesheet", href: appCss }]');
});
it("is idempotent on re-apply", () => {
const { ctx, project } = setup("src/routes/__root.tsx", ROOT_WITH_HEAD);
addArrayEntryInCall.apply(ctx, {
file: "src/routes/__root.tsx",
callee: "createRootRoute",
property: "head().links",
entry: '{ rel: "stylesheet", href: appCss }',
});
project.saveSync();
const project2 = openProject(path.join(tmp, "apps/web"));
const result = addArrayEntryInCall.apply(
{ ...ctx, project: () => project2 },
{
file: "src/routes/__root.tsx",
callee: "createRootRoute",
property: "head().links",
entry: '{ rel: "stylesheet", href: appCss }',
},
) as { touchedFiles: string[] };
expect(result.touchedFiles).toEqual([]);
});
it("handles flat (non-call) nested path", () => {
const initial = `import { defineConfig } from "vite";
export default defineConfig({
build: { lib: { entry: "src/index.ts" }, deps: [] },
});
`;
const { ctx, abs, project } = setup("vite.config.ts", initial);
addArrayEntryInCall.apply(ctx, {
file: "vite.config.ts",
callee: "defineConfig",
property: "build.deps",
entry: '"foo"',
});
project.saveSync();
const text = fs.readFileSync(abs, "utf8");
expect(text).toContain('deps: ["foo"]');
});
it("revert removes the entry and import", () => {
const { ctx, abs, project } = setup("src/routes/__root.tsx", ROOT_WITH_HEAD);
addArrayEntryInCall.apply(ctx, {
file: "src/routes/__root.tsx",
callee: "createRootRoute",
property: "head().links",
entry: '{ rel: "stylesheet", href: appCss }',
imports: [{ from: "@acme/ui/globals.css?url", default: "appCss" }],
});
project.saveSync();
const project2 = openProject(path.join(tmp, "apps/web"));
addArrayEntryInCall.revert?.(
{ ...ctx, project: () => project2 },
{
file: "src/routes/__root.tsx",
callee: "createRootRoute",
property: "head().links",
entry: '{ rel: "stylesheet", href: appCss }',
imports: [{ from: "@acme/ui/globals.css?url", default: "appCss" }],
},
);
project2.saveSync();
const text = fs.readFileSync(abs, "utf8");
expect(text).not.toContain("appCss");
expect(text).not.toContain("@acme/ui/globals.css");
});
});
@@ -0,0 +1,416 @@
import path from "node:path";
import type { ArrayLiteralExpression, CallExpression, ObjectLiteralExpression } from "ts-morph";
import {
addDefaultImport,
addNamedImport,
type Codemod,
type ImportDeclaration,
type NamedImportSpec,
type Node,
type SourceFile,
SyntaxKind,
} from "../index";
type ArrayLiteral = ArrayLiteralExpression;
type ObjectLiteral = ObjectLiteralExpression;
/**
* Splice an entry into an array literal that lives at a *nested* property
* path inside a CallExpression's object argument. The more powerful sibling
* of `add-plugin-to-call` — `add-plugin-to-call` covers the flat case
* (`callee({ <property>: [...] })`); this codemod walks a dotted `property`
* path and supports descent through arrow-function-returning-object
* properties via the `name()` segment syntax.
*
* The motivating use case is TanStack Start's root route:
*
* ```ts
* createRootRoute({
* head: () => ({
* links: [{ rel: "stylesheet", href: appCss }],
* }),
* })
* ```
*
* which we reach with `callee: "createRootRoute"` + `property: "head().links"`.
* Plain dotted paths (e.g. `"foo.bar"`) walk regular property assignments;
* append `()` to a segment to dive into an arrow function's returned object.
*
* Creates missing intermediate properties (and the terminal array) when
* possible; idempotent on the normalized form of `entry`.
*/
export type AddArrayEntryInCallArgs = {
/** Path to the file, relative to `base`. */
file: string;
/**
* Where `file` resolves against:
* - `"repo"`: `ctx.projectRoot`
* - `"app"` (default): `ctx.appRoot`
* - `"package:<dir>"`: `<projectRoot>/packages/<dir>/`
*/
base?: "app" | "repo" | `package:${string}`;
/** Identifier of the call expression to find, e.g. `"createRootRoute"`. */
callee: string;
/** Which argument of the call. Defaults to 0. Must be an object literal. */
argIndex?: number;
/**
* Dotted path from the call argument to the target array. Each segment
* navigates one property; suffix a segment with `()` to dive into an arrow
* function's returned object expression.
*
* "links" → arg.links
* "head.links" → arg.head.links
* "head().links" → (arg.head as () => Obj)().links
*/
property: string;
/** Source text of the entry to insert into the array, verbatim. */
entry: string;
/**
* Imports to add. Same shape as `add-plugin-to-call`'s — each entry
* resolves to one import declaration (named + default merge).
*/
imports?: Array<{
from: string;
named?: NamedImportSpec[];
default?: string;
}>;
/**
* Position in the array. Defaults to `"end"`. Anchors (`before:<text>` /
* `after:<text>`) match elements whose text starts with `<text>`.
* Missing anchor logs a warning and falls back to `"end"`.
*/
position?: "start" | "end" | `before:${string}` | `after:${string}`;
/**
* Region key segment used to track ownership. Defaults to
* `<callee>.<property>.<entry-prefix>`.
*/
regionKey?: string;
};
const addArrayEntryInCall: Codemod<AddArrayEntryInCallArgs> = {
id: "add-array-entry-in-call",
description: "Insert an entry into an array nested inside a call expression's object argument.",
apply(ctx, args) {
const abs = resolveFilePath(ctx, args);
const rel = path.relative(ctx.projectRoot, abs);
const sf = ctx.project().addSourceFileAtPath(abs);
const arr = getOrCreateArray(sf, args);
const target = normalize(args.entry);
const elements = arr.getElements();
if (elements.some((el: Node) => normalize(el.getText()) === target)) {
// Already present — claim so revert works but report no touch.
ctx.claimRegion(rel, regionKeyFor(args));
return { touchedFiles: [] };
}
addImports(sf, args.imports);
const index = resolveIndex(arr, args.position ?? "end", args.entry);
insertEntry(sf, arr, index, args.entry);
ctx.claimRegion(rel, regionKeyFor(args));
return { touchedFiles: [rel] };
},
revert(ctx, args) {
const abs = resolveFilePath(ctx, args);
const rel = path.relative(ctx.projectRoot, abs);
const sf = ctx.project().addSourceFileAtPath(abs);
const arr = findArray(sf, args);
if (arr) {
const target = normalize(args.entry);
const matching = arr.getElements().find((el: Node) => normalize(el.getText()) === target);
if (matching) arr.removeElement(matching);
}
for (const imp of args.imports ?? []) {
const decl = sf.getImportDeclaration(
(d: ImportDeclaration) => d.getModuleSpecifierValue() === imp.from,
);
decl?.remove();
}
ctx.releaseRegion(rel, regionKeyFor(args));
return { touchedFiles: [rel] };
},
};
function resolveFilePath(
ctx: { projectRoot: string; appRoot: string },
args: AddArrayEntryInCallArgs,
): string {
const base = args.base ?? "app";
if (base.startsWith("package:")) {
return path.join(ctx.projectRoot, "packages", base.slice("package:".length), args.file);
}
return path.join(base === "repo" ? ctx.projectRoot : ctx.appRoot, args.file);
}
function regionKeyFor(args: AddArrayEntryInCallArgs): string {
if (args.regionKey) return args.regionKey;
const leading = args.entry.match(/^[A-Za-z_$][\w$]*/)?.[0] ?? "entry";
return `${args.callee}.${args.property}.${leading}`;
}
function addImports(sf: SourceFile, imports: AddArrayEntryInCallArgs["imports"]): void {
if (!imports) return;
for (const imp of imports) {
if (imp.default) addDefaultImport(sf, imp.from, imp.default);
if (imp.named && imp.named.length > 0) addNamedImport(sf, imp.from, imp.named);
}
}
function findCalleeCall(sf: SourceFile, callee: string): CallExpression | undefined {
return sf
.getFirstDescendant((node) => {
if (node.getKind() !== SyntaxKind.CallExpression) return false;
const call = node.asKind(SyntaxKind.CallExpression);
return call?.getExpression().getText() === callee;
})
?.asKind(SyntaxKind.CallExpression);
}
/** Parse `"head().links"` → `[{ name: "head", call: true }, { name: "links" }]`. */
type PathSegment = { name: string; call: boolean };
function parsePath(property: string): PathSegment[] {
return property.split(".").map((raw) => {
if (raw.endsWith("()")) return { name: raw.slice(0, -2), call: true };
return { name: raw, call: false };
});
}
/** Returns `null` if any segment isn't present — fine for `revert`. */
function findArray(sf: SourceFile, args: AddArrayEntryInCallArgs): ArrayLiteral | null {
const call = findCalleeCall(sf, args.callee);
if (!call) return null;
const arg = call.getArguments()[args.argIndex ?? 0]?.asKind(SyntaxKind.ObjectLiteralExpression);
if (!arg) return null;
const segments = parsePath(args.property);
let obj: ObjectLiteral | null = arg;
for (let i = 0; i < segments.length - 1; i += 1) {
const seg = segments[i]!;
obj = descend(obj, seg);
if (!obj) return null;
}
const last = segments[segments.length - 1]!;
const prop = obj?.getProperty(last.name)?.asKind(SyntaxKind.PropertyAssignment);
return prop?.getInitializerIfKind(SyntaxKind.ArrayLiteralExpression) ?? null;
}
/** Same as `findArray` but creates missing intermediates and the terminal array. */
function getOrCreateArray(sf: SourceFile, args: AddArrayEntryInCallArgs): ArrayLiteral {
const call = findCalleeCall(sf, args.callee);
if (!call) {
throw new Error(
`add-array-entry-in-call: ${sf.getBaseName()} has no \`${args.callee}(...)\` call.`,
);
}
const argIndex = args.argIndex ?? 0;
const arg = call.getArguments()[argIndex]?.asKind(SyntaxKind.ObjectLiteralExpression);
if (!arg) {
throw new Error(
`add-array-entry-in-call: ${sf.getBaseName()}'s \`${args.callee}\` call needs an ` +
`object-literal at argument ${argIndex}.`,
);
}
const segments = parsePath(args.property);
// Walk/create intermediates.
let obj: ObjectLiteral = arg;
for (let i = 0; i < segments.length - 1; i += 1) {
obj = ensureIntermediate(sf, obj, segments[i]!);
}
// Terminal segment must resolve (or be created) as an array.
return ensureTerminalArray(sf, obj, segments[segments.length - 1]!);
}
/** Descend a single segment; returns `null` if the property doesn't match. */
function descend(obj: ObjectLiteral, seg: PathSegment): ObjectLiteral | null {
const prop = obj.getProperty(seg.name)?.asKind(SyntaxKind.PropertyAssignment);
if (!prop) return null;
const init = prop.getInitializer();
if (!init) return null;
if (!seg.call) {
return init.asKind(SyntaxKind.ObjectLiteralExpression) ?? null;
}
// Arrow function returning an object: `() => ({...})` (ParenthesizedExpression)
// or `() => { return {...}; }` (Block + ReturnStatement).
const arrow = init.asKind(SyntaxKind.ArrowFunction);
if (!arrow) return null;
const body = arrow.getBody();
const parenthesized = body.asKind(SyntaxKind.ParenthesizedExpression);
if (parenthesized) {
return parenthesized.getExpression().asKind(SyntaxKind.ObjectLiteralExpression) ?? null;
}
const block = body.asKind(SyntaxKind.Block);
if (block) {
const ret = block
.getStatements()
.find((s) => s.getKind() === SyntaxKind.ReturnStatement)
?.asKind(SyntaxKind.ReturnStatement);
return ret?.getExpression()?.asKind(SyntaxKind.ObjectLiteralExpression) ?? null;
}
return body.asKind(SyntaxKind.ObjectLiteralExpression) ?? null;
}
/** Ensure `obj.<seg>` (or `obj.<seg>()`) is an object literal we can descend into. */
function ensureIntermediate(sf: SourceFile, obj: ObjectLiteral, seg: PathSegment): ObjectLiteral {
const existing = descend(obj, seg);
if (existing) return existing;
// Create the property in the form the segment demands.
const init = seg.call ? "() => ({})" : "{}";
addObjectProperty(sf, obj, seg.name, init);
// Re-traverse — the inserted text invalidates the previous handle.
return findOrThrowAfterInsert(sf, seg);
}
/** Ensure the terminal segment resolves to an array literal; create if missing. */
function ensureTerminalArray(sf: SourceFile, obj: ObjectLiteral, seg: PathSegment): ArrayLiteral {
// Reject `()` on the terminal — arrays don't return from a call.
if (seg.call) {
throw new Error(
`add-array-entry-in-call: terminal path segment "${seg.name}()" can't be a call — ` +
`drop the parentheses (the leaf must be an array property).`,
);
}
const prop = obj.getProperty(seg.name)?.asKind(SyntaxKind.PropertyAssignment);
if (prop) {
const arr = prop.getInitializerIfKind(SyntaxKind.ArrayLiteralExpression);
if (!arr) {
throw new Error(
`add-array-entry-in-call: \`${seg.name}\` already exists but isn't an array literal — ` +
`reshape it manually first.`,
);
}
return arr;
}
addObjectProperty(sf, obj, seg.name, "[]");
// Re-find — the cached `obj` handle is now stale.
const refreshed = sf
.getDescendantsOfKind(SyntaxKind.PropertyAssignment)
.findLast((p) => p.getName() === seg.name);
const arr = refreshed?.getInitializerIfKind(SyntaxKind.ArrayLiteralExpression);
if (!arr) {
throw new Error(`add-array-entry-in-call: failed to create \`${seg.name}: []\`.`);
}
return arr;
}
/** Insert `<name>: <init>` keeping the surrounding indent. */
function addObjectProperty(sf: SourceFile, obj: ObjectLiteral, name: string, init: string): void {
const properties = obj.getProperties();
if (properties.length > 0) {
const last = properties[properties.length - 1]!;
const trivia = sf.getFullText().slice(last.getFullStart(), last.getStart());
const indent = trivia.match(/\n([ \t]*)$/)?.[1] ?? " ";
sf.insertText(last.getEnd(), `,\n${indent}${name}: ${init}`);
} else {
obj.addPropertyAssignment({ name, initializer: init });
}
}
/** After a text insert, re-find the segment's object/arrow value or throw. */
function findOrThrowAfterInsert(sf: SourceFile, seg: PathSegment): ObjectLiteral {
const prop = sf
.getDescendantsOfKind(SyntaxKind.PropertyAssignment)
.findLast((p) => p.getName() === seg.name);
if (!prop) {
throw new Error(`add-array-entry-in-call: failed to materialize property \`${seg.name}\`.`);
}
const init = prop.getInitializer();
if (!init) {
throw new Error(`add-array-entry-in-call: \`${seg.name}\` has no initializer after insert.`);
}
if (!seg.call) {
const obj = init.asKind(SyntaxKind.ObjectLiteralExpression);
if (!obj) {
throw new Error(
`add-array-entry-in-call: \`${seg.name}\` initializer isn't an object after insert.`,
);
}
return obj;
}
const arrow = init.asKind(SyntaxKind.ArrowFunction);
const body = arrow?.getBody();
const paren = body?.asKind(SyntaxKind.ParenthesizedExpression);
const obj = paren?.getExpression().asKind(SyntaxKind.ObjectLiteralExpression);
if (!obj) {
throw new Error(
`add-array-entry-in-call: \`${seg.name}\` arrow-returned object missing after insert.`,
);
}
return obj;
}
/** Text-edit insertion so we keep the array's existing indent (ts-morph
* `insertElement` would reformat to 4-space). Mirrors add-plugin-to-call. */
function insertEntry(sf: SourceFile, arr: ArrayLiteral, index: number, entry: string): void {
const elements = arr.getElements();
if (elements.length === 0) {
arr.insertElement(index, entry);
return;
}
const probe = elements[Math.min(index, elements.length - 1)];
if (!probe) {
arr.insertElement(index, entry);
return;
}
const trivia = sf.getFullText().slice(probe.getFullStart(), probe.getStart());
const newlineIndent = trivia.match(/\n([ \t]*)$/);
if (newlineIndent) {
const indent = newlineIndent[1];
if (index < elements.length) {
sf.insertText(probe.getStart(), `${entry},\n${indent}`);
} else {
const last = elements[elements.length - 1]!;
sf.insertText(last.getEnd(), `,\n${indent}${entry}`);
}
} else {
if (index < elements.length) {
sf.insertText(probe.getStart(), `${entry}, `);
} else {
const last = elements[elements.length - 1]!;
sf.insertText(last.getEnd(), `, ${entry}`);
}
}
}
function resolveIndex(
arr: ArrayLiteral,
position: NonNullable<AddArrayEntryInCallArgs["position"]>,
entry: string,
): number {
const elements = arr.getElements();
if (position === "start") return 0;
if (position === "end") return elements.length;
const sep = position.indexOf(":");
const direction = position.slice(0, sep);
const anchor = position.slice(sep + 1);
const anchorIdx = elements.findIndex((el: Node) =>
normalize(el.getText()).startsWith(normalize(anchor)),
);
if (anchorIdx === -1) {
const leading = entry.match(/^[A-Za-z_$][\w$]*/)?.[0] ?? "entry";
console.warn(
`add-array-entry-in-call: anchor "${anchor}" not found — appending ${leading} at end.`,
);
return elements.length;
}
return direction === "before" ? anchorIdx : anchorIdx + 1;
}
function normalize(s: string): string {
return s.replace(/\s+/g, "");
}
export default addArrayEntryInCall;
+23 -1
View File
@@ -20,23 +20,45 @@
* subpath export.
*/
import type { Codemod } from "../index";
import addArrayEntryInCall from "./add-array-entry-in-call";
import addPackageDep from "./add-package-dep";
import addPluginToCall from "./add-plugin-to-call";
import appendToFile from "./append-to-file";
import reExport from "./re-export";
import replaceImport from "./replace-import";
import setHtmlAttributes from "./set-html-attributes";
import setTsconfigPaths from "./set-tsconfig-paths";
import wrapRootLayout from "./wrap-root-layout";
export const CODEMOD_CATALOG: Record<string, Codemod> = {
[addArrayEntryInCall.id]: addArrayEntryInCall,
[addPackageDep.id]: addPackageDep,
[addPluginToCall.id]: addPluginToCall,
[appendToFile.id]: appendToFile,
[reExport.id]: reExport,
[replaceImport.id]: replaceImport,
[setHtmlAttributes.id]: setHtmlAttributes,
[setTsconfigPaths.id]: setTsconfigPaths,
[wrapRootLayout.id]: wrapRootLayout,
};
export { addPackageDep, addPluginToCall, appendToFile, reExport, wrapRootLayout };
export {
addArrayEntryInCall,
addPackageDep,
addPluginToCall,
appendToFile,
reExport,
replaceImport,
setHtmlAttributes,
setTsconfigPaths,
wrapRootLayout,
};
export type { AddArrayEntryInCallArgs } from "./add-array-entry-in-call";
export type { AddPackageDepArgs } from "./add-package-dep";
export type { AddPluginToCallArgs } from "./add-plugin-to-call";
export type { AppendToFileArgs } from "./append-to-file";
export type { ReExportArgs } from "./re-export";
export type { ReplaceImportArgs } from "./replace-import";
export type { SetHtmlAttributesArgs } from "./set-html-attributes";
export type { SetTsconfigPathsArgs } from "./set-tsconfig-paths";
export type { WrapRootLayoutArgs } from "./wrap-root-layout";
@@ -0,0 +1,127 @@
import fs from "node:fs";
import os from "node:os";
import path from "node:path";
import { emptyManifest } from "@stanza/registry";
import { afterEach, beforeEach, describe, expect, it } from "vite-plus/test";
import { type CodemodContext, openProject } from "../index";
import replaceImport from "./replace-import";
let tmp: string;
beforeEach(() => {
tmp = fs.mkdtempSync(path.join(os.tmpdir(), "stanza-replace-import-"));
fs.mkdirSync(path.join(tmp, "apps/web/app"), { recursive: true });
});
afterEach(() => {
fs.rmSync(tmp, { recursive: true, force: true });
});
function setup(filePath: string, initial: string) {
const abs = path.join(tmp, "apps/web", filePath);
fs.mkdirSync(path.dirname(abs), { recursive: true });
fs.writeFileSync(abs, initial, "utf8");
const project = openProject(path.join(tmp, "apps/web"));
const claimed: Array<{ file: string; region: string }> = [];
const released: Array<{ file: string; region: string }> = [];
const manifest = emptyManifest({ name: "acme" });
const ctx: CodemodContext = {
projectRoot: tmp,
app: manifest.apps[0]!,
appRoot: path.join(tmp, "apps/web"),
project: () => project,
manifest,
owner: { category: "ui", module: "shadcn-radix" },
adapter: "next",
claimRegion(file, region) {
claimed.push({ file, region });
},
releaseRegion(file, region) {
released.push({ file, region });
},
};
return { ctx, abs, project, claimed, released };
}
const LAYOUT_TSX = `import type { ReactNode } from "react";
import "./globals.css";
export default function RootLayout({ children }: { children: ReactNode }) {
return <html><body>{children}</body></html>;
}
`;
describe("replace-import", () => {
it("swaps a side-effect import's module specifier in place", () => {
const { ctx, abs, project, claimed } = setup("app/layout.tsx", LAYOUT_TSX);
const result = replaceImport.apply(ctx, {
file: "app/layout.tsx",
from: "./globals.css",
to: "@acme/ui/globals.css",
}) as { touchedFiles: string[] };
expect(result.touchedFiles).toEqual(["apps/web/app/layout.tsx"]);
project.saveSync();
expect(fs.readFileSync(abs, "utf8")).toContain('import "@acme/ui/globals.css";');
expect(fs.readFileSync(abs, "utf8")).not.toContain('import "./globals.css";');
expect(claimed).toEqual([
{ file: "apps/web/app/layout.tsx", region: "imports.@acme/ui/globals.css" },
]);
});
it("is idempotent when the target import already exists", () => {
const { ctx, project } = setup(
"app/layout.tsx",
LAYOUT_TSX.replace("./globals.css", "@acme/ui/globals.css"),
);
const result = replaceImport.apply(ctx, {
file: "app/layout.tsx",
from: "./globals.css",
to: "@acme/ui/globals.css",
}) as { touchedFiles: string[] };
expect(result.touchedFiles).toEqual([]);
project.saveSync();
});
it("throws when neither `from` nor `to` is present", () => {
const { ctx } = setup("app/layout.tsx", LAYOUT_TSX.replace('import "./globals.css";\n\n', ""));
expect(() =>
replaceImport.apply(ctx, {
file: "app/layout.tsx",
from: "./globals.css",
to: "@acme/ui/globals.css",
}),
).toThrow(/no import from "\.\/globals\.css"/);
});
it("preserves the import's binding (named import)", () => {
const initial = `import { foo } from "./oldmod";\n`;
const { ctx, abs, project } = setup("app/x.ts", initial);
replaceImport.apply(ctx, { file: "app/x.ts", from: "./oldmod", to: "./newmod" });
project.saveSync();
expect(fs.readFileSync(abs, "utf8")).toContain('import { foo } from "./newmod";');
});
it("revert swaps the specifier back", () => {
const { ctx, abs, project } = setup("app/layout.tsx", LAYOUT_TSX);
replaceImport.apply(ctx, {
file: "app/layout.tsx",
from: "./globals.css",
to: "@acme/ui/globals.css",
});
project.saveSync();
expect(fs.readFileSync(abs, "utf8")).toContain('import "@acme/ui/globals.css";');
const project2 = openProject(path.join(tmp, "apps/web"));
const ctx2: CodemodContext = { ...ctx, project: () => project2 };
replaceImport.revert?.(ctx2, {
file: "app/layout.tsx",
from: "./globals.css",
to: "@acme/ui/globals.css",
});
project2.saveSync();
expect(fs.readFileSync(abs, "utf8")).toContain('import "./globals.css";');
});
});
@@ -0,0 +1,103 @@
import path from "node:path";
import { type Codemod, type ImportDeclaration } from "../index";
/**
* Swap the module specifier of an existing `import` statement in place.
*
* Useful for redirecting a framework-shipped import to one shipped by another
* module — e.g. switching `import "./globals.css"` to
* `import "@my-app/ui/globals.css"` so a UI module's stylesheet supplants the
* framework's default. Preserves the import kind (side-effect, default, named)
* and any binding names; only the module specifier changes.
*
* Idempotent: a no-op when the file already imports from `to` (matching
* neither `from` nor a stale prior swap matters — we just check the
* destination).
*/
export type ReplaceImportArgs = {
/** Path to the file, relative to `base`. */
file: string;
/**
* Where `file` resolves against:
* - `"repo"`: `ctx.projectRoot`
* - `"app"` (default): `ctx.appRoot`
* - `"package:<dir>"`: `<projectRoot>/packages/<dir>/`
*/
base?: "app" | "repo" | `package:${string}`;
/** Existing module specifier to find. Matched exactly (no normalization). */
from: string;
/** New module specifier to write. */
to: string;
/**
* Region key segment used to track ownership. Defaults to `imports.<to>`.
*/
regionKey?: string;
};
const replaceImport: Codemod<ReplaceImportArgs> = {
id: "replace-import",
description: "Swap the module specifier of an existing `import` statement in place.",
apply(ctx, args) {
const abs = resolveFilePath(ctx, args);
const rel = path.relative(ctx.projectRoot, abs);
const sf = ctx.project().addSourceFileAtPath(abs);
// Already pointing at `to`? Treat as idempotent no-op.
const alreadyAtTarget = sf
.getImportDeclarations()
.some((d: ImportDeclaration) => d.getModuleSpecifierValue() === args.to);
if (alreadyAtTarget) {
// Still claim so remove() knows to release.
ctx.claimRegion(rel, regionKeyFor(args));
return { touchedFiles: [] };
}
const decl = sf.getImportDeclaration(
(d: ImportDeclaration) => d.getModuleSpecifierValue() === args.from,
);
if (!decl) {
throw new Error(
`replace-import: no import from "${args.from}" in ${rel}. ` +
`Already swapped, or the file's imports diverged from the expected shape.`,
);
}
decl.setModuleSpecifier(args.to);
ctx.claimRegion(rel, regionKeyFor(args));
return { touchedFiles: [rel] };
},
revert(ctx, args) {
const abs = resolveFilePath(ctx, args);
const rel = path.relative(ctx.projectRoot, abs);
const sf = ctx.project().addSourceFileAtPath(abs);
const decl = sf.getImportDeclaration(
(d: ImportDeclaration) => d.getModuleSpecifierValue() === args.to,
);
if (decl) decl.setModuleSpecifier(args.from);
ctx.releaseRegion(rel, regionKeyFor(args));
return { touchedFiles: decl ? [rel] : [] };
},
};
function resolveFilePath(
ctx: { projectRoot: string; appRoot: string },
args: ReplaceImportArgs,
): string {
const base = args.base ?? "app";
if (base.startsWith("package:")) {
return path.join(ctx.projectRoot, "packages", base.slice("package:".length), args.file);
}
return path.join(base === "repo" ? ctx.projectRoot : ctx.appRoot, args.file);
}
function regionKeyFor(args: ReplaceImportArgs): string {
return args.regionKey ?? `imports.${args.to}`;
}
export default replaceImport;
@@ -0,0 +1,131 @@
import fs from "node:fs";
import os from "node:os";
import path from "node:path";
import { emptyManifest } from "@stanza/registry";
import { afterEach, beforeEach, describe, expect, it } from "vite-plus/test";
import { type CodemodContext, openProject } from "../index";
import setHtmlAttributes from "./set-html-attributes";
let tmp: string;
beforeEach(() => {
tmp = fs.mkdtempSync(path.join(os.tmpdir(), "stanza-html-attrs-"));
fs.mkdirSync(path.join(tmp, "apps/web/app"), { recursive: true });
});
afterEach(() => {
fs.rmSync(tmp, { recursive: true, force: true });
});
function setup(filePath: string, initial: string) {
const abs = path.join(tmp, "apps/web", filePath);
fs.mkdirSync(path.dirname(abs), { recursive: true });
fs.writeFileSync(abs, initial, "utf8");
const project = openProject(path.join(tmp, "apps/web"));
const manifest = emptyManifest({ name: "acme" });
const ctx: CodemodContext = {
projectRoot: tmp,
app: manifest.apps[0]!,
appRoot: path.join(tmp, "apps/web"),
project: () => project,
manifest,
owner: { category: "ui", module: "shadcn-radix" },
adapter: "next",
claimRegion() {},
releaseRegion() {},
};
return { ctx, abs, project };
}
const LAYOUT_TSX = `import type { ReactNode } from "react";
import "./globals.css";
export default function RootLayout({ children }: { children: ReactNode }) {
return (
<html lang="en">
<body>{children}</body>
</html>
);
}
`;
describe("set-html-attributes", () => {
it("adds a boolean attribute (suppressHydrationWarning)", () => {
const { ctx, abs, project } = setup("app/layout.tsx", LAYOUT_TSX);
const result = setHtmlAttributes.apply(ctx, {
file: "app/layout.tsx",
attributes: [{ name: "suppressHydrationWarning", boolean: true }],
}) as { touchedFiles: string[] };
expect(result.touchedFiles).toEqual(["apps/web/app/layout.tsx"]);
project.saveSync();
const text = fs.readFileSync(abs, "utf8");
expect(text).toContain('<html lang="en" suppressHydrationWarning');
});
it("merges className tokens into an existing className string", () => {
const initial = LAYOUT_TSX.replace(
'<html lang="en">',
'<html lang="en" className="antialiased">',
);
const { ctx, abs, project } = setup("app/layout.tsx", initial);
setHtmlAttributes.apply(ctx, {
file: "app/layout.tsx",
attributes: [{ name: "className", value: "font-sans antialiased" }],
});
project.saveSync();
const text = fs.readFileSync(abs, "utf8");
expect(text).toMatch(/className="antialiased font-sans"/);
});
it("adds className when missing", () => {
const { ctx, abs, project } = setup("app/layout.tsx", LAYOUT_TSX);
setHtmlAttributes.apply(ctx, {
file: "app/layout.tsx",
attributes: [{ name: "className", value: "antialiased font-sans" }],
});
project.saveSync();
const text = fs.readFileSync(abs, "utf8");
expect(text).toMatch(/className="antialiased font-sans"/);
});
it("sets a JSX-expression attribute", () => {
const { ctx, abs, project } = setup("app/layout.tsx", LAYOUT_TSX);
setHtmlAttributes.apply(ctx, {
file: "app/layout.tsx",
attributes: [{ name: "className", expression: 'cn("antialiased", "font-sans")' }],
});
project.saveSync();
const text = fs.readFileSync(abs, "utf8");
expect(text).toContain('className={cn("antialiased", "font-sans")}');
});
it("is idempotent when the boolean attr is already present", () => {
const initial = LAYOUT_TSX.replace(
'<html lang="en">',
'<html lang="en" suppressHydrationWarning>',
);
const { ctx, abs, project } = setup("app/layout.tsx", initial);
const result = setHtmlAttributes.apply(ctx, {
file: "app/layout.tsx",
attributes: [{ name: "suppressHydrationWarning", boolean: true }],
}) as { touchedFiles: string[] };
expect(result.touchedFiles).toEqual([]);
project.saveSync();
const text = fs.readFileSync(abs, "utf8");
// Only one occurrence.
expect(text.match(/suppressHydrationWarning/g)?.length).toBe(1);
});
it("throws when no <html> element is present", () => {
const { ctx } = setup("app/page.tsx", "export default function Page() { return null; }\n");
expect(() =>
setHtmlAttributes.apply(ctx, {
file: "app/page.tsx",
attributes: [{ name: "suppressHydrationWarning", boolean: true }],
}),
).toThrow(/no top-level `<html …>`/);
});
});
@@ -0,0 +1,200 @@
import path from "node:path";
import {
type Codemod,
type JsxAttribute,
type JsxOpeningElement,
type SourceFile,
SyntaxKind,
} from "../index";
/**
* Set attributes on the file's root `<html>` JSX element. Used by UI modules
* to add `suppressHydrationWarning` and apply theme/font classes during
* setup — the canonical example is shadcn's `<html lang="en"
* suppressHydrationWarning className="antialiased font-sans">`.
*
* Three attribute shapes are supported:
* - **`boolean: true`** — emit a bare attribute (`suppressHydrationWarning`)
* - **`value: string`** — emit a string literal (`lang="en"`). For
* `name: "className"` the value is *merged* into any existing className —
* duplicates are skipped (token-wise), order is preserved.
* - **`expression: string`** — emit a JSX expression container with the raw
* code inside (`className={cn("a", "b")}`). For `className` this *replaces*
* any existing className (no automatic merge possible at the AST level).
*
* Idempotent: a no-op when every requested attribute is already satisfied.
*/
export type SetHtmlAttributesArgs = {
/** Path to the file, relative to `base`. */
file: string;
/**
* Where `file` resolves against:
* - `"repo"`: `ctx.projectRoot`
* - `"app"` (default): `ctx.appRoot`
* - `"package:<dir>"`: `<projectRoot>/packages/<dir>/`
*/
base?: "app" | "repo" | `package:${string}`;
/** Attributes to set/merge. Each entry uses exactly one of `boolean | value | expression`. */
attributes: Array<
| { name: string; boolean: true }
| { name: string; value: string }
| { name: string; expression: string }
>;
/**
* Region key segment used to track ownership. Defaults to
* `html.attributes.<comma-joined-names>`.
*/
regionKey?: string;
};
const setHtmlAttributes: Codemod<SetHtmlAttributesArgs> = {
id: "set-html-attributes",
description: "Set or merge attributes on the file's root `<html>` JSX element.",
apply(ctx, args) {
const abs = resolveFilePath(ctx, args);
const rel = path.relative(ctx.projectRoot, abs);
const sf = ctx.project().addSourceFileAtPath(abs);
const opening = findHtmlOpening(sf);
if (!opening) {
throw new Error(
`set-html-attributes: ${rel} has no top-level \`<html …>\` element. ` +
`This codemod targets the root layout file.`,
);
}
let changed = false;
for (const attr of args.attributes) {
if (applyOne(opening, attr)) changed = true;
}
ctx.claimRegion(rel, regionKeyFor(args));
return { touchedFiles: changed ? [rel] : [] };
},
revert(ctx, args) {
const abs = resolveFilePath(ctx, args);
const rel = path.relative(ctx.projectRoot, abs);
const sf = ctx.project().addSourceFileAtPath(abs);
const opening = findHtmlOpening(sf);
let changed = false;
if (opening) {
for (const attr of args.attributes) {
if (revertOne(opening, attr)) changed = true;
}
}
ctx.releaseRegion(rel, regionKeyFor(args));
return { touchedFiles: changed ? [rel] : [] };
},
};
function resolveFilePath(
ctx: { projectRoot: string; appRoot: string },
args: SetHtmlAttributesArgs,
): string {
const base = args.base ?? "app";
if (base.startsWith("package:")) {
return path.join(ctx.projectRoot, "packages", base.slice("package:".length), args.file);
}
return path.join(base === "repo" ? ctx.projectRoot : ctx.appRoot, args.file);
}
function regionKeyFor(args: SetHtmlAttributesArgs): string {
if (args.regionKey) return args.regionKey;
return `html.attributes.${args.attributes.map((a) => a.name).join(",")}`;
}
/** Find the first `<html …>` opening element in the file. */
function findHtmlOpening(sf: SourceFile): JsxOpeningElement | undefined {
for (const node of sf.getDescendantsOfKind(SyntaxKind.JsxOpeningElement)) {
if (node.getTagNameNode().getText() === "html") return node;
}
return undefined;
}
function getAttribute(opening: JsxOpeningElement, name: string): JsxAttribute | undefined {
for (const a of opening.getAttributes()) {
const attr = a.asKind(SyntaxKind.JsxAttribute);
if (attr && attr.getNameNode().getText() === name) return attr;
}
return undefined;
}
/** Returns `true` when the element text was modified. */
function applyOne(
opening: JsxOpeningElement,
attr: SetHtmlAttributesArgs["attributes"][number],
): boolean {
const existing = getAttribute(opening, attr.name);
if ("boolean" in attr) {
if (existing) return false; // present in any form → no change
opening.addAttribute({ name: attr.name });
return true;
}
if ("value" in attr) {
if (attr.name === "className" && existing) {
// Merge tokens into the existing string literal (only — bail on expr form).
const init = existing.getInitializer();
if (!init || init.getKind() !== SyntaxKind.StringLiteral) return false;
const current: string[] = init.getText().slice(1, -1).split(/\s+/).filter(Boolean);
const incoming: string[] = attr.value.split(/\s+/).filter(Boolean);
const additions = incoming.filter((t: string) => !current.includes(t));
if (additions.length === 0) return false;
init.replaceWithText(JSON.stringify([...current, ...additions].join(" ")));
return true;
}
if (existing) {
const init = existing.getInitializer();
if (init && init.getText() === JSON.stringify(attr.value)) return false;
existing.setInitializer(JSON.stringify(attr.value));
return true;
}
opening.addAttribute({ name: attr.name, initializer: JSON.stringify(attr.value) });
return true;
}
// `expression`
const expr = `{${attr.expression}}`;
if (existing) {
const init = existing.getInitializer();
if (init && init.getText() === expr) return false;
existing.setInitializer(expr);
return true;
}
opening.addAttribute({ name: attr.name, initializer: expr });
return true;
}
function revertOne(
opening: JsxOpeningElement,
attr: SetHtmlAttributesArgs["attributes"][number],
): boolean {
const existing = getAttribute(opening, attr.name);
if (!existing) return false;
// For className value-merge we can only safely remove tokens we know about.
if ("value" in attr && attr.name === "className") {
const init = existing.getInitializer();
if (init && init.getKind() === SyntaxKind.StringLiteral) {
const current: string[] = init.getText().slice(1, -1).split(/\s+/).filter(Boolean);
const toRemove = new Set<string>(attr.value.split(/\s+/).filter(Boolean));
const remaining = current.filter((t: string) => !toRemove.has(t));
if (remaining.length === 0) {
existing.remove();
} else if (remaining.length !== current.length) {
init.replaceWithText(JSON.stringify(remaining.join(" ")));
}
return true;
}
}
existing.remove();
return true;
}
export default setHtmlAttributes;
@@ -0,0 +1,111 @@
import fs from "node:fs";
import os from "node:os";
import path from "node:path";
import { emptyManifest } from "@stanza/registry";
import { afterEach, beforeEach, describe, expect, it } from "vite-plus/test";
import { type CodemodContext, openProject } from "../index";
import setTsconfigPaths from "./set-tsconfig-paths";
let tmp: string;
beforeEach(() => {
tmp = fs.mkdtempSync(path.join(os.tmpdir(), "stanza-tsconfig-"));
fs.mkdirSync(path.join(tmp, "apps/web"), { recursive: true });
});
afterEach(() => {
fs.rmSync(tmp, { recursive: true, force: true });
});
function setup(initial: Record<string, unknown>) {
const abs = path.join(tmp, "apps/web/tsconfig.json");
fs.writeFileSync(abs, JSON.stringify(initial, null, 2) + "\n");
const project = openProject(path.join(tmp, "apps/web"));
const manifest = emptyManifest({ name: "acme" });
const ctx: CodemodContext = {
projectRoot: tmp,
app: manifest.apps[0]!,
appRoot: path.join(tmp, "apps/web"),
project: () => project,
manifest,
owner: { category: "ui", module: "shadcn-radix" },
adapter: "next",
claimRegion() {},
releaseRegion() {},
};
return { ctx, abs };
}
describe("set-tsconfig-paths", () => {
it("adds compilerOptions.paths + baseUrl when both missing", () => {
const { ctx, abs } = setup({ compilerOptions: { strict: true } });
const result = setTsconfigPaths.apply(ctx, {
paths: { "@acme/ui/*": ["../../packages/ui/src/*"] },
}) as { touchedFiles: string[] };
expect(result.touchedFiles).toEqual(["apps/web/tsconfig.json"]);
const next = JSON.parse(fs.readFileSync(abs, "utf8"));
expect(next.compilerOptions.baseUrl).toBe(".");
expect(next.compilerOptions.paths["@acme/ui/*"]).toEqual(["../../packages/ui/src/*"]);
expect(next.compilerOptions.strict).toBe(true);
});
it("merges into existing paths and keeps other entries", () => {
const { ctx, abs } = setup({
compilerOptions: {
baseUrl: ".",
paths: { "@/*": ["./src/*"] },
},
});
setTsconfigPaths.apply(ctx, {
paths: { "@acme/ui/*": ["../../packages/ui/src/*"] },
});
const next = JSON.parse(fs.readFileSync(abs, "utf8"));
expect(next.compilerOptions.paths).toEqual({
"@/*": ["./src/*"],
"@acme/ui/*": ["../../packages/ui/src/*"],
});
});
it("is idempotent on re-apply", () => {
const { ctx } = setup({ compilerOptions: { baseUrl: "." } });
setTsconfigPaths.apply(ctx, {
paths: { "@acme/ui/*": ["../../packages/ui/src/*"] },
});
const result = setTsconfigPaths.apply(ctx, {
paths: { "@acme/ui/*": ["../../packages/ui/src/*"] },
}) as { touchedFiles: string[] };
expect(result.touchedFiles).toEqual([]);
});
it("throws on conflicting existing value for same key", () => {
const { ctx } = setup({
compilerOptions: {
paths: { "@acme/ui/*": ["./different"] },
},
});
expect(() =>
setTsconfigPaths.apply(ctx, {
paths: { "@acme/ui/*": ["../../packages/ui/src/*"] },
}),
).toThrow(/already maps "@acme\/ui\/\*"/);
});
it("revert removes only the keys we added", () => {
const { ctx, abs } = setup({
compilerOptions: {
baseUrl: ".",
paths: { "@/*": ["./src/*"] },
},
});
setTsconfigPaths.apply(ctx, {
paths: { "@acme/ui/*": ["../../packages/ui/src/*"] },
});
setTsconfigPaths.revert?.(ctx, {
paths: { "@acme/ui/*": ["../../packages/ui/src/*"] },
});
const next = JSON.parse(fs.readFileSync(abs, "utf8"));
expect(next.compilerOptions.paths).toEqual({ "@/*": ["./src/*"] });
});
});
@@ -0,0 +1,157 @@
import fs from "node:fs";
import path from "node:path";
import { type Codemod, mergeJson, readJson, writeJson } from "../index";
/**
* Merge entries into a `tsconfig.json`'s `compilerOptions.paths` map.
* Sets `baseUrl: "."` when missing (TypeScript requires it for relative
* `paths` to resolve).
*
* Used by UI modules to add `@<name>/ui/*` aliases so the IDE resolves
* subpath imports back into the package's `./src/*` even though the runtime
* `exports` map already points there — keeps go-to-definition snappy in
* mixed pnpm-isolated workspaces.
*
* Idempotent: existing path entries are left alone (same key + same value
* is a no-op; same key + different value throws to avoid silent shadowing).
*/
export type SetTsconfigPathsArgs = {
/** Path to the tsconfig file, relative to `base`. Defaults to `"tsconfig.json"`. */
file?: string;
/**
* Where `file` resolves against:
* - `"repo"`: `ctx.projectRoot`
* - `"app"` (default): `ctx.appRoot`
* - `"package:<dir>"`: `<projectRoot>/packages/<dir>/`
*/
base?: "app" | "repo" | `package:${string}`;
/**
* Path aliases to add under `compilerOptions.paths`. Each value is a list
* of target patterns, matching TypeScript's native shape.
*/
paths: Record<string, string[]>;
/**
* Region key used to track ownership. Defaults to
* `tsconfig.paths.<comma-joined-keys>`.
*/
regionKey?: string;
};
const setTsconfigPaths: Codemod<SetTsconfigPathsArgs> = {
id: "set-tsconfig-paths",
description: "Merge entries into a tsconfig's compilerOptions.paths (idempotent).",
apply(ctx, args) {
const abs = resolveFilePath(ctx, args);
const rel = path.relative(ctx.projectRoot, abs);
if (!fs.existsSync(abs)) {
throw new Error(
`set-tsconfig-paths: ${rel} not found. ` +
`Create it via the framework module's template before running this codemod.`,
);
}
const root = readObject(abs);
const compilerOptions = isRecord(root.compilerOptions) ? root.compilerOptions : {};
const existing = isRecord(compilerOptions.paths) ? compilerOptions.paths : {};
// Conflict check: same key, different value → throw so the user resolves.
for (const [key, val] of Object.entries(args.paths)) {
const prior = existing[key];
if (prior !== undefined && !arraysEqual(toArray(prior), val)) {
throw new Error(
`set-tsconfig-paths: ${rel} already maps "${key}" to a different target. ` +
`Reconcile manually before re-running.`,
);
}
}
// Already satisfied → no-op.
const allPresent = Object.entries(args.paths).every(
([k, v]) => existing[k] !== undefined && arraysEqual(toArray(existing[k]), v),
);
if (allPresent && compilerOptions.baseUrl !== undefined) {
ctx.claimRegion(rel, regionKeyFor(args));
return { touchedFiles: [] };
}
const patch: Record<string, unknown> = {
compilerOptions: {
...(compilerOptions.baseUrl === undefined ? { baseUrl: "." } : {}),
paths: { ...existing, ...args.paths },
},
};
mergeJson(abs, patch);
ctx.claimRegion(rel, regionKeyFor(args));
return { touchedFiles: [rel] };
},
revert(ctx, args) {
const abs = resolveFilePath(ctx, args);
const rel = path.relative(ctx.projectRoot, abs);
if (!fs.existsSync(abs)) {
ctx.releaseRegion(rel, regionKeyFor(args));
return { touchedFiles: [] };
}
const root = readObject(abs);
const compilerOptions = isRecord(root.compilerOptions) ? { ...root.compilerOptions } : {};
const paths = isRecord(compilerOptions.paths) ? { ...compilerOptions.paths } : {};
let changed = false;
for (const [key, val] of Object.entries(args.paths)) {
if (paths[key] !== undefined && arraysEqual(toArray(paths[key]), val)) {
delete paths[key];
changed = true;
}
}
if (changed) {
if (Object.keys(paths).length === 0) {
delete compilerOptions.paths;
} else {
compilerOptions.paths = paths;
}
writeJson(abs, { ...root, compilerOptions });
}
ctx.releaseRegion(rel, regionKeyFor(args));
return { touchedFiles: changed ? [rel] : [] };
},
};
function resolveFilePath(
ctx: { projectRoot: string; appRoot: string },
args: SetTsconfigPathsArgs,
): string {
const file = args.file ?? "tsconfig.json";
const base = args.base ?? "app";
if (base.startsWith("package:")) {
return path.join(ctx.projectRoot, "packages", base.slice("package:".length), file);
}
return path.join(base === "repo" ? ctx.projectRoot : ctx.appRoot, file);
}
function regionKeyFor(args: SetTsconfigPathsArgs): string {
if (args.regionKey) return args.regionKey;
return `tsconfig.paths.${Object.keys(args.paths).join(",")}`;
}
function isRecord(value: unknown): value is Record<string, unknown> {
return typeof value === "object" && value !== null && !Array.isArray(value);
}
function readObject(file: string): Record<string, unknown> {
const v = readJson(file);
return isRecord(v) ? v : {};
}
function toArray(value: unknown): string[] {
if (Array.isArray(value)) return value.map(String);
return [];
}
function arraysEqual(a: string[], b: string[]): boolean {
if (a.length !== b.length) return false;
return a.every((v, i) => v === b[i]);
}
export default setTsconfigPaths;
@@ -0,0 +1,226 @@
import fs from "node:fs";
import os from "node:os";
import path from "node:path";
import { emptyManifest, type StanzaManifest } from "@stanza/registry";
import { afterEach, beforeEach, describe, expect, it } from "vite-plus/test";
import { type CodemodContext, openProject } from "../index";
import wrapRootLayout from "./wrap-root-layout";
let tmp: string;
beforeEach(() => {
tmp = fs.mkdtempSync(path.join(os.tmpdir(), "stanza-wrap-root-"));
fs.mkdirSync(path.join(tmp, "apps/web"), { recursive: true });
});
afterEach(() => {
fs.rmSync(tmp, { recursive: true, force: true });
});
function withFramework(id: string): StanzaManifest {
const manifest = emptyManifest({ name: "acme" });
return {
...manifest,
modules: {
framework: [{ id, version: "0.1.0", adapter: "default", apps: ["web"] }],
},
};
}
function setup(
filePath: string,
initial: string,
manifest: StanzaManifest = withFramework("next"),
) {
const abs = path.join(tmp, "apps/web", filePath);
fs.mkdirSync(path.dirname(abs), { recursive: true });
fs.writeFileSync(abs, initial, "utf8");
const project = openProject(path.join(tmp, "apps/web"));
const claimed: Array<{ file: string; region: string }> = [];
const released: Array<{ file: string; region: string }> = [];
const ctx: CodemodContext = {
projectRoot: tmp,
app: manifest.apps[0]!,
appRoot: path.join(tmp, "apps/web"),
project: () => project,
manifest,
owner: { category: "auth", module: "clerk" },
adapter: "default",
claimRegion(file, region) {
claimed.push({ file, region });
},
releaseRegion(file, region) {
released.push({ file, region });
},
};
return { ctx, abs, project, claimed, released };
}
const NEXT_LAYOUT = `import type { ReactNode } from "react";
import "./globals.css";
export default function RootLayout({ children }: { children: ReactNode }) {
return (
<html lang="en">
<body>{children}</body>
</html>
);
}
`;
const TANSTACK_ROOT = `import { Outlet, createRootRoute } from "@tanstack/react-router";
import "../globals.css";
export const Route = createRootRoute({
component: () => (
<html lang="en">
<body>
<Outlet />
</body>
</html>
),
});
`;
describe("wrap-root-layout", () => {
describe("next dispatch", () => {
it("wraps {children} and adds a named import", () => {
const { ctx, abs, project, claimed } = setup("app/layout.tsx", NEXT_LAYOUT);
const result = wrapRootLayout.apply(ctx, {
providerName: "ClerkProvider",
providerImport: "@clerk/nextjs",
}) as { touchedFiles: string[] };
expect(result.touchedFiles).toEqual(["apps/web/app/layout.tsx"]);
project.saveSync();
const text = fs.readFileSync(abs, "utf8");
expect(text).toContain('import { ClerkProvider } from "@clerk/nextjs"');
expect(text).toContain("<ClerkProvider>{children}</ClerkProvider>");
expect(claimed).toEqual([
{ file: "apps/web/app/layout.tsx", region: "imports.ClerkProvider" },
{ file: "apps/web/app/layout.tsx", region: "providers.ClerkProvider" },
]);
});
it("supports default-import kind", () => {
const { ctx, abs, project } = setup("app/layout.tsx", NEXT_LAYOUT);
wrapRootLayout.apply(ctx, {
providerName: "Providers",
providerImport: "./providers",
importKind: "default",
});
project.saveSync();
const text = fs.readFileSync(abs, "utf8");
expect(text).toContain('import Providers from "./providers"');
expect(text).toContain("<Providers>{children}</Providers>");
});
it("is idempotent on re-apply", () => {
const { ctx, project } = setup("app/layout.tsx", NEXT_LAYOUT);
wrapRootLayout.apply(ctx, {
providerName: "ClerkProvider",
providerImport: "@clerk/nextjs",
});
project.saveSync();
const project2 = openProject(path.join(tmp, "apps/web"));
const result = wrapRootLayout.apply(
{ ...ctx, project: () => project2 },
{ providerName: "ClerkProvider", providerImport: "@clerk/nextjs" },
) as { touchedFiles: string[] };
expect(result.touchedFiles).toEqual([]);
});
it("throws when the {children} marker is missing", () => {
const customized = `export default function RootLayout() { return <html><body /></html>; }\n`;
const { ctx } = setup("app/layout.tsx", customized);
expect(() =>
wrapRootLayout.apply(ctx, {
providerName: "ClerkProvider",
providerImport: "@clerk/nextjs",
}),
).toThrow(/could not find `{children}`/);
});
});
describe("tanstack-start dispatch", () => {
it("wraps <Outlet /> and adds a named import", () => {
const { ctx, abs, project, claimed } = setup(
"src/routes/__root.tsx",
TANSTACK_ROOT,
withFramework("tanstack-start"),
);
wrapRootLayout.apply(ctx, {
providerName: "ThemeProvider",
providerImport: "@/components/theme-provider",
});
project.saveSync();
const text = fs.readFileSync(abs, "utf8");
expect(text).toContain('import { ThemeProvider } from "@/components/theme-provider"');
expect(text).toContain("<ThemeProvider><Outlet /></ThemeProvider>");
expect(claimed).toEqual([
{ file: "apps/web/src/routes/__root.tsx", region: "imports.ThemeProvider" },
{ file: "apps/web/src/routes/__root.tsx", region: "providers.ThemeProvider" },
]);
});
});
describe("unsupported framework", () => {
it("throws with a helpful message", () => {
const { ctx } = setup("app/layout.tsx", NEXT_LAYOUT, withFramework("solid-start"));
expect(() =>
wrapRootLayout.apply(ctx, {
providerName: "ClerkProvider",
providerImport: "@clerk/nextjs",
}),
).toThrow(/framework "solid-start" not supported/);
});
it("throws when no framework is selected at all", () => {
const { ctx } = setup("app/layout.tsx", NEXT_LAYOUT, emptyManifest({ name: "acme" }));
expect(() =>
wrapRootLayout.apply(ctx, {
providerName: "ClerkProvider",
providerImport: "@clerk/nextjs",
}),
).toThrow(/framework "<unset>" not supported/);
});
});
describe("revert", () => {
it("removes the wrapper and import", () => {
const { ctx, abs, project, released } = setup("app/layout.tsx", NEXT_LAYOUT);
wrapRootLayout.apply(ctx, {
providerName: "ClerkProvider",
providerImport: "@clerk/nextjs",
});
project.saveSync();
const project2 = openProject(path.join(tmp, "apps/web"));
wrapRootLayout.revert?.(
{ ...ctx, project: () => project2 },
{ providerName: "ClerkProvider", providerImport: "@clerk/nextjs" },
);
project2.saveSync();
const text = fs.readFileSync(abs, "utf8");
expect(text).not.toContain("ClerkProvider");
expect(text).toContain("<body>{children}</body>");
expect(released).toEqual([
{ file: "apps/web/app/layout.tsx", region: "imports.ClerkProvider" },
{ file: "apps/web/app/layout.tsx", region: "providers.ClerkProvider" },
]);
});
it("revert is a no-op on an unsupported framework", () => {
const { ctx } = setup("app/layout.tsx", NEXT_LAYOUT, withFramework("solid-start"));
const result = wrapRootLayout.revert?.(ctx, {
providerName: "ClerkProvider",
providerImport: "@clerk/nextjs",
});
expect(result).toEqual({ touchedFiles: [] });
});
});
});
+2
View File
@@ -7,6 +7,8 @@ export type {
ExportDeclaration,
ExportSpecifier,
ImportDeclaration,
JsxAttribute,
JsxOpeningElement,
Node,
Project,
SourceFile,
+4 -4
View File
@@ -46,11 +46,11 @@ export const CATEGORIES = [
home: { kind: "app" },
},
{
id: "styling",
label: "Styling",
description: "CSS / styling system.",
id: "ui",
label: "UI",
description: "Styling system + component primitives.",
cardinality: "one",
home: { kind: "app" },
home: { kind: "package", dir: "ui" },
},
{
id: "db",
+27 -1
View File
@@ -117,13 +117,39 @@ export function appPackageJsonBase(opts: { name: string; app: AppSpec }): Packag
};
}
/**
* Subpath-`exports` map per package home. The `ui` home doesn't ship a barrel
* — apps import shadcn-style subpaths (`@<name>/ui/lib/utils`, etc.) — so the
* bootstrap omits `main`/`types` + the `.` entry.
*
* Keep keys to *real* `home.dir` values from `CATEGORIES`. Unknown dirs fall
* back to the barrel shape (one `.` entry → `./src/index.ts`).
*/
const PACKAGE_EXPORTS: Record<string, PackageJson> = {
ui: {
type: "module",
exports: {
"./globals.css": "./src/styles/globals.css",
"./postcss.config": "./postcss.config.mjs",
"./lib/*": "./src/lib/*.ts",
"./components/*": "./src/components/*.tsx",
"./hooks/*": "./src/hooks/*.ts",
},
},
};
/** Slot-package `package.json` for `packages/<dir>/` — matches the CLI's `ensureSlotPackage`. */
export function slotPackageJsonBase(opts: { name: string; dir: string }): PackageJson {
return {
const base: PackageJson = {
name: `@${opts.name}/${opts.dir}`,
version: "0.0.0",
private: true,
type: "module",
};
const override = PACKAGE_EXPORTS[opts.dir];
if (override) return { ...base, ...override };
return {
...base,
main: "./src/index.ts",
types: "./src/index.ts",
exports: { ".": "./src/index.ts" },
+1 -1
View File
@@ -82,7 +82,7 @@ describe("resolveAdapter", () => {
it("falls back to a default (empty-match) adapter when no peers are required", () => {
const tailwind: Module = defineModule({
id: "tailwind",
category: "styling",
category: "ui",
label: "",
description: "",
version: "0.1.0",
+1 -1
View File
@@ -90,7 +90,7 @@ export function isCompatible(module: Module, context: ResolveContext): boolean {
* Active peer ids from the manifest alone — only `cardinality: "one"`
* categories can be peers, so we iterate `PEER_CATEGORIES` and read the
* single installed pick. When `targetAppId` is set, the lookup is scoped to
* that app so e.g. web's styling adapter peer-matches against web's
* that app so e.g. web's ui adapter peer-matches against web's
* framework, not native's.
*
* Exported so callers outside the resolver (the CLI runner and web
+3 -3
View File
@@ -115,9 +115,9 @@ describe("buildRenderContext", () => {
peers: { framework: "next" },
});
expect(ctx.peers.framework).toBe("next");
// styling is a PEER_CATEGORY but not set — the key exists, value is undefined.
expect("styling" in ctx.peers).toBe(true);
expect(ctx.peers.styling).toBeUndefined();
// ui is a PEER_CATEGORY but not set — the key exists, value is undefined.
expect("ui" in ctx.peers).toBe(true);
expect(ctx.peers.ui).toBeUndefined();
});
});
+112 -3
View File
@@ -244,10 +244,10 @@ importers:
version: '@voidzero-dev/vite-plus-core@0.1.22(@types/node@25.9.1)(esbuild@0.28.0)(jiti@2.7.0)(typescript@6.0.3)'
vite-plus:
specifier: 'catalog:'
version: 0.1.22(@opentelemetry/api@1.9.1)(@types/node@25.9.1)(esbuild@0.28.0)(jiti@2.7.0)(jsdom@29.1.1(@noble/hashes@1.8.0))(typescript@6.0.3)(vite@8.0.14(@types/node@25.9.1)(esbuild@0.28.0)(jiti@2.7.0))
version: 0.1.22(@opentelemetry/api@1.9.1)(@types/node@25.9.1)(@voidzero-dev/vite-plus-core@0.1.22(@types/node@25.9.1)(esbuild@0.28.0)(jiti@2.7.0)(typescript@6.0.3))(esbuild@0.28.0)(jiti@2.7.0)(jsdom@29.1.1(@noble/hashes@1.8.0))(typescript@6.0.3)
vitest:
specifier: npm:@voidzero-dev/vite-plus-test@^0.1.22
version: '@voidzero-dev/vite-plus-test@0.1.22(@opentelemetry/api@1.9.1)(@types/node@25.9.1)(esbuild@0.28.0)(jiti@2.7.0)(jsdom@29.1.1(@noble/hashes@1.8.0))(typescript@6.0.3)(vite@8.0.14(@types/node@25.9.1)(esbuild@0.28.0)(jiti@2.7.0))'
version: '@voidzero-dev/vite-plus-test@0.1.22(@opentelemetry/api@1.9.1)(@types/node@25.9.1)(@voidzero-dev/vite-plus-core@0.1.22(@types/node@25.9.1)(esbuild@0.28.0)(jiti@2.7.0)(typescript@6.0.3))(esbuild@0.28.0)(jiti@2.7.0)(jsdom@29.1.1(@noble/hashes@1.8.0))(typescript@6.0.3)'
web-vitals:
specifier: ^5.2.0
version: 5.2.0
@@ -403,7 +403,25 @@ importers:
specifier: workspace:*
version: link:../../../packages/registry
registry/modules/styling-tailwind:
registry/modules/ui-shadcn-base:
dependencies:
'@stanza/codemods':
specifier: workspace:*
version: link:../../../packages/codemods
'@stanza/registry':
specifier: workspace:*
version: link:../../../packages/registry
registry/modules/ui-shadcn-radix:
dependencies:
'@stanza/codemods':
specifier: workspace:*
version: link:../../../packages/codemods
'@stanza/registry':
specifier: workspace:*
version: link:../../../packages/registry
registry/modules/ui-tailwind:
dependencies:
'@stanza/codemods':
specifier: workspace:*
@@ -7102,6 +7120,48 @@ snapshots:
'@voidzero-dev/vite-plus-linux-x64-musl@0.1.22':
optional: true
'@voidzero-dev/vite-plus-test@0.1.22(@opentelemetry/api@1.9.1)(@types/node@25.9.1)(@voidzero-dev/vite-plus-core@0.1.22(@types/node@25.9.1)(esbuild@0.28.0)(jiti@2.7.0)(typescript@6.0.3))(esbuild@0.28.0)(jiti@2.7.0)(jsdom@29.1.1(@noble/hashes@1.8.0))(typescript@6.0.3)':
dependencies:
'@standard-schema/spec': 1.1.0
'@types/chai': 5.2.3
'@voidzero-dev/vite-plus-core': 0.1.22(@types/node@25.9.1)(esbuild@0.28.0)(jiti@2.7.0)(typescript@6.0.3)
es-module-lexer: 1.7.0
obug: 2.1.1
pixelmatch: 7.2.0
pngjs: 7.0.0
sirv: 3.0.2
std-env: 4.1.0
tinybench: 2.9.0
tinyexec: 1.2.2
tinyglobby: 0.2.16
vite: '@voidzero-dev/vite-plus-core@0.1.22(@types/node@25.9.1)(esbuild@0.28.0)(jiti@2.7.0)(typescript@6.0.3)'
ws: 8.21.0
optionalDependencies:
'@opentelemetry/api': 1.9.1
'@types/node': 25.9.1
jsdom: 29.1.1(@noble/hashes@1.8.0)
transitivePeerDependencies:
- '@arethetypeswrong/core'
- '@tsdown/css'
- '@tsdown/exe'
- '@vitejs/devtools'
- bufferutil
- esbuild
- jiti
- less
- publint
- sass
- sass-embedded
- stylus
- sugarss
- terser
- tsx
- typescript
- unplugin-unused
- unrun
- utf-8-validate
- yaml
'@voidzero-dev/vite-plus-test@0.1.22(@opentelemetry/api@1.9.1)(@types/node@25.9.1)(esbuild@0.28.0)(jiti@2.7.0)(jsdom@29.1.1(@noble/hashes@1.8.0))(typescript@6.0.3)(vite@8.0.14(@types/node@25.9.1)(esbuild@0.28.0)(jiti@2.7.0))':
dependencies:
'@standard-schema/spec': 1.1.0
@@ -9957,6 +10017,55 @@ snapshots:
'@types/unist': 3.0.3
vfile-message: 4.0.3
vite-plus@0.1.22(@opentelemetry/api@1.9.1)(@types/node@25.9.1)(@voidzero-dev/vite-plus-core@0.1.22(@types/node@25.9.1)(esbuild@0.28.0)(jiti@2.7.0)(typescript@6.0.3))(esbuild@0.28.0)(jiti@2.7.0)(jsdom@29.1.1(@noble/hashes@1.8.0))(typescript@6.0.3):
dependencies:
'@oxc-project/types': 0.129.0
'@oxlint/plugins': 1.61.0
'@voidzero-dev/vite-plus-core': 0.1.22(@types/node@25.9.1)(esbuild@0.28.0)(jiti@2.7.0)(typescript@6.0.3)
'@voidzero-dev/vite-plus-test': 0.1.22(@opentelemetry/api@1.9.1)(@types/node@25.9.1)(@voidzero-dev/vite-plus-core@0.1.22(@types/node@25.9.1)(esbuild@0.28.0)(jiti@2.7.0)(typescript@6.0.3))(esbuild@0.28.0)(jiti@2.7.0)(jsdom@29.1.1(@noble/hashes@1.8.0))(typescript@6.0.3)
oxfmt: 0.48.0
oxlint: 1.63.0(oxlint-tsgolint@0.22.1)
oxlint-tsgolint: 0.22.1
optionalDependencies:
'@voidzero-dev/vite-plus-darwin-arm64': 0.1.22
'@voidzero-dev/vite-plus-darwin-x64': 0.1.22
'@voidzero-dev/vite-plus-linux-arm64-gnu': 0.1.22
'@voidzero-dev/vite-plus-linux-arm64-musl': 0.1.22
'@voidzero-dev/vite-plus-linux-x64-gnu': 0.1.22
'@voidzero-dev/vite-plus-linux-x64-musl': 0.1.22
'@voidzero-dev/vite-plus-win32-arm64-msvc': 0.1.22
'@voidzero-dev/vite-plus-win32-x64-msvc': 0.1.22
transitivePeerDependencies:
- '@arethetypeswrong/core'
- '@edge-runtime/vm'
- '@opentelemetry/api'
- '@tsdown/css'
- '@tsdown/exe'
- '@types/node'
- '@vitejs/devtools'
- '@vitest/coverage-istanbul'
- '@vitest/coverage-v8'
- '@vitest/ui'
- bufferutil
- esbuild
- happy-dom
- jiti
- jsdom
- less
- publint
- sass
- sass-embedded
- stylus
- sugarss
- terser
- tsx
- typescript
- unplugin-unused
- unrun
- utf-8-validate
- vite
- yaml
vite-plus@0.1.22(@opentelemetry/api@1.9.1)(@types/node@25.9.1)(esbuild@0.28.0)(jiti@2.7.0)(jsdom@29.1.1(@noble/hashes@1.8.0))(typescript@6.0.3)(vite@8.0.14(@types/node@25.9.1)(esbuild@0.28.0)(jiti@2.7.0)):
dependencies:
'@oxc-project/types': 0.129.0
@@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" fill="#fff" aria-label="Base UI" viewBox="0 0 17 24"><path d="M9.5 7.015A.477.477 0 0 0 9 7.5V23a8 8 0 0 0 .5-15.985ZM8 9.8V23c-4.418 0-8-3.94-8-8.8V1c4.418 0 8 3.94 8 8.8Z"/></svg>

After

Width:  |  Height:  |  Size: 221 B

@@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" aria-label="Base UI" viewBox="0 0 17 24"><path d="M9.5 7.015A.477.477 0 0 0 9 7.5V23a8 8 0 0 0 .5-15.985ZM8 9.8V23c-4.418 0-8-3.94-8-8.8V1c4.418 0 8 3.94 8 8.8Z"/></svg>

After

Width:  |  Height:  |  Size: 209 B

+230
View File
@@ -0,0 +1,230 @@
import { defineModule } from "@stanza/registry";
export default defineModule({
id: "shadcn-base",
category: "ui",
label: "shadcn (Base UI)",
description:
"shadcn/ui registry preset `base-nova` — Tailwind + Base UI primitives in packages/ui.",
version: "0.1.0",
peers: { framework: ["next", "tanstack-start"] },
homepage: "https://ui.shadcn.com",
// Everything shadcn pulls in regardless of framework — kept at module level so
// the two adapters only carry the per-framework deltas.
dependencies: {
"@base-ui/react": "^1.5.0",
"class-variance-authority": "^0.7.1",
clsx: "^2.1.1",
"lucide-react": "^1.16.0",
shadcn: "^4.8.0",
"tailwind-merge": "^3.6.0",
"tw-animate-css": "^1.4.0",
zod: "^3.25.76",
},
devDependencies: { tailwindcss: "^4.1.18" },
adapters: [
{
key: "next",
match: { framework: "next" },
// next-themes powers shadcn's <ThemeProvider> on Next — class strategy +
// localStorage + system-pref subscription, all out of the box.
dependencies: { "next-themes": "^0.4.6" },
devDependencies: { "@tailwindcss/postcss": "^4.1.18" },
templates: [
// packages/ui/ — shared shadcn surface.
{
src: "shared/package/src/lib/utils.ts",
dest: "src/lib/utils.ts",
scope: "package",
},
{
src: "shared/package/src/components/button.tsx",
dest: "src/components/button.tsx",
scope: "package",
template: true,
},
{
src: "shared/package/components.json",
dest: "components.json",
scope: "package",
template: true,
},
// Next-specific: the oklch theme tokens + postcss plugin config.
{
src: "next/package/src/styles/globals.css",
dest: "src/styles/globals.css",
scope: "package",
},
{
src: "next/package/postcss.config.mjs",
dest: "postcss.config.mjs",
scope: "package",
},
// apps/<id>/ — outer shadcn config + ThemeProvider + postcss re-export.
{
src: "next/app/components.json",
dest: "components.json",
scope: "app",
template: true,
},
{
src: "next/app/postcss.config.mjs",
dest: "postcss.config.mjs",
scope: "app",
template: true,
},
{
src: "next/app/components/theme-provider.tsx",
dest: "components/theme-provider.tsx",
scope: "app",
},
],
codemods: [
// Swap the framework's `import "./globals.css"` for the shadcn-managed
// bundle in packages/ui (oklch tokens + tw-animate-css + the shadcn
// tailwind base). Done as an in-place replace (not append + comment)
// because the framework's CSS file becomes orphaned otherwise.
{
id: "replace-import",
args: {
file: "app/layout.tsx",
from: "./globals.css",
to: "{{package.name}}/globals.css",
},
},
// `next-themes` toggles the `dark`/`light` class on <html>; without
// suppressHydrationWarning React complains on the first paint
// because the server-rendered class != the client-resolved one.
{
id: "set-html-attributes",
args: {
file: "app/layout.tsx",
attributes: [{ name: "suppressHydrationWarning", boolean: true }],
},
},
// Wrap `{children}` with <ThemeProvider>.
{
id: "wrap-root-layout",
args: {
providerName: "ThemeProvider",
providerImport: "@/components/theme-provider",
importKind: "named",
},
},
// IDE-only: pnpm's isolated linker + the package's subpath exports
// are enough at runtime, but shadcn's CLI (when adding more
// components) reads tsconfig.paths to know where ui/* lives.
{
id: "set-tsconfig-paths",
args: {
paths: { "{{package.name}}/*": ["../../packages/ui/src/*"] },
},
},
],
},
{
key: "tanstack-start",
match: { framework: "tanstack-start" },
// TSS has no next/font; pull Inter via @fontsource so the shadcn
// `--font-sans` token resolves to a real font.
dependencies: { "@fontsource-variable/inter": "^5.2.8" },
devDependencies: { "@tailwindcss/vite": "^4.1.18" },
templates: [
// packages/ui/ — shared shadcn surface.
{
src: "shared/package/src/lib/utils.ts",
dest: "src/lib/utils.ts",
scope: "package",
},
{
src: "shared/package/src/components/button.tsx",
dest: "src/components/button.tsx",
scope: "package",
template: true,
},
{
src: "shared/package/components.json",
dest: "components.json",
scope: "package",
template: true,
},
// Start-specific globals.css — adds the @fontsource Inter import.
{
src: "tanstack-start/package/src/styles/globals.css",
dest: "src/styles/globals.css",
scope: "package",
},
// apps/<id>/ — outer shadcn config + the TSS-native ThemeProvider
// (ScriptOnce-based, per https://ui.shadcn.com/docs/dark-mode/tanstack-start —
// next-themes is Next-only).
{
src: "tanstack-start/app/components.json",
dest: "components.json",
scope: "app",
template: true,
},
{
src: "tanstack-start/app/components/theme-provider.tsx",
dest: "components/theme-provider.tsx",
scope: "app",
},
],
codemods: [
// Splice tailwindcss() into the framework's vite.config.ts plugin array.
{
id: "add-plugin-to-call",
args: {
file: "vite.config.ts",
callee: "defineConfig",
property: "plugins",
call: "tailwindcss()",
imports: [{ from: "@tailwindcss/vite", default: "tailwindcss" }],
position: "before:react",
},
},
// TSS's idiomatic CSS wiring: import the stylesheet `?url` and
// emit it as a `<link rel="stylesheet">` via the route's
// `head: () => ({ links: [...] })`. The codemod creates the head
// arrow + links array when absent.
{
id: "add-array-entry-in-call",
args: {
file: "src/routes/__root.tsx",
callee: "createRootRoute",
property: "head().links",
entry: '{ rel: "stylesheet", href: appCss }',
imports: [{ from: "{{package.name}}/globals.css?url", default: "appCss" }],
},
},
// Hand-rolled ThemeProvider toggles `dark`/`light` on <html>;
// suppressHydrationWarning silences the SSR/CSR class mismatch.
{
id: "set-html-attributes",
args: {
file: "src/routes/__root.tsx",
attributes: [{ name: "suppressHydrationWarning", boolean: true }],
},
},
// Wrap <Outlet /> with <ThemeProvider>. ThemeProvider defaults
// (defaultTheme="system", storageKey="theme") match shadcn's
// dark-mode-docs guidance, so no extra prop wiring is needed.
{
id: "wrap-root-layout",
args: {
providerName: "ThemeProvider",
providerImport: "@/components/theme-provider",
importKind: "named",
},
},
// IDE-only: matches shadcn's app tsconfig paths so its CLI can add
// more components without manual path config.
{
id: "set-tsconfig-paths",
args: {
paths: { "{{package.name}}/*": ["../../packages/ui/src/*"] },
},
},
],
},
],
});
@@ -1,5 +1,5 @@
{
"name": "@stanza-modules/styling-tailwind",
"name": "@stanza-modules/ui-shadcn-base",
"version": "0.1.0",
"private": true,
"license": "MIT",
@@ -0,0 +1,23 @@
{
"$schema": "https://ui.shadcn.com/schema.json",
"style": "base-nova",
"rsc": true,
"tsx": true,
"tailwind": {
"config": "",
"css": "../../packages/ui/src/styles/globals.css",
"baseColor": "neutral",
"cssVariables": true
},
"iconLibrary": "lucide",
"aliases": {
"components": "@/components",
"hooks": "@/hooks",
"lib": "@/lib",
"utils": "{{package.name}}/lib/utils",
"ui": "{{package.name}}/components"
},
"rtl": false,
"menuColor": "default",
"menuAccent": "subtle"
}
@@ -0,0 +1,71 @@
"use client"
import * as React from "react"
import { ThemeProvider as NextThemesProvider, useTheme } from "next-themes"
function ThemeProvider({
children,
...props
}: React.ComponentProps<typeof NextThemesProvider>) {
return (
<NextThemesProvider
attribute="class"
defaultTheme="system"
enableSystem
disableTransitionOnChange
{...props}
>
<ThemeHotkey />
{children}
</NextThemesProvider>
)
}
function isTypingTarget(target: EventTarget | null) {
if (!(target instanceof HTMLElement)) {
return false
}
return (
target.isContentEditable ||
target.tagName === "INPUT" ||
target.tagName === "TEXTAREA" ||
target.tagName === "SELECT"
)
}
function ThemeHotkey() {
const { resolvedTheme, setTheme } = useTheme()
React.useEffect(() => {
function onKeyDown(event: KeyboardEvent) {
if (event.defaultPrevented || event.repeat) {
return
}
if (event.metaKey || event.ctrlKey || event.altKey) {
return
}
if (event.key.toLowerCase() !== "d") {
return
}
if (isTypingTarget(event.target)) {
return
}
setTheme(resolvedTheme === "dark" ? "light" : "dark")
}
window.addEventListener("keydown", onKeyDown)
return () => {
window.removeEventListener("keydown", onKeyDown)
}
}, [resolvedTheme, setTheme])
return null
}
export { ThemeProvider }
@@ -0,0 +1 @@
export { default } from "{{package.name}}/postcss.config";
@@ -0,0 +1,6 @@
/** @type {import('postcss-load-config').Config} */
const config = {
plugins: { "@tailwindcss/postcss": {} },
};
export default config;
@@ -0,0 +1,129 @@
@import "tailwindcss";
@import "tw-animate-css";
@import "shadcn/tailwind.css";
@custom-variant dark (&:is(.dark *));
@source "../../../apps/**/*.{ts,tsx}";
@source "../../../components/**/*.{ts,tsx}";
@source "../**/*.{ts,tsx}";
@theme inline {
--font-heading: var(--font-sans);
--color-sidebar-ring: var(--sidebar-ring);
--color-sidebar-border: var(--sidebar-border);
--color-sidebar-accent-foreground: var(--sidebar-accent-foreground);
--color-sidebar-accent: var(--sidebar-accent);
--color-sidebar-primary-foreground: var(--sidebar-primary-foreground);
--color-sidebar-primary: var(--sidebar-primary);
--color-sidebar-foreground: var(--sidebar-foreground);
--color-sidebar: var(--sidebar);
--color-chart-5: var(--chart-5);
--color-chart-4: var(--chart-4);
--color-chart-3: var(--chart-3);
--color-chart-2: var(--chart-2);
--color-chart-1: var(--chart-1);
--color-ring: var(--ring);
--color-input: var(--input);
--color-border: var(--border);
--color-destructive: var(--destructive);
--color-accent-foreground: var(--accent-foreground);
--color-accent: var(--accent);
--color-muted-foreground: var(--muted-foreground);
--color-muted: var(--muted);
--color-secondary-foreground: var(--secondary-foreground);
--color-secondary: var(--secondary);
--color-primary-foreground: var(--primary-foreground);
--color-primary: var(--primary);
--color-popover-foreground: var(--popover-foreground);
--color-popover: var(--popover);
--color-card-foreground: var(--card-foreground);
--color-card: var(--card);
--color-foreground: var(--foreground);
--color-background: var(--background);
--radius-sm: calc(var(--radius) * 0.6);
--radius-md: calc(var(--radius) * 0.8);
--radius-lg: var(--radius);
--radius-xl: calc(var(--radius) * 1.4);
--radius-2xl: calc(var(--radius) * 1.8);
--radius-3xl: calc(var(--radius) * 2.2);
--radius-4xl: calc(var(--radius) * 2.6);
--font-sans: var(--font-sans);
}
:root {
--background: oklch(1 0 0);
--foreground: oklch(0.145 0 0);
--card: oklch(1 0 0);
--card-foreground: oklch(0.145 0 0);
--popover: oklch(1 0 0);
--popover-foreground: oklch(0.145 0 0);
--primary: oklch(0.205 0 0);
--primary-foreground: oklch(0.985 0 0);
--secondary: oklch(0.97 0 0);
--secondary-foreground: oklch(0.205 0 0);
--muted: oklch(0.97 0 0);
--muted-foreground: oklch(0.556 0 0);
--accent: oklch(0.97 0 0);
--accent-foreground: oklch(0.205 0 0);
--destructive: oklch(0.577 0.245 27.325);
--border: oklch(0.922 0 0);
--input: oklch(0.922 0 0);
--ring: oklch(0.708 0 0);
--chart-1: oklch(0.87 0 0);
--chart-2: oklch(0.556 0 0);
--chart-3: oklch(0.439 0 0);
--chart-4: oklch(0.371 0 0);
--chart-5: oklch(0.269 0 0);
--radius: 0.625rem;
--sidebar: oklch(0.985 0 0);
--sidebar-foreground: oklch(0.145 0 0);
--sidebar-primary: oklch(0.205 0 0);
--sidebar-primary-foreground: oklch(0.985 0 0);
--sidebar-accent: oklch(0.97 0 0);
--sidebar-accent-foreground: oklch(0.205 0 0);
--sidebar-border: oklch(0.922 0 0);
--sidebar-ring: oklch(0.708 0 0);
}
.dark {
--background: oklch(0.145 0 0);
--foreground: oklch(0.985 0 0);
--card: oklch(0.205 0 0);
--card-foreground: oklch(0.985 0 0);
--popover: oklch(0.205 0 0);
--popover-foreground: oklch(0.985 0 0);
--primary: oklch(0.922 0 0);
--primary-foreground: oklch(0.205 0 0);
--secondary: oklch(0.269 0 0);
--secondary-foreground: oklch(0.985 0 0);
--muted: oklch(0.269 0 0);
--muted-foreground: oklch(0.708 0 0);
--accent: oklch(0.269 0 0);
--accent-foreground: oklch(0.985 0 0);
--destructive: oklch(0.704 0.191 22.216);
--border: oklch(1 0 0 / 10%);
--input: oklch(1 0 0 / 15%);
--ring: oklch(0.556 0 0);
--chart-1: oklch(0.87 0 0);
--chart-2: oklch(0.556 0 0);
--chart-3: oklch(0.439 0 0);
--chart-4: oklch(0.371 0 0);
--chart-5: oklch(0.269 0 0);
--sidebar: oklch(0.205 0 0);
--sidebar-foreground: oklch(0.985 0 0);
--sidebar-primary: oklch(0.488 0.243 264.376);
--sidebar-primary-foreground: oklch(0.985 0 0);
--sidebar-accent: oklch(0.269 0 0);
--sidebar-accent-foreground: oklch(0.985 0 0);
--sidebar-border: oklch(1 0 0 / 10%);
--sidebar-ring: oklch(0.556 0 0);
}
@layer base {
* {
@apply border-border outline-ring/50;
}
body {
@apply bg-background text-foreground;
}
}
@@ -0,0 +1,23 @@
{
"$schema": "https://ui.shadcn.com/schema.json",
"style": "base-nova",
"rsc": {{#if (eq peers.framework "next")}}true{{else}}false{{/if}},
"tsx": true,
"tailwind": {
"config": "",
"css": "src/styles/globals.css",
"baseColor": "neutral",
"cssVariables": true
},
"iconLibrary": "lucide",
"aliases": {
"components": "{{package.name}}/components",
"utils": "{{package.name}}/lib/utils",
"hooks": "{{package.name}}/hooks",
"lib": "{{package.name}}/lib",
"ui": "{{package.name}}/components"
},
"rtl": false,
"menuColor": "default",
"menuAccent": "subtle"
}
@@ -0,0 +1,58 @@
import { Button as ButtonPrimitive } from "@base-ui/react/button"
import { cva, type VariantProps } from "class-variance-authority"
import { cn } from "{{package.name}}/lib/utils"
const buttonVariants = cva(
"group/button inline-flex shrink-0 items-center justify-center rounded-lg border border-transparent bg-clip-padding text-sm font-medium whitespace-nowrap transition-all outline-none select-none focus-visible:border-ring focus-visible:ring-3 focus-visible:ring-ring/50 active:not-aria-[haspopup]:translate-y-px disabled:pointer-events-none disabled:opacity-50 aria-invalid:border-destructive aria-invalid:ring-3 aria-invalid:ring-destructive/20 dark:aria-invalid:border-destructive/50 dark:aria-invalid:ring-destructive/40 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4",
{
variants: {
variant: {
default: "bg-primary text-primary-foreground [a]:hover:bg-primary/80",
outline:
"border-border bg-background hover:bg-muted hover:text-foreground aria-expanded:bg-muted aria-expanded:text-foreground dark:border-input dark:bg-input/30 dark:hover:bg-input/50",
secondary:
"bg-secondary text-secondary-foreground hover:bg-secondary/80 aria-expanded:bg-secondary aria-expanded:text-secondary-foreground",
ghost:
"hover:bg-muted hover:text-foreground aria-expanded:bg-muted aria-expanded:text-foreground dark:hover:bg-muted/50",
destructive:
"bg-destructive/10 text-destructive hover:bg-destructive/20 focus-visible:border-destructive/40 focus-visible:ring-destructive/20 dark:bg-destructive/20 dark:hover:bg-destructive/30 dark:focus-visible:ring-destructive/40",
link: "text-primary underline-offset-4 hover:underline",
},
size: {
default:
"h-8 gap-1.5 px-2.5 has-data-[icon=inline-end]:pr-2 has-data-[icon=inline-start]:pl-2",
xs: "h-6 gap-1 rounded-[min(var(--radius-md),10px)] px-2 text-xs in-data-[slot=button-group]:rounded-lg has-data-[icon=inline-end]:pr-1.5 has-data-[icon=inline-start]:pl-1.5 [&_svg:not([class*='size-'])]:size-3",
sm: "h-7 gap-1 rounded-[min(var(--radius-md),12px)] px-2.5 text-[0.8rem] in-data-[slot=button-group]:rounded-lg has-data-[icon=inline-end]:pr-1.5 has-data-[icon=inline-start]:pl-1.5 [&_svg:not([class*='size-'])]:size-3.5",
lg: "h-9 gap-1.5 px-2.5 has-data-[icon=inline-end]:pr-2 has-data-[icon=inline-start]:pl-2",
icon: "size-8",
"icon-xs":
"size-6 rounded-[min(var(--radius-md),10px)] in-data-[slot=button-group]:rounded-lg [&_svg:not([class*='size-'])]:size-3",
"icon-sm":
"size-7 rounded-[min(var(--radius-md),12px)] in-data-[slot=button-group]:rounded-lg",
"icon-lg": "size-9",
},
},
defaultVariants: {
variant: "default",
size: "default",
},
}
)
function Button({
className,
variant = "default",
size = "default",
...props
}: ButtonPrimitive.Props & VariantProps<typeof buttonVariants>) {
return (
<ButtonPrimitive
data-slot="button"
className={cn(buttonVariants({ variant, size, className }))}
{...props}
/>
)
}
export { Button, buttonVariants }
@@ -0,0 +1,6 @@
import { clsx, type ClassValue } from "clsx"
import { twMerge } from "tailwind-merge"
export function cn(...inputs: ClassValue[]) {
return twMerge(clsx(inputs))
}
@@ -0,0 +1,23 @@
{
"$schema": "https://ui.shadcn.com/schema.json",
"style": "base-nova",
"rsc": false,
"tsx": true,
"tailwind": {
"config": "",
"css": "../../packages/ui/src/styles/globals.css",
"baseColor": "neutral",
"cssVariables": true
},
"iconLibrary": "lucide",
"aliases": {
"components": "@/components",
"hooks": "@/hooks",
"lib": "@/lib",
"utils": "{{package.name}}/lib/utils",
"ui": "{{package.name}}/components"
},
"rtl": false,
"menuColor": "default",
"menuAccent": "subtle"
}
@@ -0,0 +1,91 @@
import { createContext, useContext, useEffect, useState } from "react";
import { ScriptOnce } from "@tanstack/react-router";
type Theme = "dark" | "light" | "system";
type ThemeProviderProps = {
children: React.ReactNode;
defaultTheme?: Theme;
storageKey?: string;
};
type ThemeProviderState = {
theme: Theme;
setTheme: (theme: Theme) => void;
};
function getThemeScript(storageKey: string, defaultTheme: Theme) {
const key = JSON.stringify(storageKey);
const fallback = JSON.stringify(defaultTheme);
return `(function(){try{var t=localStorage.getItem(${key});if(t!=='light'&&t!=='dark'&&t!=='system'){t=${fallback}}var d=matchMedia('(prefers-color-scheme: dark)').matches;var r=t==='system'?(d?'dark':'light'):t;var e=document.documentElement;e.classList.add(r);e.style.colorScheme=r}catch(e){}})();`;
}
const ThemeProviderContext = createContext<ThemeProviderState>({
theme: "system",
setTheme: () => {},
});
function applyTheme(theme: Theme) {
const root = document.documentElement;
root.classList.remove("light", "dark");
const resolved =
theme === "system"
? window.matchMedia("(prefers-color-scheme: dark)").matches
? "dark"
: "light"
: theme;
root.classList.add(resolved);
root.style.colorScheme = resolved;
}
export function ThemeProvider({
children,
defaultTheme = "system",
storageKey = "theme",
}: ThemeProviderProps) {
const [theme, setThemeState] = useState<Theme>(defaultTheme);
const [mounted, setMounted] = useState(false);
useEffect(() => {
const stored = localStorage.getItem(storageKey);
setThemeState(
stored === "light" || stored === "dark" || stored === "system" ? stored : defaultTheme,
);
setMounted(true);
}, [defaultTheme, storageKey]);
useEffect(() => {
if (!mounted) return;
applyTheme(theme);
}, [theme, mounted]);
useEffect(() => {
if (!mounted || theme !== "system") return;
const media = window.matchMedia("(prefers-color-scheme: dark)");
const onChange = () => applyTheme("system");
media.addEventListener("change", onChange);
return () => media.removeEventListener("change", onChange);
}, [theme, mounted]);
const setTheme = (next: Theme) => {
localStorage.setItem(storageKey, next);
setThemeState(next);
};
return (
<ThemeProviderContext value={{ theme, setTheme }}>
<ScriptOnce>{getThemeScript(storageKey, defaultTheme)}</ScriptOnce>
{children}
</ThemeProviderContext>
);
}
export function useTheme() {
const context = useContext(ThemeProviderContext);
if (context === undefined) throw new Error("useTheme must be used within a ThemeProvider");
return context;
}
@@ -0,0 +1,130 @@
@import "tailwindcss";
@import "tw-animate-css";
@import "shadcn/tailwind.css";
@import "@fontsource-variable/inter";
@custom-variant dark (&:is(.dark *));
@source "../../../apps/**/*.{ts,tsx}";
@source "../../../components/**/*.{ts,tsx}";
@source "../**/*.{ts,tsx}";
@theme inline {
--font-heading: var(--font-sans);
--color-sidebar-ring: var(--sidebar-ring);
--color-sidebar-border: var(--sidebar-border);
--color-sidebar-accent-foreground: var(--sidebar-accent-foreground);
--color-sidebar-accent: var(--sidebar-accent);
--color-sidebar-primary-foreground: var(--sidebar-primary-foreground);
--color-sidebar-primary: var(--sidebar-primary);
--color-sidebar-foreground: var(--sidebar-foreground);
--color-sidebar: var(--sidebar);
--color-chart-5: var(--chart-5);
--color-chart-4: var(--chart-4);
--color-chart-3: var(--chart-3);
--color-chart-2: var(--chart-2);
--color-chart-1: var(--chart-1);
--color-ring: var(--ring);
--color-input: var(--input);
--color-border: var(--border);
--color-destructive: var(--destructive);
--color-accent-foreground: var(--accent-foreground);
--color-accent: var(--accent);
--color-muted-foreground: var(--muted-foreground);
--color-muted: var(--muted);
--color-secondary-foreground: var(--secondary-foreground);
--color-secondary: var(--secondary);
--color-primary-foreground: var(--primary-foreground);
--color-primary: var(--primary);
--color-popover-foreground: var(--popover-foreground);
--color-popover: var(--popover);
--color-card-foreground: var(--card-foreground);
--color-card: var(--card);
--color-foreground: var(--foreground);
--color-background: var(--background);
--radius-sm: calc(var(--radius) * 0.6);
--radius-md: calc(var(--radius) * 0.8);
--radius-lg: var(--radius);
--radius-xl: calc(var(--radius) * 1.4);
--radius-2xl: calc(var(--radius) * 1.8);
--radius-3xl: calc(var(--radius) * 2.2);
--radius-4xl: calc(var(--radius) * 2.6);
--font-sans: 'Inter Variable', sans-serif;
}
:root {
--background: oklch(1 0 0);
--foreground: oklch(0.145 0 0);
--card: oklch(1 0 0);
--card-foreground: oklch(0.145 0 0);
--popover: oklch(1 0 0);
--popover-foreground: oklch(0.145 0 0);
--primary: oklch(0.205 0 0);
--primary-foreground: oklch(0.985 0 0);
--secondary: oklch(0.97 0 0);
--secondary-foreground: oklch(0.205 0 0);
--muted: oklch(0.97 0 0);
--muted-foreground: oklch(0.556 0 0);
--accent: oklch(0.97 0 0);
--accent-foreground: oklch(0.205 0 0);
--destructive: oklch(0.577 0.245 27.325);
--border: oklch(0.922 0 0);
--input: oklch(0.922 0 0);
--ring: oklch(0.708 0 0);
--chart-1: oklch(0.87 0 0);
--chart-2: oklch(0.556 0 0);
--chart-3: oklch(0.439 0 0);
--chart-4: oklch(0.371 0 0);
--chart-5: oklch(0.269 0 0);
--radius: 0.625rem;
--sidebar: oklch(0.985 0 0);
--sidebar-foreground: oklch(0.145 0 0);
--sidebar-primary: oklch(0.205 0 0);
--sidebar-primary-foreground: oklch(0.985 0 0);
--sidebar-accent: oklch(0.97 0 0);
--sidebar-accent-foreground: oklch(0.205 0 0);
--sidebar-border: oklch(0.922 0 0);
--sidebar-ring: oklch(0.708 0 0);
}
.dark {
--background: oklch(0.145 0 0);
--foreground: oklch(0.985 0 0);
--card: oklch(0.205 0 0);
--card-foreground: oklch(0.985 0 0);
--popover: oklch(0.205 0 0);
--popover-foreground: oklch(0.985 0 0);
--primary: oklch(0.922 0 0);
--primary-foreground: oklch(0.205 0 0);
--secondary: oklch(0.269 0 0);
--secondary-foreground: oklch(0.985 0 0);
--muted: oklch(0.269 0 0);
--muted-foreground: oklch(0.708 0 0);
--accent: oklch(0.269 0 0);
--accent-foreground: oklch(0.985 0 0);
--destructive: oklch(0.704 0.191 22.216);
--border: oklch(1 0 0 / 10%);
--input: oklch(1 0 0 / 15%);
--ring: oklch(0.556 0 0);
--chart-1: oklch(0.87 0 0);
--chart-2: oklch(0.556 0 0);
--chart-3: oklch(0.439 0 0);
--chart-4: oklch(0.371 0 0);
--chart-5: oklch(0.269 0 0);
--sidebar: oklch(0.205 0 0);
--sidebar-foreground: oklch(0.985 0 0);
--sidebar-primary: oklch(0.488 0.243 264.376);
--sidebar-primary-foreground: oklch(0.985 0 0);
--sidebar-accent: oklch(0.269 0 0);
--sidebar-accent-foreground: oklch(0.985 0 0);
--sidebar-border: oklch(1 0 0 / 10%);
--sidebar-ring: oklch(0.556 0 0);
}
@layer base {
* {
@apply border-border outline-ring/50;
}
body {
@apply bg-background text-foreground;
}
}
@@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" fill="#ffff" style="margin-right:3px" viewBox="4 0 17 25"><path d="M12 25a8 8 0 1 1 0-16v16zM12 0H4v8h8V0zM17 8a4 4 0 1 0 0-8 4 4 0 0 0 0 8z" fill="#ffff"/></svg>

After

Width:  |  Height:  |  Size: 202 B

@@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" fill="#121212" style="margin-right:3px" viewBox="4 0 17 25"><path d="M12 25a8 8 0 1 1 0-16v16zM12 0H4v8h8V0zM17 8a4 4 0 1 0 0-8 4 4 0 0 0 0 8z" fill="#121212"/></svg>

After

Width:  |  Height:  |  Size: 206 B

+230
View File
@@ -0,0 +1,230 @@
import { defineModule } from "@stanza/registry";
export default defineModule({
id: "shadcn-radix",
category: "ui",
label: "shadcn (Radix)",
description:
"shadcn/ui registry preset `radix-nova` — Tailwind + Radix primitives in packages/ui.",
version: "0.1.0",
peers: { framework: ["next", "tanstack-start"] },
homepage: "https://ui.shadcn.com",
// Everything shadcn pulls in regardless of framework — kept at module level so
// the two adapters only carry the per-framework deltas.
dependencies: {
"class-variance-authority": "^0.7.1",
clsx: "^2.1.1",
"lucide-react": "^1.16.0",
"radix-ui": "^1.4.3",
shadcn: "^4.8.0",
"tailwind-merge": "^3.6.0",
"tw-animate-css": "^1.4.0",
zod: "^3.25.76",
},
devDependencies: { tailwindcss: "^4.1.18" },
adapters: [
{
key: "next",
match: { framework: "next" },
// next-themes powers shadcn's <ThemeProvider> on Next — class strategy +
// localStorage + system-pref subscription, all out of the box.
dependencies: { "next-themes": "^0.4.6" },
devDependencies: { "@tailwindcss/postcss": "^4.1.18" },
templates: [
// packages/ui/ — shared shadcn surface.
{
src: "shared/package/src/lib/utils.ts",
dest: "src/lib/utils.ts",
scope: "package",
},
{
src: "shared/package/src/components/button.tsx",
dest: "src/components/button.tsx",
scope: "package",
template: true,
},
{
src: "shared/package/components.json",
dest: "components.json",
scope: "package",
template: true,
},
// Next-specific: the oklch theme tokens + postcss plugin config.
{
src: "next/package/src/styles/globals.css",
dest: "src/styles/globals.css",
scope: "package",
},
{
src: "next/package/postcss.config.mjs",
dest: "postcss.config.mjs",
scope: "package",
},
// apps/<id>/ — outer shadcn config + ThemeProvider + postcss re-export.
{
src: "next/app/components.json",
dest: "components.json",
scope: "app",
template: true,
},
{
src: "next/app/postcss.config.mjs",
dest: "postcss.config.mjs",
scope: "app",
template: true,
},
{
src: "next/app/components/theme-provider.tsx",
dest: "components/theme-provider.tsx",
scope: "app",
},
],
codemods: [
// Swap the framework's `import "./globals.css"` for the shadcn-managed
// bundle in packages/ui (oklch tokens + tw-animate-css + the shadcn
// tailwind base). Done as an in-place replace (not append + comment)
// because the framework's CSS file becomes orphaned otherwise.
{
id: "replace-import",
args: {
file: "app/layout.tsx",
from: "./globals.css",
to: "{{package.name}}/globals.css",
},
},
// `next-themes` toggles the `dark`/`light` class on <html>; without
// suppressHydrationWarning React complains on the first paint
// because the server-rendered class != the client-resolved one.
{
id: "set-html-attributes",
args: {
file: "app/layout.tsx",
attributes: [{ name: "suppressHydrationWarning", boolean: true }],
},
},
// Wrap `{children}` with <ThemeProvider>.
{
id: "wrap-root-layout",
args: {
providerName: "ThemeProvider",
providerImport: "@/components/theme-provider",
importKind: "named",
},
},
// IDE-only: pnpm's isolated linker + the package's subpath exports
// are enough at runtime, but shadcn's CLI (when adding more
// components) reads tsconfig.paths to know where ui/* lives.
{
id: "set-tsconfig-paths",
args: {
paths: { "{{package.name}}/*": ["../../packages/ui/src/*"] },
},
},
],
},
{
key: "tanstack-start",
match: { framework: "tanstack-start" },
// TSS has no next/font; pull Inter via @fontsource so the shadcn
// `--font-sans` token resolves to a real font.
dependencies: { "@fontsource-variable/inter": "^5.2.8" },
devDependencies: { "@tailwindcss/vite": "^4.1.18" },
templates: [
// packages/ui/ — shared shadcn surface.
{
src: "shared/package/src/lib/utils.ts",
dest: "src/lib/utils.ts",
scope: "package",
},
{
src: "shared/package/src/components/button.tsx",
dest: "src/components/button.tsx",
scope: "package",
template: true,
},
{
src: "shared/package/components.json",
dest: "components.json",
scope: "package",
template: true,
},
// Start-specific globals.css — adds the @fontsource Inter import.
{
src: "tanstack-start/package/src/styles/globals.css",
dest: "src/styles/globals.css",
scope: "package",
},
// apps/<id>/ — outer shadcn config + the TSS-native ThemeProvider
// (ScriptOnce-based, per https://ui.shadcn.com/docs/dark-mode/tanstack-start —
// next-themes is Next-only).
{
src: "tanstack-start/app/components.json",
dest: "components.json",
scope: "app",
template: true,
},
{
src: "tanstack-start/app/components/theme-provider.tsx",
dest: "components/theme-provider.tsx",
scope: "app",
},
],
codemods: [
// Splice tailwindcss() into the framework's vite.config.ts plugin array.
{
id: "add-plugin-to-call",
args: {
file: "vite.config.ts",
callee: "defineConfig",
property: "plugins",
call: "tailwindcss()",
imports: [{ from: "@tailwindcss/vite", default: "tailwindcss" }],
position: "before:react",
},
},
// TSS's idiomatic CSS wiring: import the stylesheet `?url` and
// emit it as a `<link rel="stylesheet">` via the route's
// `head: () => ({ links: [...] })`. The codemod creates the head
// arrow + links array when absent.
{
id: "add-array-entry-in-call",
args: {
file: "src/routes/__root.tsx",
callee: "createRootRoute",
property: "head().links",
entry: '{ rel: "stylesheet", href: appCss }',
imports: [{ from: "{{package.name}}/globals.css?url", default: "appCss" }],
},
},
// Hand-rolled ThemeProvider toggles `dark`/`light` on <html>;
// suppressHydrationWarning silences the SSR/CSR class mismatch.
{
id: "set-html-attributes",
args: {
file: "src/routes/__root.tsx",
attributes: [{ name: "suppressHydrationWarning", boolean: true }],
},
},
// Wrap <Outlet /> with <ThemeProvider>. ThemeProvider defaults
// (defaultTheme="system", storageKey="theme") match shadcn's
// dark-mode-docs guidance, so no extra prop wiring is needed.
{
id: "wrap-root-layout",
args: {
providerName: "ThemeProvider",
providerImport: "@/components/theme-provider",
importKind: "named",
},
},
// IDE-only: matches shadcn's app tsconfig paths so its CLI can add
// more components without manual path config.
{
id: "set-tsconfig-paths",
args: {
paths: { "{{package.name}}/*": ["../../packages/ui/src/*"] },
},
},
],
},
],
});
@@ -0,0 +1,12 @@
{
"name": "@stanza-modules/ui-shadcn-radix",
"version": "0.1.0",
"private": true,
"license": "MIT",
"type": "module",
"main": "./module.ts",
"dependencies": {
"@stanza/codemods": "workspace:*",
"@stanza/registry": "workspace:*"
}
}
@@ -0,0 +1,23 @@
{
"$schema": "https://ui.shadcn.com/schema.json",
"style": "radix-nova",
"rsc": true,
"tsx": true,
"tailwind": {
"config": "",
"css": "../../packages/ui/src/styles/globals.css",
"baseColor": "neutral",
"cssVariables": true
},
"iconLibrary": "lucide",
"aliases": {
"components": "@/components",
"hooks": "@/hooks",
"lib": "@/lib",
"utils": "{{package.name}}/lib/utils",
"ui": "{{package.name}}/components"
},
"rtl": false,
"menuColor": "default",
"menuAccent": "subtle"
}
@@ -0,0 +1,71 @@
"use client"
import * as React from "react"
import { ThemeProvider as NextThemesProvider, useTheme } from "next-themes"
function ThemeProvider({
children,
...props
}: React.ComponentProps<typeof NextThemesProvider>) {
return (
<NextThemesProvider
attribute="class"
defaultTheme="system"
enableSystem
disableTransitionOnChange
{...props}
>
<ThemeHotkey />
{children}
</NextThemesProvider>
)
}
function isTypingTarget(target: EventTarget | null) {
if (!(target instanceof HTMLElement)) {
return false
}
return (
target.isContentEditable ||
target.tagName === "INPUT" ||
target.tagName === "TEXTAREA" ||
target.tagName === "SELECT"
)
}
function ThemeHotkey() {
const { resolvedTheme, setTheme } = useTheme()
React.useEffect(() => {
function onKeyDown(event: KeyboardEvent) {
if (event.defaultPrevented || event.repeat) {
return
}
if (event.metaKey || event.ctrlKey || event.altKey) {
return
}
if (event.key.toLowerCase() !== "d") {
return
}
if (isTypingTarget(event.target)) {
return
}
setTheme(resolvedTheme === "dark" ? "light" : "dark")
}
window.addEventListener("keydown", onKeyDown)
return () => {
window.removeEventListener("keydown", onKeyDown)
}
}, [resolvedTheme, setTheme])
return null
}
export { ThemeProvider }
@@ -0,0 +1 @@
export { default } from "{{package.name}}/postcss.config";
@@ -0,0 +1,6 @@
/** @type {import('postcss-load-config').Config} */
const config = {
plugins: { "@tailwindcss/postcss": {} },
};
export default config;
@@ -0,0 +1,129 @@
@import "tailwindcss";
@import "tw-animate-css";
@import "shadcn/tailwind.css";
@custom-variant dark (&:is(.dark *));
@source "../../../apps/**/*.{ts,tsx}";
@source "../../../components/**/*.{ts,tsx}";
@source "../**/*.{ts,tsx}";
@theme inline {
--font-heading: var(--font-sans);
--color-sidebar-ring: var(--sidebar-ring);
--color-sidebar-border: var(--sidebar-border);
--color-sidebar-accent-foreground: var(--sidebar-accent-foreground);
--color-sidebar-accent: var(--sidebar-accent);
--color-sidebar-primary-foreground: var(--sidebar-primary-foreground);
--color-sidebar-primary: var(--sidebar-primary);
--color-sidebar-foreground: var(--sidebar-foreground);
--color-sidebar: var(--sidebar);
--color-chart-5: var(--chart-5);
--color-chart-4: var(--chart-4);
--color-chart-3: var(--chart-3);
--color-chart-2: var(--chart-2);
--color-chart-1: var(--chart-1);
--color-ring: var(--ring);
--color-input: var(--input);
--color-border: var(--border);
--color-destructive: var(--destructive);
--color-accent-foreground: var(--accent-foreground);
--color-accent: var(--accent);
--color-muted-foreground: var(--muted-foreground);
--color-muted: var(--muted);
--color-secondary-foreground: var(--secondary-foreground);
--color-secondary: var(--secondary);
--color-primary-foreground: var(--primary-foreground);
--color-primary: var(--primary);
--color-popover-foreground: var(--popover-foreground);
--color-popover: var(--popover);
--color-card-foreground: var(--card-foreground);
--color-card: var(--card);
--color-foreground: var(--foreground);
--color-background: var(--background);
--radius-sm: calc(var(--radius) * 0.6);
--radius-md: calc(var(--radius) * 0.8);
--radius-lg: var(--radius);
--radius-xl: calc(var(--radius) * 1.4);
--radius-2xl: calc(var(--radius) * 1.8);
--radius-3xl: calc(var(--radius) * 2.2);
--radius-4xl: calc(var(--radius) * 2.6);
--font-sans: var(--font-sans);
}
:root {
--background: oklch(1 0 0);
--foreground: oklch(0.145 0 0);
--card: oklch(1 0 0);
--card-foreground: oklch(0.145 0 0);
--popover: oklch(1 0 0);
--popover-foreground: oklch(0.145 0 0);
--primary: oklch(0.205 0 0);
--primary-foreground: oklch(0.985 0 0);
--secondary: oklch(0.97 0 0);
--secondary-foreground: oklch(0.205 0 0);
--muted: oklch(0.97 0 0);
--muted-foreground: oklch(0.556 0 0);
--accent: oklch(0.97 0 0);
--accent-foreground: oklch(0.205 0 0);
--destructive: oklch(0.577 0.245 27.325);
--border: oklch(0.922 0 0);
--input: oklch(0.922 0 0);
--ring: oklch(0.708 0 0);
--chart-1: oklch(0.87 0 0);
--chart-2: oklch(0.556 0 0);
--chart-3: oklch(0.439 0 0);
--chart-4: oklch(0.371 0 0);
--chart-5: oklch(0.269 0 0);
--radius: 0.625rem;
--sidebar: oklch(0.985 0 0);
--sidebar-foreground: oklch(0.145 0 0);
--sidebar-primary: oklch(0.205 0 0);
--sidebar-primary-foreground: oklch(0.985 0 0);
--sidebar-accent: oklch(0.97 0 0);
--sidebar-accent-foreground: oklch(0.205 0 0);
--sidebar-border: oklch(0.922 0 0);
--sidebar-ring: oklch(0.708 0 0);
}
.dark {
--background: oklch(0.145 0 0);
--foreground: oklch(0.985 0 0);
--card: oklch(0.205 0 0);
--card-foreground: oklch(0.985 0 0);
--popover: oklch(0.205 0 0);
--popover-foreground: oklch(0.985 0 0);
--primary: oklch(0.922 0 0);
--primary-foreground: oklch(0.205 0 0);
--secondary: oklch(0.269 0 0);
--secondary-foreground: oklch(0.985 0 0);
--muted: oklch(0.269 0 0);
--muted-foreground: oklch(0.708 0 0);
--accent: oklch(0.269 0 0);
--accent-foreground: oklch(0.985 0 0);
--destructive: oklch(0.704 0.191 22.216);
--border: oklch(1 0 0 / 10%);
--input: oklch(1 0 0 / 15%);
--ring: oklch(0.556 0 0);
--chart-1: oklch(0.87 0 0);
--chart-2: oklch(0.556 0 0);
--chart-3: oklch(0.439 0 0);
--chart-4: oklch(0.371 0 0);
--chart-5: oklch(0.269 0 0);
--sidebar: oklch(0.205 0 0);
--sidebar-foreground: oklch(0.985 0 0);
--sidebar-primary: oklch(0.488 0.243 264.376);
--sidebar-primary-foreground: oklch(0.985 0 0);
--sidebar-accent: oklch(0.269 0 0);
--sidebar-accent-foreground: oklch(0.985 0 0);
--sidebar-border: oklch(1 0 0 / 10%);
--sidebar-ring: oklch(0.556 0 0);
}
@layer base {
* {
@apply border-border outline-ring/50;
}
body {
@apply bg-background text-foreground;
}
}
@@ -0,0 +1,23 @@
{
"$schema": "https://ui.shadcn.com/schema.json",
"style": "radix-nova",
"rsc": {{#if (eq peers.framework "next")}}true{{else}}false{{/if}},
"tsx": true,
"tailwind": {
"config": "",
"css": "src/styles/globals.css",
"baseColor": "neutral",
"cssVariables": true
},
"iconLibrary": "lucide",
"aliases": {
"components": "{{package.name}}/components",
"utils": "{{package.name}}/lib/utils",
"hooks": "{{package.name}}/hooks",
"lib": "{{package.name}}/lib",
"ui": "{{package.name}}/components"
},
"rtl": false,
"menuColor": "default",
"menuAccent": "subtle"
}
@@ -0,0 +1,67 @@
import * as React from "react"
import { cva, type VariantProps } from "class-variance-authority"
import { Slot } from "radix-ui"
import { cn } from "{{package.name}}/lib/utils"
const buttonVariants = cva(
"group/button inline-flex shrink-0 items-center justify-center rounded-lg border border-transparent bg-clip-padding text-sm font-medium whitespace-nowrap transition-all outline-none select-none focus-visible:border-ring focus-visible:ring-3 focus-visible:ring-ring/50 active:not-aria-[haspopup]:translate-y-px disabled:pointer-events-none disabled:opacity-50 aria-invalid:border-destructive aria-invalid:ring-3 aria-invalid:ring-destructive/20 dark:aria-invalid:border-destructive/50 dark:aria-invalid:ring-destructive/40 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4",
{
variants: {
variant: {
default: "bg-primary text-primary-foreground [a]:hover:bg-primary/80",
outline:
"border-border bg-background hover:bg-muted hover:text-foreground aria-expanded:bg-muted aria-expanded:text-foreground dark:border-input dark:bg-input/30 dark:hover:bg-input/50",
secondary:
"bg-secondary text-secondary-foreground hover:bg-secondary/80 aria-expanded:bg-secondary aria-expanded:text-secondary-foreground",
ghost:
"hover:bg-muted hover:text-foreground aria-expanded:bg-muted aria-expanded:text-foreground dark:hover:bg-muted/50",
destructive:
"bg-destructive/10 text-destructive hover:bg-destructive/20 focus-visible:border-destructive/40 focus-visible:ring-destructive/20 dark:bg-destructive/20 dark:hover:bg-destructive/30 dark:focus-visible:ring-destructive/40",
link: "text-primary underline-offset-4 hover:underline",
},
size: {
default:
"h-8 gap-1.5 px-2.5 has-data-[icon=inline-end]:pr-2 has-data-[icon=inline-start]:pl-2",
xs: "h-6 gap-1 rounded-[min(var(--radius-md),10px)] px-2 text-xs in-data-[slot=button-group]:rounded-lg has-data-[icon=inline-end]:pr-1.5 has-data-[icon=inline-start]:pl-1.5 [&_svg:not([class*='size-'])]:size-3",
sm: "h-7 gap-1 rounded-[min(var(--radius-md),12px)] px-2.5 text-[0.8rem] in-data-[slot=button-group]:rounded-lg has-data-[icon=inline-end]:pr-1.5 has-data-[icon=inline-start]:pl-1.5 [&_svg:not([class*='size-'])]:size-3.5",
lg: "h-9 gap-1.5 px-2.5 has-data-[icon=inline-end]:pr-2 has-data-[icon=inline-start]:pl-2",
icon: "size-8",
"icon-xs":
"size-6 rounded-[min(var(--radius-md),10px)] in-data-[slot=button-group]:rounded-lg [&_svg:not([class*='size-'])]:size-3",
"icon-sm":
"size-7 rounded-[min(var(--radius-md),12px)] in-data-[slot=button-group]:rounded-lg",
"icon-lg": "size-9",
},
},
defaultVariants: {
variant: "default",
size: "default",
},
}
)
function Button({
className,
variant = "default",
size = "default",
asChild = false,
...props
}: React.ComponentProps<"button"> &
VariantProps<typeof buttonVariants> & {
asChild?: boolean
}) {
const Comp = asChild ? Slot.Root : "button"
return (
<Comp
data-slot="button"
data-variant={variant}
data-size={size}
className={cn(buttonVariants({ variant, size, className }))}
{...props}
/>
)
}
export { Button, buttonVariants }
@@ -0,0 +1,6 @@
import { clsx, type ClassValue } from "clsx"
import { twMerge } from "tailwind-merge"
export function cn(...inputs: ClassValue[]) {
return twMerge(clsx(inputs))
}
@@ -0,0 +1,23 @@
{
"$schema": "https://ui.shadcn.com/schema.json",
"style": "radix-nova",
"rsc": false,
"tsx": true,
"tailwind": {
"config": "",
"css": "../../packages/ui/src/styles/globals.css",
"baseColor": "neutral",
"cssVariables": true
},
"iconLibrary": "lucide",
"aliases": {
"components": "@/components",
"hooks": "@/hooks",
"lib": "@/lib",
"utils": "{{package.name}}/lib/utils",
"ui": "{{package.name}}/components"
},
"rtl": false,
"menuColor": "default",
"menuAccent": "subtle"
}
@@ -0,0 +1,91 @@
import { createContext, useContext, useEffect, useState } from "react";
import { ScriptOnce } from "@tanstack/react-router";
type Theme = "dark" | "light" | "system";
type ThemeProviderProps = {
children: React.ReactNode;
defaultTheme?: Theme;
storageKey?: string;
};
type ThemeProviderState = {
theme: Theme;
setTheme: (theme: Theme) => void;
};
function getThemeScript(storageKey: string, defaultTheme: Theme) {
const key = JSON.stringify(storageKey);
const fallback = JSON.stringify(defaultTheme);
return `(function(){try{var t=localStorage.getItem(${key});if(t!=='light'&&t!=='dark'&&t!=='system'){t=${fallback}}var d=matchMedia('(prefers-color-scheme: dark)').matches;var r=t==='system'?(d?'dark':'light'):t;var e=document.documentElement;e.classList.add(r);e.style.colorScheme=r}catch(e){}})();`;
}
const ThemeProviderContext = createContext<ThemeProviderState>({
theme: "system",
setTheme: () => {},
});
function applyTheme(theme: Theme) {
const root = document.documentElement;
root.classList.remove("light", "dark");
const resolved =
theme === "system"
? window.matchMedia("(prefers-color-scheme: dark)").matches
? "dark"
: "light"
: theme;
root.classList.add(resolved);
root.style.colorScheme = resolved;
}
export function ThemeProvider({
children,
defaultTheme = "system",
storageKey = "theme",
}: ThemeProviderProps) {
const [theme, setThemeState] = useState<Theme>(defaultTheme);
const [mounted, setMounted] = useState(false);
useEffect(() => {
const stored = localStorage.getItem(storageKey);
setThemeState(
stored === "light" || stored === "dark" || stored === "system" ? stored : defaultTheme,
);
setMounted(true);
}, [defaultTheme, storageKey]);
useEffect(() => {
if (!mounted) return;
applyTheme(theme);
}, [theme, mounted]);
useEffect(() => {
if (!mounted || theme !== "system") return;
const media = window.matchMedia("(prefers-color-scheme: dark)");
const onChange = () => applyTheme("system");
media.addEventListener("change", onChange);
return () => media.removeEventListener("change", onChange);
}, [theme, mounted]);
const setTheme = (next: Theme) => {
localStorage.setItem(storageKey, next);
setThemeState(next);
};
return (
<ThemeProviderContext value={{ theme, setTheme }}>
<ScriptOnce>{getThemeScript(storageKey, defaultTheme)}</ScriptOnce>
{children}
</ThemeProviderContext>
);
}
export function useTheme() {
const context = useContext(ThemeProviderContext);
if (context === undefined) throw new Error("useTheme must be used within a ThemeProvider");
return context;
}
@@ -0,0 +1,130 @@
@import "tailwindcss";
@import "tw-animate-css";
@import "shadcn/tailwind.css";
@import "@fontsource-variable/inter";
@custom-variant dark (&:is(.dark *));
@source "../../../apps/**/*.{ts,tsx}";
@source "../../../components/**/*.{ts,tsx}";
@source "../**/*.{ts,tsx}";
@theme inline {
--font-heading: var(--font-sans);
--color-sidebar-ring: var(--sidebar-ring);
--color-sidebar-border: var(--sidebar-border);
--color-sidebar-accent-foreground: var(--sidebar-accent-foreground);
--color-sidebar-accent: var(--sidebar-accent);
--color-sidebar-primary-foreground: var(--sidebar-primary-foreground);
--color-sidebar-primary: var(--sidebar-primary);
--color-sidebar-foreground: var(--sidebar-foreground);
--color-sidebar: var(--sidebar);
--color-chart-5: var(--chart-5);
--color-chart-4: var(--chart-4);
--color-chart-3: var(--chart-3);
--color-chart-2: var(--chart-2);
--color-chart-1: var(--chart-1);
--color-ring: var(--ring);
--color-input: var(--input);
--color-border: var(--border);
--color-destructive: var(--destructive);
--color-accent-foreground: var(--accent-foreground);
--color-accent: var(--accent);
--color-muted-foreground: var(--muted-foreground);
--color-muted: var(--muted);
--color-secondary-foreground: var(--secondary-foreground);
--color-secondary: var(--secondary);
--color-primary-foreground: var(--primary-foreground);
--color-primary: var(--primary);
--color-popover-foreground: var(--popover-foreground);
--color-popover: var(--popover);
--color-card-foreground: var(--card-foreground);
--color-card: var(--card);
--color-foreground: var(--foreground);
--color-background: var(--background);
--radius-sm: calc(var(--radius) * 0.6);
--radius-md: calc(var(--radius) * 0.8);
--radius-lg: var(--radius);
--radius-xl: calc(var(--radius) * 1.4);
--radius-2xl: calc(var(--radius) * 1.8);
--radius-3xl: calc(var(--radius) * 2.2);
--radius-4xl: calc(var(--radius) * 2.6);
--font-sans: 'Inter Variable', sans-serif;
}
:root {
--background: oklch(1 0 0);
--foreground: oklch(0.145 0 0);
--card: oklch(1 0 0);
--card-foreground: oklch(0.145 0 0);
--popover: oklch(1 0 0);
--popover-foreground: oklch(0.145 0 0);
--primary: oklch(0.205 0 0);
--primary-foreground: oklch(0.985 0 0);
--secondary: oklch(0.97 0 0);
--secondary-foreground: oklch(0.205 0 0);
--muted: oklch(0.97 0 0);
--muted-foreground: oklch(0.556 0 0);
--accent: oklch(0.97 0 0);
--accent-foreground: oklch(0.205 0 0);
--destructive: oklch(0.577 0.245 27.325);
--border: oklch(0.922 0 0);
--input: oklch(0.922 0 0);
--ring: oklch(0.708 0 0);
--chart-1: oklch(0.87 0 0);
--chart-2: oklch(0.556 0 0);
--chart-3: oklch(0.439 0 0);
--chart-4: oklch(0.371 0 0);
--chart-5: oklch(0.269 0 0);
--radius: 0.625rem;
--sidebar: oklch(0.985 0 0);
--sidebar-foreground: oklch(0.145 0 0);
--sidebar-primary: oklch(0.205 0 0);
--sidebar-primary-foreground: oklch(0.985 0 0);
--sidebar-accent: oklch(0.97 0 0);
--sidebar-accent-foreground: oklch(0.205 0 0);
--sidebar-border: oklch(0.922 0 0);
--sidebar-ring: oklch(0.708 0 0);
}
.dark {
--background: oklch(0.145 0 0);
--foreground: oklch(0.985 0 0);
--card: oklch(0.205 0 0);
--card-foreground: oklch(0.985 0 0);
--popover: oklch(0.205 0 0);
--popover-foreground: oklch(0.985 0 0);
--primary: oklch(0.922 0 0);
--primary-foreground: oklch(0.205 0 0);
--secondary: oklch(0.269 0 0);
--secondary-foreground: oklch(0.985 0 0);
--muted: oklch(0.269 0 0);
--muted-foreground: oklch(0.708 0 0);
--accent: oklch(0.269 0 0);
--accent-foreground: oklch(0.985 0 0);
--destructive: oklch(0.704 0.191 22.216);
--border: oklch(1 0 0 / 10%);
--input: oklch(1 0 0 / 15%);
--ring: oklch(0.556 0 0);
--chart-1: oklch(0.87 0 0);
--chart-2: oklch(0.556 0 0);
--chart-3: oklch(0.439 0 0);
--chart-4: oklch(0.371 0 0);
--chart-5: oklch(0.269 0 0);
--sidebar: oklch(0.205 0 0);
--sidebar-foreground: oklch(0.985 0 0);
--sidebar-primary: oklch(0.488 0.243 264.376);
--sidebar-primary-foreground: oklch(0.985 0 0);
--sidebar-accent: oklch(0.269 0 0);
--sidebar-accent-foreground: oklch(0.985 0 0);
--sidebar-border: oklch(1 0 0 / 10%);
--sidebar-ring: oklch(0.556 0 0);
}
@layer base {
* {
@apply border-border outline-ring/50;
}
body {
@apply bg-background text-foreground;
}
}
@@ -0,0 +1,5 @@
{
"extends": "../../../tsconfig.json",
"include": ["module.ts"],
"exclude": ["templates"]
}
@@ -2,10 +2,10 @@ import { defineModule } from "@stanza/registry";
export default defineModule({
id: "tailwind",
category: "styling",
category: "ui",
label: "Tailwind CSS",
description: "Utility-first CSS framework. Pairs with any web framework.",
version: "0.1.0",
version: "0.2.0",
peers: { framework: ["next", "tanstack-start"] },
homepage: "https://tailwindcss.com",
// `tailwindcss` itself is shared; the per-framework integration dep varies.
@@ -18,7 +18,19 @@ export default defineModule({
"@tailwindcss/postcss": "^4.3.0",
postcss: "^8.5.15",
},
templates: [{ src: "next/postcss.config.mjs", dest: "postcss.config.mjs", scope: "app" }],
templates: [
// Real plugin config lives in `packages/ui/` alongside the deps so
// pnpm's isolated linker exposes `@tailwindcss/postcss` to it. The
// app's `postcss.config.mjs` just re-exports it via the workspace
// package's subpath export.
{ src: "package/postcss.config.mjs", dest: "postcss.config.mjs", scope: "package" },
{
src: "next/postcss.config.mjs",
dest: "postcss.config.mjs",
scope: "app",
template: true,
},
],
codemods: [
{
// framework-next ships its own `app/globals.css` with base styles.
+12
View File
@@ -0,0 +1,12 @@
{
"name": "@stanza-modules/ui-tailwind",
"version": "0.1.0",
"private": true,
"license": "MIT",
"type": "module",
"main": "./module.ts",
"dependencies": {
"@stanza/codemods": "workspace:*",
"@stanza/registry": "workspace:*"
}
}
@@ -0,0 +1 @@
export { default } from "{{package.name}}/postcss.config";
@@ -0,0 +1,5 @@
{
"extends": "../../../tsconfig.json",
"include": ["module.ts"],
"exclude": ["templates"]
}
+3 -3
View File
@@ -42,7 +42,7 @@ npx -y stanza-cli@latest search
```sh
npx -y stanza-cli@latest init my-app --yes \
--framework=next \
--styling=tailwind \
--ui=tailwind \
--db=postgres \
--orm=drizzle \
--auth=better-auth \
@@ -61,7 +61,7 @@ pnpm dev
## Commands
- `stanza init [name] --yes ...` scaffolds a new project in a child directory of the current working directory. Today every `init` produces a single web app (`apps/web`, id `web`); multi-app init is planned but not yet exposed.
- `stanza add <category> <module> [--app=<id>]` adds one module to an existing Stanza project. The `--app` flag picks which app receives an app-scoped module (`framework`/`styling`/`testing`/`email`) or which app a package-scoped module's shims (e.g. Better Auth's route handler) target. Single-app projects auto-target. Multi-app projects auto-pick if `cwd` is inside an app's `dir`; otherwise the CLI prompts interactively on a TTY or errors out in non-interactive runs.
- `stanza add <category> <module> [--app=<id>]` adds one module to an existing Stanza project. The `--app` flag picks which app receives an app-scoped module (`framework`/`testing`) or which app a package-scoped module's shims (e.g. Better Auth's route handler) target. Single-app projects auto-target. Multi-app projects auto-pick if `cwd` is inside an app's `dir`; otherwise the CLI prompts interactively on a TTY or errors out in non-interactive runs.
- `stanza remove <category> [id] [--app=<id>]` removes a module. For single-choice categories the `id` is optional; for multi-choice categories it is required. `--app` scopes removal in projects with multiple apps.
- `stanza list` prints installed modules grouped by category from the nearest `stanza.json`.
- `stanza search [query]` lists registry modules and their `category/id` pairs.
@@ -72,7 +72,7 @@ Run `add`, `remove`, and `list` from the project root or any child directory con
Every module belongs to exactly one **category**, and each category is either single-choice or multi-choice:
- **Single-choice** categories: `framework`, `styling`, `db`, `orm`, `auth`, `tooling`. Adding a module to a filled single-choice category fails until the existing one is removed.
- **Single-choice** categories: `framework`, `ui`, `db`, `orm`, `auth`, `tooling`. Adding a module to a filled single-choice category fails until the existing one is removed.
- **Multi-choice** categories: `testing` (with `deploy`, `email`, `monorepo` planned). Multiple modules coexist in one category.
For `init --yes`, pass each category's module ids as a comma-separated value; single-choice categories take exactly one, multi-choice take several — for example `--framework=next` and `--testing=vitest,playwright`.