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 # 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: 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. 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`. 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 - **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 - **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 - **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 - **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 - **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) - **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 - **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 - **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 - **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: - 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) - `"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 - `"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`) - `"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` 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 `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}}";`) - 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 - **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 - 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 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] > [!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. > 🚧 **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 codemods/ # ts-morph helpers for region-aware patching
create-stanza/ # `npm init stanza` template shim create-stanza/ # `npm init stanza` template shim
registry/ registry/
modules/ # first-party modules (framework, orm, db, auth, styling, tooling, testing) modules/ # first-party modules (framework, orm, db, auth, ui, tooling, testing)
``` ```
## License ## 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` 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"` - [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 - [ ] `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] `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; 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] 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 - [ ] Authoring guide: docs page covering `defineModule`, slot/peer/capability semantics, template vs. codemod choice, region ownership, and the `scope: "package"` + `consumesPackages` story
## Registry expansion ## 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. 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` - [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 - [ ] 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 - [ ] `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 - [ ] `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 ## 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` - [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 - [ ] 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 - [ ] 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 - [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", name: "app",
yes: true, yes: true,
framework: "next", framework: "next",
styling: "tailwind", ui: "tailwind",
db: "postgres", db: "postgres",
orm: "drizzle", orm: "drizzle",
auth: "better-auth", auth: "better-auth",
@@ -63,7 +63,7 @@ describe("cmdInit --yes", () => {
expect(fs.existsSync(path.join(projectRoot, "stanza.json"))).toBe(true); expect(fs.existsSync(path.join(projectRoot, "stanza.json"))).toBe(true);
const manifest = JSON.parse(fs.readFileSync(path.join(projectRoot, "stanza.json"), "utf8")); const manifest = JSON.parse(fs.readFileSync(path.join(projectRoot, "stanza.json"), "utf8"));
expect(manifest.modules.framework[0].id).toBe("next"); 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.db[0].id).toBe("postgres");
expect(manifest.modules.orm[0].id).toBe("drizzle"); expect(manifest.modules.orm[0].id).toBe("drizzle");
expect(manifest.modules.auth[0].id).toBe("better-auth"); 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")); const manifest = JSON.parse(fs.readFileSync(path.join(tmp, "minimal", "stanza.json"), "utf8"));
expect(manifest.modules.framework?.[0]?.id).toBe("next"); expect(manifest.modules.framework?.[0]?.id).toBe("next");
expect(manifest.modules.styling).toBeUndefined(); expect(manifest.modules.ui).toBeUndefined();
expect(manifest.modules.auth).toBeUndefined(); expect(manifest.modules.auth).toBeUndefined();
}); });
+32 -25
View File
@@ -390,31 +390,32 @@ function ensureSlotPackage(args: {
if (!dryRun) { if (!dryRun) {
// Self-contained tsconfig — generated projects don't share a base, so // Self-contained tsconfig — generated projects don't share a base, so
// each app and package stands on its own. Mirrors the shape framework // 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( fs.writeFileSync(
tsconfigPath, tsconfigPath,
JSON.stringify( JSON.stringify({ compilerOptions, include: ["src"], exclude: ["node_modules"] }, null, 2) +
{ "\n",
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",
"utf8", "utf8",
); );
} }
@@ -473,13 +474,19 @@ function renderArgs(
if (Array.isArray(value)) return value.map(visit); if (Array.isArray(value)) return value.map(visit);
if (value && typeof value === "object") { if (value && typeof value === "object") {
const out: Record<string, JsonValue> = {}; 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 out;
} }
return value; return value;
}; };
const out: Record<string, JsonValue> = {}; 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; return out;
} }
+2 -2
View File
@@ -15,13 +15,13 @@ const main = defineCommand({
name: "stanza", name: "stanza",
version, version,
description: description:
"Modular monorepo template CLI — shadcn for stacks.\n\n" + "Modular monorepo scaffolding CLI.\n\n" +
"Examples\n" + "Examples\n" +
" stanza init my-app --yes --framework=next --orm=drizzle --db=postgres --testing=vitest,playwright\n" + " stanza init my-app --yes --framework=next --orm=drizzle --db=postgres --testing=vitest,playwright\n" +
" stanza add auth better-auth\n" + " stanza add auth better-auth\n" +
" stanza add testing vitest\n" + " stanza add testing vitest\n" +
" stanza remove testing vitest\n" + " stanza remove testing vitest\n" +
" stanza remove styling\n\n" + " stanza remove payments\n\n" +
"Docs: https://stanza.tools", "Docs: https://stanza.tools",
}, },
subCommands: { init, add, remove, list, search }, 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. 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 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.
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 ## SKILL.md
For Claude Code, Codex, and other agent runtimes that load skills, Stanza ships 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):
a self-contained skill bundle. Install it with the
[Skills CLI](https://skills.sh):
```sh ```sh
npx skills add https://github.com/jakejarvis/stanza --skill stanza-cli 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 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.
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 <Callout type="info">
[`skills/stanza-cli/`](https://github.com/jakejarvis/stanza/tree/main/skills/stanza-cli) The source lives at
if you'd rather vendor it directly. [`skills/stanza-cli/`](https://github.com/jakejarvis/stanza/tree/main/skills/stanza-cli) if you'd
rather vendor it directly.
</Callout>
## Agent guidance ## Agent guidance
A few rules on top of the [CLI surface](/docs/cli) keep agent runs predictable: 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 - **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.
with [`npx -y stanza-cli@latest search`](/docs/cli#search) before passing it - **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.
to `add`. Use the printed `category/id` value, not the display label. - **Preview with [`--dry-run`](/docs/cli#global-flags)** before mutating commands when you want to show the user what's about to happen.
- **Prefer `npx` for automation.** `npx -y stanza-cli@latest <verb>` avoids the - **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.
package-manager argument-forwarding ambiguity that `pnpm create` / `npm - **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.
create` / `bun create` introduce. - **`stanza.json` is generated state.** Don't hand-edit it; use the verbs that maintain it (`add`, `remove`).
- **Preview with [`--dry-run`](/docs/cli#global-flags)** before mutating - **Generated files are user-owned source.** Once Stanza writes a template, edit it like any other file in the repo.
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 ## Common errors
@@ -63,8 +45,7 @@ create` / `bun create` introduce.
## Putting it together ## Putting it together
A minimal agent script that scaffolds a project, adds auth, and verifies A minimal agent script that scaffolds a project, adds auth, and verifies nothing else changed:
nothing else changed:
```sh ```sh
# 1. Confirm the modules exist. # 1. Confirm the modules exist.
@@ -73,7 +54,7 @@ npx -y stanza-cli@latest search
# 2. Scaffold. # 2. Scaffold.
npx -y stanza-cli@latest init my-app --yes \ npx -y stanza-cli@latest init my-app --yes \
--framework=tanstack-start \ --framework=tanstack-start \
--styling=tailwind \ --ui=tailwind \
--db=postgres \ --db=postgres \
--orm=drizzle \ --orm=drizzle \
--pm=pnpm --pm=pnpm
@@ -90,5 +71,4 @@ pnpm install
npx -y stanza-cli@latest list npx -y stanza-cli@latest list
``` ```
When reporting results back to the user, include the exact command run, whether When reporting results back to the user, include the exact command run, whether it wrote files, and any follow-up install needed.
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. description: The Stanza command-line verbs, flags, and environment variables.
--- ---
Stanza ships five verbs against the category taxonomy. Run any verb with Stanza ships five verbs against the category taxonomy. Run any verb with `--help` for its full flag list:
`--help` for its full flag list:
```sh ```sh
npx stanza-cli add --help npx stanza-cli add --help
@@ -12,8 +11,7 @@ npx stanza-cli add --help
## `init` ## `init`
Scaffold a new monorepo. Without flags it launches an interactive wizard; Scaffold a new monorepo. Without flags it launches an interactive wizard; with `--yes` it takes every pick from flags.
with `--yes` it takes every pick from flags.
```sh ```sh
npx stanza-cli init [name] --yes --framework=next --orm=drizzle --db=postgres --pm=pnpm 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). - `name` — project directory name (positional; prompted if omitted).
- `--yes` — non-interactive; take selections from category flags. - `--yes` — non-interactive; take selections from category flags.
- `--<category>=<ids>` — pick modules for a category. One flag per category - `--<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.
(`--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.
- `--pm=<pnpm|bun|npm>` — package manager recorded in the manifest. - `--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" }` `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).
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` ## `add`
@@ -41,17 +32,9 @@ Add one module to an existing project.
npx stanza-cli add <category> <module> [--app=<id>] npx stanza-cli add <category> <module> [--app=<id>]
``` ```
Resolves peers, selects the matching adapter, and writes the module's templates, 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).
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 - `--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.
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`. - The flag is meaningless for `home: "repo"` modules like `tooling`.
## `remove` ## `remove`
@@ -62,14 +45,9 @@ Remove a module and sweep the files (regions) it owns.
npx stanza-cli remove <category> [id] [--app=<id>] npx stanza-cli remove <category> [id] [--app=<id>]
``` ```
For single-choice categories the `id` is optional; for multi-choice categories 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.
(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>/`) 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.
strips the `workspace:*` dep from _every_ app's `package.json`, not just the
first one.
## `list` ## `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`): These apply to the mutating verbs (`init`, `add`, `remove`):
- `--dry-run` — print the actions that would be taken and write nothing. - `--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 - `--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.
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. - `--no-telemetry` — disable anonymous usage events for this invocation.
## Environment variables ## Environment variables
- `STANZA_REGISTRY=<url-or-path>` — use a custom or self-hosted registry (HTTP - `STANZA_REGISTRY=<url-or-path>` — use a custom or self-hosted registry (HTTP URL or filesystem path) instead of the default.
URL or filesystem path) instead of the default. - `STANZA_NO_NPM_LOOKUP=1` — skip npm version lookups and write dependency ranges verbatim.
- `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_NPM_REGISTRY=<url>` — override the npm registry used for version lookups.
- `STANZA_TELEMETRY=0` / `DO_NOT_TRACK=1` — disable telemetry persistently. - `STANZA_TELEMETRY=0` / `DO_NOT_TRACK=1` — disable telemetry persistently. Telemetry is also auto-skipped in CI.
Telemetry is also auto-skipped in CI.
## Dependency versioning ## Dependency versioning
On `init` and `add`, Stanza bumps each `^`/`~` dependency range to the latest npm 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.
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. 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 A handful of ideas explain how Stanza decides what to write and where. Once they click, the CLI's behavior is predictable.
click, the CLI's behavior is predictable.
## Apps ## Apps
A Stanza project contains one or more **apps** — each a buildable thing under 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:
`apps/<dir>/` with its own framework, styling, and tests. An app is declared in
`stanza.json` as:
```json ```json
{ "id": "web", "dir": "apps/web", "kind": "web" } { "id": "web", "dir": "apps/web", "kind": "web" }
``` ```
- **`id`** — short, stable handle. Doubles as the workspace-package suffix - **`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.
(`@<your-app>/web`) and as the value you pass to `--app=<id>` in the CLI.
- **`dir`** — repo-relative directory the app lives in. - **`dir`** — repo-relative directory the app lives in.
- **`kind`** — `web` or `native`. Lets Stanza reject obviously-wrong installs - **`kind`** — `web` or `native`. Lets Stanza reject obviously-wrong installs (e.g. installing Next.js into a `kind: "native"` app).
(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" }`). `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.
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 ## Categories
Every module belongs to exactly one **category**. A category has two independent Every module belongs to exactly one **category**. A category has two independent properties that govern how its modules behave:
properties that govern how its modules behave:
- **Cardinality** — `one` (single-choice) or `many` (coexisting). - **Cardinality** — `one` (single-choice) or `many` (coexisting).
- **Home** — where a module's output lands: `app`, `repo`, or `package`. - **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 | | Category | Cardinality | Home |
| ----------- | ----------- | ------- | | ----------- | ----------- | ------- |
| `framework` | one | app | | `framework` | one | app |
| `styling` | one | app | | `ui` | one | package |
| `db` | one | package | | `db` | one | package |
| `orm` | one | package | | `orm` | one | package |
| `auth` | one | package | | `auth` | one | package |
| `tooling` | one | repo | | `tooling` | one | repo |
| `testing` | many | app | | `testing` | many | app |
A `one` category holds at most one module — adding a second fails until you 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).
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: **Home** decides placement:
- `app` modules wire a specific app's shell (in `apps/web/`, say). They install - `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.
into the app(s) named in the module record's `apps` field.
- `repo` modules write config at the monorepo root. - `repo` modules write config at the monorepo root.
- `package` modules install into their own internal workspace package under - `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.
`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"> <Callout type="info">
**`framework` is one category, not many.** A module's `appKind` (`web` or `native`) pins it to a **`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 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 the category per platform would force every peer-aware category (ui, testing, …) to split too,
too, which would be a worse design. See the [registry](/docs/registry) for the roadmap. which would be a worse design. See the [registry](/docs/registry) for the roadmap.
</Callout> </Callout>
<Callout type="info"> <Callout type="info">
@@ -79,29 +60,17 @@ side).
## Vendoring ## Vendoring
Stanza copies a module's code into your repo verbatim. There's no shared runtime 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.
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 ## Peers and adapters
Modules declare **peers** — the other categories they care about. When you `add` 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.
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 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.
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.
## The manifest (`stanza.json`) ## The manifest (`stanza.json`)
The `stanza.json` file at your repo root is the source of truth for what's The `stanza.json` file at your repo root is the source of truth for what's installed:
installed:
```json ```json
{ {
@@ -125,32 +94,18 @@ installed:
} }
``` ```
`add`, `remove`, and `list` all read it. Treat it as generated state: don't `add`, `remove`, and `list` all read it. Treat it as generated state: don't hand-edit it unless you're repairing something deliberately.
hand-edit it unless you're repairing something deliberately.
A module record's `apps` field tells Stanza which app(s) the install targets: A module record's `apps` field tells Stanza which app(s) the install targets:
- **Required** for `home: "app"` modules (framework, styling, testing). One - **Required** for `home: "app"` modules (framework, testing). One record per app.
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>/`.
- **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. - **Forbidden** for `home: "repo"` modules — they're project-wide.
## Regions ## Regions
A **region** is a claim on a file (or a section of one) by a specific module. 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.
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 ## Open registry
A registry is just static JSON: an index plus one file per module, each carrying 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.
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 ## Prerequisites
- **Node.js** 20 or newer. - **Node.js** 20 or newer.
- A package manager — **pnpm** (recommended), **npm**, or **bun**. Stanza writes - A package manager — **pnpm** (recommended), **npm**, or **bun**. Stanza writes a workspace-based monorepo, so a workspace-aware package manager is required.
a workspace-based monorepo, so a workspace-aware package manager is required.
## Create a project ## Create a project
The create command launches an interactive wizard that walks you through each The create command launches an interactive wizard that walks you through each category:
category:
```sh ```sh
pnpm create stanza my-app pnpm create stanza my-app
@@ -27,17 +25,11 @@ npm create stanza@latest -- my-app
bun create stanza@latest my-app bun create stanza@latest my-app
``` ```
To run it without the wizard — in CI, for example — see To run it without the wizard — in CI, for example — see [Non-interactive setup](/docs/getting-started#non-interactive-setup) below.
[Non-interactive setup](#non-interactive-setup) below.
## What you get ## What you get
Stanza generates a clean monorepo: your app under `apps/web/` (one of 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.
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 ## Install and run
@@ -49,19 +41,13 @@ pnpm dev
## Add a module ## Add a module
Every category — `framework`, `styling`, `db`, `orm`, `auth`, `tooling`, Every category — `framework`, `ui`, `db`, `orm`, `auth`, `tooling`, `testing` — is one command away:
`testing` — is one command away:
```sh ```sh
npx stanza-cli add auth better-auth npx stanza-cli add auth better-auth
``` ```
Stanza resolves peer constraints, picks the adapter that matches your stack, and 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:
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 ```sh
pnpm install 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 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 `stanza search` prints each module's `category/id` pair — use the **id**, not the display label, when passing it to `add`.
display label, when passing it to `add`.
## Non-interactive setup ## Non-interactive setup
Pass `--yes` and select each category explicitly with a flag. Single-choice 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:
categories take one id; multi-choice categories (like `testing`) take a
comma-separated list:
```sh ```sh
npx -y stanza-cli@latest init my-app --yes \ npx -y stanza-cli@latest init my-app --yes \
--framework=next \ --framework=next \
--styling=tailwind \ --ui=tailwind \
--db=postgres \ --db=postgres \
--orm=drizzle \ --orm=drizzle \
--auth=better-auth \ --auth=better-auth \
@@ -95,5 +78,4 @@ npx -y stanza-cli@latest init my-app --yes \
--pm=pnpm --pm=pnpm
``` ```
`--yes` never fills in defaults for categories you omit — any category without a `--yes` never fills in defaults for categories you omit — any category without a flag is simply skipped.
flag is simply skipped.
+5 -19
View File
@@ -3,32 +3,18 @@ title: Introduction
description: Assemble modular full-stack TypeScript monorepos with Stanza. description: Assemble modular full-stack TypeScript monorepos with Stanza.
--- ---
Stanza is a shadcn-style CLI for assembling modular full-stack TypeScript 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.
monorepos. Pick a framework, styling, database, ORM, and auth — Stanza vendors
idiomatic code directly into your repo, with no runtime dependency to carry
around.
## Why Stanza ## Why Stanza
- **`add` works after `init`.** Run `stanza add` on an existing project. It reads - **`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
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. different code than adding it to a TanStack Start + Prisma app.
- **Your code, vendored.** Generated files land in your repo verbatim. There's no - **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.
`@stanza/runtime` package to install, and nothing to upgrade out from under - **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.
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 ## How it fits together
You choose modules across a small set of **categories** — `framework`, `styling`, 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.
`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.
## Next steps ## 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. 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 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).
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 ## Categories
A category has two independent properties: its **cardinality** — `one` 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.
(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 | | Category | Cardinality | Home | What it covers |
| ----------- | ----------- | ------- | ---------------------------------------------------------- | | ----------- | ----------- | ------- | ----------------------------------------------------------- |
| `framework` | one | app | App shell + router (Next, TanStack Start, Expo, …) | | `framework` | one | app | App shell + router (Next, TanStack Start, Expo, …) |
| `api` | one | package | Typed RPC layer between framework and services | | `api` | one | package | Typed RPC layer between framework and services |
| `ai` | one | package | AI SDK + provider wiring | | `ai` | one | package | AI SDK + provider wiring |
| `auth` | one | package | Authentication (Better Auth, Clerk, …) | | `auth` | one | package | Authentication (Better Auth, Clerk, …) |
| `db` | one | package | Database driver (Postgres, SQLite) | | `db` | one | package | Database driver (Postgres, SQLite) |
| `orm` | one | package | Schema + query layer (Drizzle, Prisma) | | `orm` | one | package | Schema + query layer (Drizzle, Prisma) |
| `styling` | one | app | CSS toolchain (Tailwind for web, NativeWind for native, …) | | `ui` | one | package | Styling system + component primitives (Tailwind, shadcn, …) |
| `payments` | one | package | Checkout + webhooks (Stripe, Polar, …) | | `payments` | one | package | Checkout + webhooks (Stripe, Polar, …) |
| `email` | one | package | Transactional email (Resend) | | `email` | one | package | Transactional email (Resend) |
| `tooling` | one | repo | Lint/format toolchain | | `tooling` | one | repo | Lint/format toolchain |
| `testing` | many | app | Unit + e2e (Vitest, Playwright) | | `testing` | many | app | Unit + e2e (Vitest, Playwright) |
| `deploy` | many | repo | Deploy targets (Vercel, Cloudflare, …) | | `deploy` | many | repo | Deploy targets (Vercel, Cloudflare, …) |
| `monorepo` | many | repo | Monorepo task runner (Turborepo) | | `monorepo` | many | repo | Monorepo task runner (Turborepo) |
Single-choice categories with `home: "app"` (currently `framework`, `styling`) 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).
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 ## Framework
Single-choice per app — the chosen framework wires the app shell, router, and 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.
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 | | Module | `appKind` | Status | Notes |
| ---------------- | --------- | ----------------- | ---------------------------------------------------------------------------------------- | | ---------------- | --------- | ----------------- | ---------------------------------------------------------------------------------------- |
@@ -54,8 +42,7 @@ one. A multi-app project can pick one framework per app.
## API ## API
Single-choice, constraint-bearing. An optional layer between the framework and Single-choice, constraint-bearing. An optional layer between the framework and the database or services, installed as its own workspace package.
the database or services, installed as its own workspace package.
| Module | Status | Notes | | 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). | | `drizzle` | 🟢&nbsp;Available | Drizzle ORM 0.45. Peers on `db` (postgres, sqlite). |
| `prisma` | 🟢&nbsp;Available | Prisma 7. 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 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, …).
the same `tailwind` module ships PostCSS config for Next, a Vite plugin for
TanStack Start, etc.
| Module | For | Status | Notes | | Module | For | Status | Notes |
| -------------- | ------ | ----------------- | -------------------------------------------------- | | -------------- | ------ | ----------------- | --------------------------------------------------------------------- |
| `tailwind` | web | 🟢&nbsp;Available | Vanilla Tailwind v4, adapters per web framework. | | `tailwind` | web | 🟢&nbsp;Available | Bare Tailwind v4 in `packages/ui/`; apps re-export postcss config. |
| `shadcn-radix` | web | 🟨&nbsp;Planned | Classic shadcn/ui (Radix primitives). | | `shadcn-radix` | web | 🟢&nbsp;Available | shadcn/ui registry preset `radix-nova` (Radix primitives) + Tailwind. |
| `shadcn-base` | web | 🟨&nbsp;Planned | shadcn on Base UI. | | `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. | | `nativewind` | native | 🟨&nbsp;Planned | Tailwind-style className API for React Native. |
| `unistyles` | native | 🟨&nbsp;Planned | Native StyleSheet with theme + breakpoint support. | | `unistyles` | native | 🟨&nbsp;Planned | Native StyleSheet with theme + breakpoint support. |
## Payments ## Payments
Single-choice, constraint-bearing. Installs into `packages/payments/`, plus Single-choice, constraint-bearing. Installs into `packages/payments/`, plus a Better Auth plugin when auth is selected.
a Better Auth plugin when auth is selected.
| Module | Status | Notes | | Module | Status | Notes |
| -------- | ----------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | | -------- | ----------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
@@ -135,8 +119,7 @@ Single-choice, installs into `packages/email/`.
## Tooling ## Tooling
Single-choice, repo-scoped. The three toolchains are mutually exclusive Single-choice, repo-scoped. The three toolchains are mutually exclusive substitutes.
substitutes.
| Module | Status | Notes | | Module | Status | Notes |
| ----------------- | ----------------- | ------------------------------------------------------ | | ----------------- | ----------------- | ------------------------------------------------------ |
@@ -146,8 +129,7 @@ substitutes.
## Testing ## Testing
Coexisting, app-scoped. Vitest and Playwright are independent and routinely run Coexisting, app-scoped. Vitest and Playwright are independent and routinely run side by side.
side by side.
| Module | Status | Notes | | Module | Status | Notes |
| ------------ | ----------------- | ----------------------------------------------------------------- | | ------------ | ----------------- | ----------------------------------------------------------------- |
@@ -167,8 +149,7 @@ Coexisting, repo-scoped.
## Monorepo ## Monorepo
Coexisting, repo-scoped. Currently hardcoded as Turborepo; becomes a real Coexisting, repo-scoped. Currently hardcoded as Turborepo; becomes a real configurable category when a second option lands.
configurable category when a second option lands.
| Module | Status | Notes | | Module | Status | Notes |
| ----------- | ----------------- | ------------------------------------------------------------------ | | ----------- | ----------------- | ------------------------------------------------------------------ |
@@ -177,9 +158,7 @@ configurable category when a second option lands.
## Package manager ## Package manager
Not a category — a top-level field in `stanza.json` 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.
(`packageManager: "pnpm" | "bun" | "npm"`). The wizard prompts for it; codemods
only ever touch `package.json`, never lockfiles.
| Manager | Status | | 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. * subpath export.
*/ */
import type { Codemod } from "../index"; import type { Codemod } from "../index";
import addArrayEntryInCall from "./add-array-entry-in-call";
import addPackageDep from "./add-package-dep"; import addPackageDep from "./add-package-dep";
import addPluginToCall from "./add-plugin-to-call"; import addPluginToCall from "./add-plugin-to-call";
import appendToFile from "./append-to-file"; import appendToFile from "./append-to-file";
import reExport from "./re-export"; 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"; import wrapRootLayout from "./wrap-root-layout";
export const CODEMOD_CATALOG: Record<string, Codemod> = { export const CODEMOD_CATALOG: Record<string, Codemod> = {
[addArrayEntryInCall.id]: addArrayEntryInCall,
[addPackageDep.id]: addPackageDep, [addPackageDep.id]: addPackageDep,
[addPluginToCall.id]: addPluginToCall, [addPluginToCall.id]: addPluginToCall,
[appendToFile.id]: appendToFile, [appendToFile.id]: appendToFile,
[reExport.id]: reExport, [reExport.id]: reExport,
[replaceImport.id]: replaceImport,
[setHtmlAttributes.id]: setHtmlAttributes,
[setTsconfigPaths.id]: setTsconfigPaths,
[wrapRootLayout.id]: wrapRootLayout, [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 { AddPackageDepArgs } from "./add-package-dep";
export type { AddPluginToCallArgs } from "./add-plugin-to-call"; export type { AddPluginToCallArgs } from "./add-plugin-to-call";
export type { AppendToFileArgs } from "./append-to-file"; export type { AppendToFileArgs } from "./append-to-file";
export type { ReExportArgs } from "./re-export"; 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"; 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, ExportDeclaration,
ExportSpecifier, ExportSpecifier,
ImportDeclaration, ImportDeclaration,
JsxAttribute,
JsxOpeningElement,
Node, Node,
Project, Project,
SourceFile, SourceFile,
+4 -4
View File
@@ -46,11 +46,11 @@ export const CATEGORIES = [
home: { kind: "app" }, home: { kind: "app" },
}, },
{ {
id: "styling", id: "ui",
label: "Styling", label: "UI",
description: "CSS / styling system.", description: "Styling system + component primitives.",
cardinality: "one", cardinality: "one",
home: { kind: "app" }, home: { kind: "package", dir: "ui" },
}, },
{ {
id: "db", 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`. */ /** Slot-package `package.json` for `packages/<dir>/` — matches the CLI's `ensureSlotPackage`. */
export function slotPackageJsonBase(opts: { name: string; dir: string }): PackageJson { export function slotPackageJsonBase(opts: { name: string; dir: string }): PackageJson {
return { const base: PackageJson = {
name: `@${opts.name}/${opts.dir}`, name: `@${opts.name}/${opts.dir}`,
version: "0.0.0", version: "0.0.0",
private: true, private: true,
type: "module", type: "module",
};
const override = PACKAGE_EXPORTS[opts.dir];
if (override) return { ...base, ...override };
return {
...base,
main: "./src/index.ts", main: "./src/index.ts",
types: "./src/index.ts", types: "./src/index.ts",
exports: { ".": "./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", () => { it("falls back to a default (empty-match) adapter when no peers are required", () => {
const tailwind: Module = defineModule({ const tailwind: Module = defineModule({
id: "tailwind", id: "tailwind",
category: "styling", category: "ui",
label: "", label: "",
description: "", description: "",
version: "0.1.0", 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"` * Active peer ids from the manifest alone — only `cardinality: "one"`
* categories can be peers, so we iterate `PEER_CATEGORIES` and read the * categories can be peers, so we iterate `PEER_CATEGORIES` and read the
* single installed pick. When `targetAppId` is set, the lookup is scoped to * 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. * framework, not native's.
* *
* Exported so callers outside the resolver (the CLI runner and web * Exported so callers outside the resolver (the CLI runner and web
+3 -3
View File
@@ -115,9 +115,9 @@ describe("buildRenderContext", () => {
peers: { framework: "next" }, peers: { framework: "next" },
}); });
expect(ctx.peers.framework).toBe("next"); expect(ctx.peers.framework).toBe("next");
// styling is a PEER_CATEGORY but not set — the key exists, value is undefined. // ui is a PEER_CATEGORY but not set — the key exists, value is undefined.
expect("styling" in ctx.peers).toBe(true); expect("ui" in ctx.peers).toBe(true);
expect(ctx.peers.styling).toBeUndefined(); 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)' 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: vite-plus:
specifier: 'catalog:' 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: vitest:
specifier: npm:@voidzero-dev/vite-plus-test@^0.1.22 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: web-vitals:
specifier: ^5.2.0 specifier: ^5.2.0
version: 5.2.0 version: 5.2.0
@@ -403,7 +403,25 @@ importers:
specifier: workspace:* specifier: workspace:*
version: link:../../../packages/registry 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: dependencies:
'@stanza/codemods': '@stanza/codemods':
specifier: workspace:* specifier: workspace:*
@@ -7102,6 +7120,48 @@ snapshots:
'@voidzero-dev/vite-plus-linux-x64-musl@0.1.22': '@voidzero-dev/vite-plus-linux-x64-musl@0.1.22':
optional: true 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))': '@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: dependencies:
'@standard-schema/spec': 1.1.0 '@standard-schema/spec': 1.1.0
@@ -9957,6 +10017,55 @@ snapshots:
'@types/unist': 3.0.3 '@types/unist': 3.0.3
vfile-message: 4.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)): 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: dependencies:
'@oxc-project/types': 0.129.0 '@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", "version": "0.1.0",
"private": true, "private": true,
"license": "MIT", "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({ export default defineModule({
id: "tailwind", id: "tailwind",
category: "styling", category: "ui",
label: "Tailwind CSS", label: "Tailwind CSS",
description: "Utility-first CSS framework. Pairs with any web framework.", description: "Utility-first CSS framework. Pairs with any web framework.",
version: "0.1.0", version: "0.2.0",
peers: { framework: ["next", "tanstack-start"] }, peers: { framework: ["next", "tanstack-start"] },
homepage: "https://tailwindcss.com", homepage: "https://tailwindcss.com",
// `tailwindcss` itself is shared; the per-framework integration dep varies. // `tailwindcss` itself is shared; the per-framework integration dep varies.
@@ -18,7 +18,19 @@ export default defineModule({
"@tailwindcss/postcss": "^4.3.0", "@tailwindcss/postcss": "^4.3.0",
postcss: "^8.5.15", 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: [ codemods: [
{ {
// framework-next ships its own `app/globals.css` with base styles. // 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 ```sh
npx -y stanza-cli@latest init my-app --yes \ npx -y stanza-cli@latest init my-app --yes \
--framework=next \ --framework=next \
--styling=tailwind \ --ui=tailwind \
--db=postgres \ --db=postgres \
--orm=drizzle \ --orm=drizzle \
--auth=better-auth \ --auth=better-auth \
@@ -61,7 +61,7 @@ pnpm dev
## Commands ## 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 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 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 list` prints installed modules grouped by category from the nearest `stanza.json`.
- `stanza search [query]` lists registry modules and their `category/id` pairs. - `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: 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. - **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`. 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`.