mirror of
https://github.com/jakejarvis/stanza.git
synced 2026-10-16 06:15:43 -04:00
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:
68 files changed
+3610
-312
No files matched your search
@@ -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
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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();
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@@ -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
@@ -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 },
|
||||||
|
|||||||
@@ -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.
|
|
||||||
@@ -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.
|
|
||||||
@@ -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.
|
|
||||||
@@ -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.
|
|
||||||
@@ -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
|
||||||
|
|
||||||
|
|||||||
@@ -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` | 🟢 Available | Drizzle ORM 0.45. Peers on `db` (postgres, sqlite). |
|
| `drizzle` | 🟢 Available | Drizzle ORM 0.45. Peers on `db` (postgres, sqlite). |
|
||||||
| `prisma` | 🟢 Available | Prisma 7. Peers on `db` (postgres, sqlite). |
|
| `prisma` | 🟢 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 | 🟢 Available | Vanilla Tailwind v4, adapters per web framework. |
|
| `tailwind` | web | 🟢 Available | Bare Tailwind v4 in `packages/ui/`; apps re-export postcss config. |
|
||||||
| `shadcn-radix` | web | 🟨 Planned | Classic shadcn/ui (Radix primitives). |
|
| `shadcn-radix` | web | 🟢 Available | shadcn/ui registry preset `radix-nova` (Radix primitives) + Tailwind. |
|
||||||
| `shadcn-base` | web | 🟨 Planned | shadcn on Base UI. |
|
| `shadcn-base` | web | 🟢 Available | shadcn/ui registry preset `base-nova` (Base UI) + Tailwind. |
|
||||||
| `nativewind` | native | 🟨 Planned | Tailwind-style className API for React Native. |
|
| `nativewind` | native | 🟨 Planned | Tailwind-style className API for React Native. |
|
||||||
| `unistyles` | native | 🟨 Planned | Native StyleSheet with theme + breakpoint support. |
|
| `unistyles` | native | 🟨 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;
|
||||||
@@ -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: [] });
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -7,6 +7,8 @@ export type {
|
|||||||
ExportDeclaration,
|
ExportDeclaration,
|
||||||
ExportSpecifier,
|
ExportSpecifier,
|
||||||
ImportDeclaration,
|
ImportDeclaration,
|
||||||
|
JsxAttribute,
|
||||||
|
JsxOpeningElement,
|
||||||
Node,
|
Node,
|
||||||
Project,
|
Project,
|
||||||
SourceFile,
|
SourceFile,
|
||||||
|
|||||||
@@ -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",
|
||||||
|
|||||||
@@ -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" },
|
||||||
|
|||||||
@@ -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",
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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();
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
Generated
+112
-3
@@ -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 |
@@ -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
-1
@@ -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"
|
||||||
|
}
|
||||||
+91
@@ -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;
|
||||||
|
}
|
||||||
+130
@@ -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;
|
||||||
|
}
|
||||||
|
}
|
||||||
File renamed without changes.
@@ -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 |
@@ -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"
|
||||||
|
}
|
||||||
+91
@@ -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;
|
||||||
|
}
|
||||||
+130
@@ -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"]
|
||||||
|
}
|
||||||
File renamed without changes.
+15
-3
@@ -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.
|
||||||
@@ -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";
|
||||||
File renamed without changes.
@@ -0,0 +1,5 @@
|
|||||||
|
{
|
||||||
|
"extends": "../../../tsconfig.json",
|
||||||
|
"include": ["module.ts"],
|
||||||
|
"exclude": ["templates"]
|
||||||
|
}
|
||||||
@@ -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`.
|
||||||
|
|||||||
Reference in new issue
Block a user