mirror of
https://github.com/jakejarvis/stanza.git
synced 2026-07-16 18:05:58 -04:00
chore: migrate to vite+
This commit is contained in:
@@ -17,45 +17,27 @@ jobs:
|
|||||||
steps:
|
steps:
|
||||||
- uses: actions/checkout@v6
|
- uses: actions/checkout@v6
|
||||||
|
|
||||||
- name: Setup pnpm
|
- uses: voidzero-dev/setup-vp@v1
|
||||||
uses: pnpm/action-setup@v6
|
|
||||||
with:
|
|
||||||
version: 10
|
|
||||||
|
|
||||||
- name: Setup Node.js
|
|
||||||
uses: actions/setup-node@v6
|
|
||||||
with:
|
with:
|
||||||
node-version: 24
|
node-version: 24
|
||||||
cache: pnpm
|
cache: true
|
||||||
|
|
||||||
- name: Setup Bun
|
- name: Setup Bun
|
||||||
uses: oven-sh/setup-bun@v2
|
uses: oven-sh/setup-bun@v2
|
||||||
with:
|
with:
|
||||||
bun-version: 1.3.14
|
bun-version: 1.3.14
|
||||||
|
|
||||||
- name: Install dependencies
|
- name: Build registry
|
||||||
run: pnpm install --frozen-lockfile
|
run: vp run registry:build
|
||||||
|
|
||||||
- name: Lint
|
- name: Lint
|
||||||
run: pnpm lint
|
run: vp check
|
||||||
|
|
||||||
- name: Format check
|
|
||||||
run: pnpm fmt:check
|
|
||||||
|
|
||||||
- name: Build registry
|
|
||||||
run: pnpm registry:build
|
|
||||||
|
|
||||||
- name: Build web app
|
|
||||||
run: pnpm --filter @stanza/web build
|
|
||||||
|
|
||||||
- name: Typecheck
|
|
||||||
run: pnpm check-types
|
|
||||||
|
|
||||||
- name: Tests
|
- name: Tests
|
||||||
run: pnpm test
|
run: vp test
|
||||||
|
|
||||||
- name: Build CLI bundle
|
- name: Build
|
||||||
run: pnpm --filter stanza-cli build
|
run: vp run build
|
||||||
|
|
||||||
- name: Smoke-test built CLI
|
- name: Smoke-test built CLI
|
||||||
run: |
|
run: |
|
||||||
|
|||||||
@@ -24,39 +24,32 @@ jobs:
|
|||||||
# Changesets needs full history to compute the changelog.
|
# Changesets needs full history to compute the changelog.
|
||||||
fetch-depth: 0
|
fetch-depth: 0
|
||||||
|
|
||||||
- name: Setup pnpm
|
- uses: voidzero-dev/setup-vp@v1
|
||||||
uses: pnpm/action-setup@v6
|
|
||||||
with:
|
|
||||||
version: 10
|
|
||||||
|
|
||||||
- name: Setup Node.js
|
|
||||||
uses: actions/setup-node@v6
|
|
||||||
with:
|
with:
|
||||||
node-version: 24
|
node-version: 24
|
||||||
cache: pnpm
|
cache: true
|
||||||
|
|
||||||
- name: Setup Bun
|
- name: Setup Bun
|
||||||
uses: oven-sh/setup-bun@v2
|
uses: oven-sh/setup-bun@v2
|
||||||
|
|
||||||
- name: Install dependencies
|
|
||||||
run: pnpm install --frozen-lockfile
|
|
||||||
|
|
||||||
- name: Build registry
|
- name: Build registry
|
||||||
run: pnpm registry:build
|
run: vp run registry:build
|
||||||
|
|
||||||
- name: Lint + typecheck + test
|
- name: Lint
|
||||||
run: |
|
run: vp check
|
||||||
pnpm lint
|
|
||||||
pnpm fmt:check
|
- name: Tests
|
||||||
pnpm check-types
|
run: vp test
|
||||||
pnpm test
|
|
||||||
|
- name: Build
|
||||||
|
run: vp run build:cli
|
||||||
|
|
||||||
- name: Create release PR or publish
|
- name: Create release PR or publish
|
||||||
id: changesets
|
id: changesets
|
||||||
uses: changesets/action@v1
|
uses: changesets/action@v1
|
||||||
with:
|
with:
|
||||||
version: pnpm changeset version
|
version: vp exec changeset version
|
||||||
publish: pnpm release
|
publish: vp run release
|
||||||
title: "chore: release"
|
title: "chore: release"
|
||||||
commit: "chore: release"
|
commit: "chore: release"
|
||||||
env:
|
env:
|
||||||
|
|||||||
@@ -1,23 +0,0 @@
|
|||||||
{
|
|
||||||
"$schema": "./node_modules/oxfmt/configuration_schema.json",
|
|
||||||
"sortImports": {},
|
|
||||||
"sortTailwindcss": {
|
|
||||||
"stylesheet": "apps/web/src/styles.css",
|
|
||||||
"functions": ["clsx", "cn", "cva", "tw"],
|
|
||||||
"preserveDuplicates": false,
|
|
||||||
"preserveWhitespace": false
|
|
||||||
},
|
|
||||||
"sortPackageJson": true,
|
|
||||||
"ignorePatterns": [
|
|
||||||
"**/node_modules/**",
|
|
||||||
"**/dist/**",
|
|
||||||
"**/.output/**",
|
|
||||||
"**/.turbo/**",
|
|
||||||
"**/.vercel/**",
|
|
||||||
"**/routeTree.gen.ts",
|
|
||||||
"**/coverage/**",
|
|
||||||
"apps/web/public/registry/**",
|
|
||||||
"registry/modules/*/logo*.svg",
|
|
||||||
"registry/modules/*/templates/**"
|
|
||||||
]
|
|
||||||
}
|
|
||||||
@@ -1,53 +0,0 @@
|
|||||||
{
|
|
||||||
"$schema": "./node_modules/oxlint/configuration_schema.json",
|
|
||||||
"plugins": ["oxc", "eslint", "typescript", "unicorn", "import", "promise", "vitest"],
|
|
||||||
"categories": {
|
|
||||||
"correctness": "error",
|
|
||||||
"suspicious": "warn",
|
|
||||||
"perf": "warn"
|
|
||||||
},
|
|
||||||
"rules": {
|
|
||||||
"no-console": "off",
|
|
||||||
"no-empty": ["error", { "allowEmptyCatch": true }],
|
|
||||||
"no-await-in-loop": "off",
|
|
||||||
"unicorn/no-null": "off",
|
|
||||||
"unicorn/filename-case": "off",
|
|
||||||
"unicorn/no-array-reduce": "off"
|
|
||||||
},
|
|
||||||
"ignorePatterns": [
|
|
||||||
"**/node_modules/**",
|
|
||||||
"**/dist/**",
|
|
||||||
"**/.output/**",
|
|
||||||
"**/.turbo/**",
|
|
||||||
"**/.vercel/**",
|
|
||||||
"**/*.gen.ts",
|
|
||||||
"**/coverage/**",
|
|
||||||
"registry/modules/*/templates/**"
|
|
||||||
],
|
|
||||||
"overrides": [
|
|
||||||
{
|
|
||||||
"files": ["**/*.test.ts", "**/*.test.tsx", "**/tests/**"],
|
|
||||||
"rules": {
|
|
||||||
"typescript/no-explicit-any": "off"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"files": ["apps/web/**/*.tsx"],
|
|
||||||
"plugins": [
|
|
||||||
"oxc",
|
|
||||||
"eslint",
|
|
||||||
"typescript",
|
|
||||||
"react",
|
|
||||||
"react-perf",
|
|
||||||
"promise",
|
|
||||||
"jsx-a11y",
|
|
||||||
"unicorn",
|
|
||||||
"import",
|
|
||||||
"vitest"
|
|
||||||
],
|
|
||||||
"rules": {
|
|
||||||
"react/react-in-jsx-scope": "off"
|
|
||||||
}
|
|
||||||
}
|
|
||||||
]
|
|
||||||
}
|
|
||||||
Executable
+1
@@ -0,0 +1 @@
|
|||||||
|
vp staged
|
||||||
Vendored
+1
-6
@@ -1,8 +1,3 @@
|
|||||||
{
|
{
|
||||||
"recommendations": [
|
"recommendations": ["VoidZero.vite-plus-extension-pack"]
|
||||||
"bradlc.vscode-tailwindcss",
|
|
||||||
"oxc.oxc-vscode",
|
|
||||||
"vercel.turbo-vsc",
|
|
||||||
"vitest.explorer"
|
|
||||||
]
|
|
||||||
}
|
}
|
||||||
|
|||||||
Vendored
+2
-1
@@ -35,5 +35,6 @@
|
|||||||
},
|
},
|
||||||
"[css]": {
|
"[css]": {
|
||||||
"editor.defaultFormatter": "oxc.oxc-vscode"
|
"editor.defaultFormatter": "oxc.oxc-vscode"
|
||||||
}
|
},
|
||||||
|
"oxc.fmt.configPath": "./vite.config.ts"
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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 [REGISTRY.md](REGISTRY.md) 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`, `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.
|
||||||
|
|
||||||
Three things differentiate stanza from other scaffolders:
|
Three things differentiate stanza from other scaffolders:
|
||||||
|
|
||||||
@@ -10,7 +10,7 @@ Three things differentiate stanza from other scaffolders:
|
|||||||
|
|
||||||
## Layout
|
## Layout
|
||||||
|
|
||||||
- `apps/cli/` — `stanza-cli`, node entrypoint at `src/bin.ts` (run via tsx in dev, tsdown-built to `dist/bin.mjs` for publish)
|
- `apps/cli/` — `stanza-cli`, node entrypoint at `src/bin.ts` (run via tsx in dev, built to `dist/bin.mjs` for publish via `vp pack`)
|
||||||
- `apps/web/` — `@stanza/web`, TanStack Start visual builder (Vite-native, no Vinxi)
|
- `apps/web/` — `@stanza/web`, TanStack Start visual builder (Vite-native, no Vinxi)
|
||||||
- `packages/registry/` — shared schema, category/peer resolver, Zod manifest validator
|
- `packages/registry/` — shared schema, category/peer resolver, Zod manifest validator
|
||||||
- `packages/codemods/` — ts-morph helpers (idempotent + reversible)
|
- `packages/codemods/` — ts-morph helpers (idempotent + reversible)
|
||||||
@@ -20,19 +20,18 @@ Three things differentiate stanza from other scaffolders:
|
|||||||
|
|
||||||
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:*`; `db` + `orm` share `packages/db/`); `framework`/`styling`/`testing` (`home: app`) wire the app shell; `tooling` (`home: repo`) writes config at the repo root + scripts in the root `package.json`. `categoryHome(id)`, `PACKAGE_DIRS`, and `categoryLabel(id)` are all derived from the 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:*`; `db` + `orm` share `packages/db/`); `framework`/`styling`/`testing` (`home: app`) wire the app shell; `tooling` (`home: repo`) writes config at the repo root + scripts in the root `package.json`. `categoryHome(id)`, `PACKAGE_DIRS`, and `categoryLabel(id)` are all derived from the array.
|
||||||
|
|
||||||
## Commands
|
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`.
|
||||||
|
|
||||||
- `pnpm --filter stanza-cli dev -- <verb>` — run CLI directly via `tsx watch ./src/bin.ts`; no build step. Or `tsx apps/cli/src/bin.ts <verb>` for a one-shot run
|
- `tsx apps/cli/src/bin.ts <verb>` — run the CLI directly in dev (no build step; use `tsx watch` for a watch loop)
|
||||||
- `pnpm --filter stanza-cli build` — build the publishable CLI via tsdown (compiles to ESM JS at `apps/cli/dist/`, externalizes npm deps, inlines workspace packages). Same for `create-stanza`
|
- `vp run stanza-cli#build` — build the publishable CLI via `vp pack` (wraps tsdown; config in [`apps/cli/tsdown.config.ts`](apps/cli/tsdown.config.ts), referenced from `apps/cli/vite.config.ts`'s `pack` block — externalizes npm deps, inlines workspace packages). Same for `create-stanza`. `vp run -r build` builds everything (both CLIs + the web app)
|
||||||
- `pnpm registry:build` — regenerate `dist/registry/{index,modules/*}.json`. Uses bun for maintainer convenience (the script body is portable; `tsx scripts/registry-build.ts` works too)
|
- `vp run registry:build` — regenerate `dist/registry/{index,modules/*}.json` (script body is `bun scripts/registry-build.ts`; portable, `tsx scripts/registry-build.ts` works too)
|
||||||
- `pnpm --filter @stanza/web dev` — TanStack Start dev server. `prebuild` invokes [`apps/web/scripts/prepare-registry.sh`](apps/web/scripts/prepare-registry.sh) which copies the built `dist/registry/` into `apps/web/public/registry/`. Since `dist/registry/` is gitignored, the script builds it first when it's missing (e.g. on Vercel's clean checkout) — preferring `bun` locally and falling back to `pnpm exec tsx` on node-only deploy targets. The deployed site ships this directory as static assets, so CLI and web consume the same JSON. `public/registry/` is also registered as a Nitro `serverAssets` dir (vite.config.ts), so its contents are embedded into the server bundle at build time — SSR reads it via `useStorage("assets:registry")` (not the CDN), which is why it works on Vercel where `public/` is absent from the function fs
|
- `vp run @stanza/web#dev` — TanStack Start dev server. `prebuild` invokes [`apps/web/scripts/prepare-registry.sh`](apps/web/scripts/prepare-registry.sh) which copies the built `dist/registry/` into `apps/web/public/registry/`. Since `dist/registry/` is gitignored, the script builds it first when it's missing (e.g. on Vercel's clean checkout) — preferring `bun` locally and falling back to `pnpm exec tsx` on node-only deploy targets. The deployed site ships this directory as static assets, so CLI and web consume the same JSON. `public/registry/` is also registered as a Nitro `serverAssets` dir (vite.config.ts), so its contents are embedded into the server bundle at build time — SSR reads it via `useStorage("assets:registry")` (not the CDN), which is why it works on Vercel where `public/` is absent from the function fs
|
||||||
- `pnpm lint` / `pnpm lint:fix` — Oxlint across the whole repo (config: `.oxlintrc.json`)
|
- `vp check` — format + lint + type-check across the repo (oxfmt + oxlint + type-aware `tsgolint`; `lint.options.typeAware`/`typeCheck` are **on**). `vp check --fix` autofixes formatting + fixable lint. `vp lint` / `vp fmt` run those passes alone. **Prefer `vp check` for validation loops** — it replaces the old `pnpm lint`/`fmt:check`/`check-types` trio
|
||||||
- `pnpm fmt` / `pnpm fmt:check` — oxfmt across the whole repo (config: `.oxfmtrc.json`)
|
- `vp test` — run the workspace test suites once (Vitest 4 via `vite-plus/test`; selection via `test.projects` in `vite.config.ts`). **`apps/web` is intentionally excluded** from `projects` (a vite-plus alpha bug breaks its plugin-heavy SSR config under the workspace loader); its suite passes standalone via `cd apps/web && vp test`. Re-add it to `projects` once the upstream loader bug is fixed
|
||||||
- `pnpm test` / `pnpm check-types` — fan out to every workspace via turbo
|
- `src/routeTree.gen.ts` is generated by the web build (`vp run @stanza/web#build`) — run it before the first `vp check` if the file is missing
|
||||||
- `cd apps/web && node_modules/.bin/vite build` — generates `src/routeTree.gen.ts` (required before first typecheck)
|
|
||||||
- E2E smoke: seed `$TMPDIR/x` with `stanza.json` + `apps/web/package.json`, then `tsx apps/cli/src/bin.ts add <slot> <module>`
|
- E2E smoke: seed `$TMPDIR/x` with `stanza.json` + `apps/web/package.json`, then `tsx apps/cli/src/bin.ts add <slot> <module>`
|
||||||
- `pnpm changeset` — create a new changeset describing what changed (run after a substantive PR)
|
- `pnpm changeset` (or `vp exec changeset`) — create a new changeset describing what changed (run after a substantive PR)
|
||||||
- `pnpm release` — build CLI/create-stanza and publish to npm (only runs in CI; locally use `pnpm pack` to inspect tarballs)
|
- `vp run release` — build CLI/create-stanza and publish to npm (only runs in CI; locally use `vp pack` / `pnpm pack` to inspect tarballs)
|
||||||
|
|
||||||
## Browser Automation
|
## Browser Automation
|
||||||
|
|
||||||
@@ -52,9 +51,9 @@ Core workflow:
|
|||||||
## Toolchain invariants
|
## Toolchain invariants
|
||||||
|
|
||||||
- **Node-only at runtime.** The CLI source uses node APIs and is dev-run via `tsx`; the published binary is plain ESM JS (`#!/usr/bin/env node`). The only place bun appears is the shebang on root maintainer scripts (`scripts/*.ts`) for our own convenience — those scripts don't use any `Bun.*` APIs and run fine under tsx/node
|
- **Node-only at runtime.** The CLI source uses node APIs and is dev-run via `tsx`; the published binary is plain ESM JS (`#!/usr/bin/env node`). The only place bun appears is the shebang on root maintainer scripts (`scripts/*.ts`) for our own convenience — those scripts don't use any `Bun.*` APIs and run fine under tsx/node
|
||||||
- **Build pipeline**: `tsdown` compiles each publishable package to ESM JS in `dist/`. External npm deps are _not_ bundled (users install them via the normal dep chain); workspace deps are _inlined_ (we don't publish `@stanza/registry` and `@stanza/codemods` separately). Transitive runtime deps (`ts-morph`, `zod`) MUST be declared as direct `dependencies` of the publishable package or tsdown will inline them into the bundle
|
- **Build pipeline**: `vp pack` (which wraps `tsdown`) compiles each publishable package to ESM JS in `dist/`. The tsdown options live in each package's `tsdown.config.ts` and are passed through `vite.config.ts`'s `pack` block. External npm deps are _not_ bundled (users install them via the normal dep chain); workspace deps are _inlined_ (we don't publish `@stanza/registry` and `@stanza/codemods` separately). Transitive runtime deps (`ts-morph`, `zod`) MUST be declared as direct `dependencies` of the publishable package or it will inline them into the bundle
|
||||||
- **Per-workspace `dist/` paths**: `main`/`types` in the source `package.json` still point at `./src/` so other workspaces resolve `.ts` directly during dev. The published tarball overrides via `publishConfig` to point at `./dist/<x>.mjs`/`.d.mts`
|
- **Per-workspace `dist/` paths**: `main`/`types` in the source `package.json` still point at `./src/` so other workspaces resolve `.ts` directly during dev. The published tarball overrides via `publishConfig` to point at `./dist/<x>.mjs`/`.d.mts`
|
||||||
- **Publishing**: only `stanza-cli` and `create-stanza` ship to npm. `@stanza/codemods` + `@stanza/registry` are marked `private: true` (inlined into the CLI bundle by tsdown); `@stanza/web` is private (deployed as a Vercel site, not an npm package); the `registry/modules/*` packages are also private (they're registry data, not npm packages). Releases go through **Changesets**: drop a markdown file via `pnpm changeset`, push to main → the [release workflow](.github/workflows/release.yml) opens a "Version Packages" PR; merging that PR triggers the same workflow to run `pnpm release` (build CLI + create-stanza, then `changeset publish` to npm). Requires `NPM_TOKEN` in repo secrets; provenance attestations are emitted via `id-token: write` + `NPM_CONFIG_PROVENANCE=true`
|
- **Publishing**: only `stanza-cli` and `create-stanza` ship to npm. `@stanza/codemods` + `@stanza/registry` are marked `private: true` (inlined into the CLI bundle by tsdown); `@stanza/web` is private (deployed as a Vercel site, not an npm package); the `registry/modules/*` packages are also private (they're registry data, not npm packages). Releases go through **Changesets**: drop a markdown file via `pnpm changeset`, push to main → the [release workflow](.github/workflows/release.yml) opens a "Version Packages" PR; merging that PR triggers the same workflow to run `vp run release` (build CLI + create-stanza, then `changeset publish` to npm). Requires `NPM_TOKEN` in repo secrets; provenance attestations are emitted via `id-token: write` + `NPM_CONFIG_PROVENANCE=true`
|
||||||
- pnpm 10 + `node-linker: isolated` — each workspace MUST declare `@types/node` in its own devDeps and set `types: ["node"]` in tsconfig (auto-discovery doesn't reach into the isolated `node_modules/@types`)
|
- pnpm 10 + `node-linker: isolated` — each workspace MUST declare `@types/node` in its own devDeps and set `types: ["node"]` in tsconfig (auto-discovery doesn't reach into the isolated `node_modules/@types`)
|
||||||
- TypeScript 6 — `allowImportingTsExtensions: true` + `noEmit: true` is set in `tsconfig.json`; the CLI/create-stanza emit JS via tsdown, the registry/codemods packages stay source-only and never emit
|
- TypeScript 6 — `allowImportingTsExtensions: true` + `noEmit: true` is set in `tsconfig.json`; the CLI/create-stanza emit JS via tsdown, the registry/codemods packages stay source-only and never emit
|
||||||
- `tsconfig.json` excludes `**/templates/**` globally — template files target user projects, not this repo
|
- `tsconfig.json` excludes `**/templates/**` globally — template files target user projects, not this repo
|
||||||
@@ -69,7 +68,7 @@ Core workflow:
|
|||||||
- **Registry is data; CLI is the runtime**: the per-module JSON ships templates (text), deps (strings), env (strings), scripts (strings), logos (SVG markup), and codemod **invocations** (`{ id, args }`). It does NOT ship codemod _code_. The catalog of generic codemods lives in [packages/codemods/src/builtins/](packages/codemods/src/builtins/) and is exposed via the `@stanza/codemods/builtins` subpath export — each codemod is parameterized by `TArgs` and reusable across modules (`wrap-root-layout` serves both Clerk and any future provider-style auth/state library). The catalog is statically imported into the CLI binary at build time, so distribution shape (single binary, pnpm-isolated, npm-hoisted, `npx`, `bun --compile`) doesn't matter — implementations always travel with the runtime
|
- **Registry is data; CLI is the runtime**: the per-module JSON ships templates (text), deps (strings), env (strings), scripts (strings), logos (SVG markup), and codemod **invocations** (`{ id, args }`). It does NOT ship codemod _code_. The catalog of generic codemods lives in [packages/codemods/src/builtins/](packages/codemods/src/builtins/) and is exposed via the `@stanza/codemods/builtins` subpath export — each codemod is parameterized by `TArgs` and reusable across modules (`wrap-root-layout` serves both Clerk and any future provider-style auth/state library). The catalog is statically imported into the CLI binary at build time, so distribution shape (single binary, pnpm-isolated, npm-hoisted, `npx`, `bun --compile`) doesn't matter — implementations always travel with the runtime
|
||||||
- **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 — verified by `vite build` followed by `grep shiki .output/public/assets/*.js` (should return nothing)
|
- **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 | 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)
|
||||||
- **Manifest stores selections in one `modules` record** keyed by `CategoryId`, each an array (`Partial<Record<CategoryId, StanzaModuleRecord[]>>`); `cardinality: "one"` categories are kept to ≤ 1 record by `add`/`init` rejecting a second pick (the runner write itself is uniform). Read via `selectedOne(m, cat)` / `selectedAll(m, cat)`. 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
|
- **Manifest stores selections in one `modules` record** keyed by `CategoryId`, each an array (`Partial<Record<CategoryId, StanzaModuleRecord[]>>`); `cardinality: "one"` categories are kept to ≤ 1 record by `add`/`init` rejecting a second pick (the runner write itself is uniform). Read via `selectedOne(m, cat)` / `selectedAll(m, cat)`. 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
|
||||||
- **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
|
||||||
@@ -104,5 +103,5 @@ Core workflow:
|
|||||||
- LSP diagnostics on template files (e.g. "Cannot find module 'react'") are noise; the global exclude works for `tsc` but tsserver still indexes them
|
- LSP diagnostics on template files (e.g. "Cannot find module 'react'") are noise; the global exclude works for `tsc` but tsserver still indexes them
|
||||||
- The dev registry is found by walking up from `import.meta.url` looking for `registry/modules/`; `STANZA_REGISTRY` env var overrides (FS path or HTTP URL)
|
- The dev registry is found by walking up from `import.meta.url` looking for `registry/modules/`; `STANZA_REGISTRY` env var overrides (FS path or HTTP URL)
|
||||||
- `pnpm install` says "Already up to date" when only workspace `package.json` files changed without lockfile-affecting bumps — use `pnpm install --force` to re-link
|
- `pnpm install` says "Already up to date" when only workspace `package.json` files changed without lockfile-affecting bumps — use `pnpm install --force` to re-link
|
||||||
- Oxlint has _most_ but not all ESLint plugin rules — `prevent-abbreviations`, `react/jsx-uses-react`, etc. don't exist. Check `node_modules/oxlint/configuration_schema.json` if a rule name fails
|
- Oxlint/oxfmt now run **through `vp`** (bundled in `vite-plus`); their config lives in the `lint`/`fmt` blocks of `vite.config.ts`, not standalone rc files. Oxlint has _most_ but not all ESLint plugin rules — `prevent-abbreviations`, `react/jsx-uses-react`, etc. don't exist; a bad rule name fails the lint run
|
||||||
- oxfmt auto-loads `.gitignore` and `.prettierignore`; we use `.oxfmtrc.json`'s `ignorePatterns` instead so we don't masquerade as a Prettier project
|
- `vp check` is type-aware (`lint.options.typeAware`/`typeCheck: true`), so it runs `tsgolint` type checks too. **`tsgolint` does not support `compilerOptions.baseUrl`** — if a tsconfig sets it, vite-plus silently skips type-aware checks. Genuinely-needed `as` assertions at untyped boundaries (CSS custom props in `React.CSSProperties`, `Object.fromEntries` → exhaustive `Record`, first-party SSR asset casts) carry a scoped `// oxlint-disable-next-line typescript/no-unsafe-type-assertion`; test files relax `no-unsafe-type-assertion`/`no-floating-promises` via the `vite.config.ts` test override
|
||||||
|
|||||||
@@ -3,11 +3,14 @@
|
|||||||
Modular monorepo template CLI — aka shadcn for full-stack TypeScript projects.
|
Modular monorepo template CLI — aka shadcn for full-stack TypeScript projects.
|
||||||
|
|
||||||
```sh
|
```sh
|
||||||
npm init stanza my-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 styling — get a clean monorepo with idiomatic code, vendored into your repo. Layer in more modules later with `stanza-cli add`.
|
||||||
|
|
||||||
|
> [!WARNING]
|
||||||
|
> 🚧 **Major work in progress!** See the [module registry](https://stanza.tools/docs/registry) for the module roadmap, and [TODO.md](./TODO.md) for active work.
|
||||||
|
|
||||||
## Why Stanza?
|
## Why Stanza?
|
||||||
|
|
||||||
- **`add` works after `init`.** Run `stanza add` on an existing project — it's manifest-driven and peer-aware, so it picks the right adapter for your stack and wires deps, env, and templates into the correct workspace package.
|
- **`add` works after `init`.** Run `stanza add` on an existing project — it's manifest-driven and peer-aware, so it picks the right adapter for your stack and wires deps, env, and templates into the correct workspace package.
|
||||||
@@ -44,15 +47,11 @@ apps/
|
|||||||
packages/
|
packages/
|
||||||
registry/ # shared schema, slot/peer/capability resolver
|
registry/ # shared schema, slot/peer/capability resolver
|
||||||
codemods/ # ts-morph helpers for region-aware patching
|
codemods/ # ts-morph helpers for region-aware patching
|
||||||
create-stanza/ # `pnpm create stanza` 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, styling, tooling, testing)
|
||||||
```
|
```
|
||||||
|
|
||||||
## Contributing
|
|
||||||
|
|
||||||
Work in progress. See [CLAUDE.md](./CLAUDE.md) for architecture and toolchain invariants, [REGISTRY.md](./REGISTRY.md) for the module roadmap, and [TODO.md](./TODO.md) for active work.
|
|
||||||
|
|
||||||
## License
|
## License
|
||||||
|
|
||||||
MIT — see [LICENSE](./LICENSE).
|
[MIT](./LICENSE)
|
||||||
|
|||||||
-145
@@ -1,145 +0,0 @@
|
|||||||
# First-party module registry
|
|
||||||
|
|
||||||
This is the canonical roadmap for the first-party modules stanza ships. Each
|
|
||||||
entry maps to a `registry/modules/<category>-<id>/` directory. Update this file
|
|
||||||
when a module lands, gets renamed, or is dropped.
|
|
||||||
|
|
||||||
Legend: `[x]` added · `[ ]` planned
|
|
||||||
|
|
||||||
## Categories
|
|
||||||
|
|
||||||
Every module fills exactly one **category**. A category has two independent,
|
|
||||||
explicit properties (`CATEGORIES` in [`module.ts`](packages/registry/src/module.ts)):
|
|
||||||
|
|
||||||
- **`cardinality`** — `"one"` (single-choice: framework, auth) or `"many"`
|
|
||||||
(coexisting: testing, deploy). This is the only thing that decides single- vs
|
|
||||||
multi-select in the wizard/web and length in the manifest.
|
|
||||||
- **`home`** — where the module's output lands: `app` (manifest.appDir), `repo`
|
|
||||||
(monorepo root), or `package` (`packages/<dir>/`).
|
|
||||||
|
|
||||||
Constraint-bearing is **emergent**, not a category property: a category is a
|
|
||||||
peer candidate only if some module declares `peers`/`match` against it, and the
|
|
||||||
resolver only treats `cardinality: "one"` categories as peers (`PEER_CATEGORIES`).
|
|
||||||
|
|
||||||
| Cardinality | Categories | Examples |
|
|
||||||
| ------------------ | ------------------------------------------------------------------------------------- | ----------------------------------------- |
|
|
||||||
| **one** (single) | `framework`, `styling`, `db`, `orm`, `auth`, `tooling`, `api`, `ai`, `ui`, `payments` | next vs tanstack-start; drizzle vs prisma |
|
|
||||||
| **many** (coexist) | `testing`, `deploy`, `email`, `monorepo` | vitest + playwright together |
|
|
||||||
|
|
||||||
A `Module` carries a single `category` field (no `kind`/`slot`/`category`
|
|
||||||
discriminator). The manifest stores everything in one `modules` record keyed by
|
|
||||||
category, each holding an array (`Partial<Record<CategoryId,
|
|
||||||
StanzaModuleRecord[]>>`); `cardinality: "one"` categories are kept to ≤ 1 record
|
|
||||||
at install time. `selectedOne`/`selectedAll` read it ergonomically.
|
|
||||||
|
|
||||||
## framework
|
|
||||||
|
|
||||||
- [x] **tanstack-start** — TanStack Start on Vite (no Vinxi). Provides `web`, `react`, `ssr`, `node`
|
|
||||||
- [x] **next** — Next.js 16 (App Router). Provides `web`, `react`, `ssr`, `rsc`, `node`, `edge`
|
|
||||||
- [ ] **nuxt** — Vue. Will require relaxing the React-implicit assumption in many peer modules (capability tag `vue`)
|
|
||||||
- [ ] **svelte** — SvelteKit. Capability `svelte`
|
|
||||||
- [ ] **solid** — SolidStart. Capability `solid`
|
|
||||||
|
|
||||||
## api
|
|
||||||
|
|
||||||
_New **slot** (single-choice, constraint-bearing)._ Optional layer between the framework and the database/services.
|
|
||||||
|
|
||||||
- [ ] **trpc** — tRPC v11; per-framework adapters (next, tanstack-start, nuxt, svelte, solid)
|
|
||||||
- [ ] **orpc** — oRPC
|
|
||||||
|
|
||||||
## ai
|
|
||||||
|
|
||||||
_New **slot** (single-choice, constraint-bearing)._
|
|
||||||
|
|
||||||
- [ ] **vercel-ai-sdk** — `ai` package + provider sub-recipes
|
|
||||||
- [ ] **tanstack-ai** — TanStack AI
|
|
||||||
|
|
||||||
## auth
|
|
||||||
|
|
||||||
- [x] **better-auth** — headless, peers `orm: [drizzle, prisma]`, `framework: [next, tanstack-start]`
|
|
||||||
- [x] **clerk** — hosted UI, peers `framework: [next]` (TanStack Start adapter planned)
|
|
||||||
- [ ] **workos** — WorkOS AuthKit
|
|
||||||
|
|
||||||
## orm
|
|
||||||
|
|
||||||
- [x] **drizzle** — Drizzle ORM 0.45, peers `db: [postgres, sqlite]`
|
|
||||||
- [x] **prisma** — Prisma 7, peers `db: [postgres, sqlite]`
|
|
||||||
|
|
||||||
## db
|
|
||||||
|
|
||||||
- [x] **postgres** — `postgres` driver 3.4.x
|
|
||||||
- [x] **sqlite** — `better-sqlite3` 12.x
|
|
||||||
|
|
||||||
## styling
|
|
||||||
|
|
||||||
- [x] **tailwind** — Vanilla Tailwind v4, adapters per framework
|
|
||||||
- [ ] **shadcn-radix** — classic shadcn/ui (Radix primitives)
|
|
||||||
- [ ] **shadcn-base** — shadcn on react-base-ui
|
|
||||||
|
|
||||||
## payments
|
|
||||||
|
|
||||||
_New **slot** (single-choice, constraint-bearing)._ In addition to adding example files to the framework app, each of these have a plugin for better-auth -- add those too if better-auth is selected, either before or after this selection.
|
|
||||||
|
|
||||||
- [ ] **stripe** — Checkout Sessions + webhooks
|
|
||||||
- [ ] **polar** — Polar SDK
|
|
||||||
- [ ] **autumn**
|
|
||||||
- [ ] **dodo payments**
|
|
||||||
|
|
||||||
## email
|
|
||||||
|
|
||||||
_`cardinality: many`, app-scoped._
|
|
||||||
|
|
||||||
- [ ] **resend** — Resend SDK + React Email templates
|
|
||||||
|
|
||||||
## tooling
|
|
||||||
|
|
||||||
_`cardinality: one`, repo-scoped._ Lint/format toolchain — single-choice because the three toolchains are mutually exclusive substitutes. Bears no _outbound_ dispatch constraints (nothing peers on `tooling`) but consumes a `framework` peer where the config varies.
|
|
||||||
|
|
||||||
- [x] **eslint-prettier** — ESLint flat config + Prettier; per-framework adapters (next, tanstack-start)
|
|
||||||
- [x] **biome** — Biome (lint + format), framework-agnostic
|
|
||||||
- [x] **oxlint-oxfmt** — Oxlint + oxfmt, framework-agnostic
|
|
||||||
|
|
||||||
## testing
|
|
||||||
|
|
||||||
_`cardinality: many`, app-scoped._ Vitest and Playwright are independent and routinely coexist.
|
|
||||||
|
|
||||||
- [x] **vitest** — unit + integration; per-framework adapters (next, tanstack-start), `jsdom` + RTL, `test`/`test:watch` scripts
|
|
||||||
- [x] **playwright** — e2e; per-framework `webServer` (`next dev` / `vite dev`), `test:e2e`/`test:e2e:ui` scripts (disjoint from vitest's)
|
|
||||||
|
|
||||||
## deploy
|
|
||||||
|
|
||||||
_`cardinality: many`, repo-scoped._
|
|
||||||
|
|
||||||
- [ ] **vercel** — `vercel.json` + framework-specific output, add the nitro vite plugin for tanstack start
|
|
||||||
- [ ] **cloudflare** — Workers / Pages adapter per framework
|
|
||||||
- [ ] **railway** — `railway.toml` + Dockerfile
|
|
||||||
- [ ] **docker** — generic `Dockerfile` + compose for self-host
|
|
||||||
|
|
||||||
## monorepo
|
|
||||||
|
|
||||||
_`cardinality: many`, repo-scoped. Currently hardcoded in `bootstrapShell` as Turborepo; becomes a real category when a second option (Nx, Moonrepo) lands._
|
|
||||||
|
|
||||||
- [x] **turborepo** — Turbo 2.x (current default; not yet a configurable choice)
|
|
||||||
|
|
||||||
## packageManager
|
|
||||||
|
|
||||||
_Not a slot — a top-level field in `stanza.json` (`packageManager: "pnpm" | "bun" | "npm"`). Wizard prompts; codemods only touch `package.json`, never lockfiles._
|
|
||||||
|
|
||||||
- [x] pnpm (default)
|
|
||||||
- [x] bun
|
|
||||||
- [x] npm
|
|
||||||
- [ ] yarn — needs lockfile/workspace handling that differs from the others
|
|
||||||
|
|
||||||
## Install homes (package extraction)
|
|
||||||
|
|
||||||
A category's `home` places its modules' output in one of three places:
|
|
||||||
|
|
||||||
- **`app`** (`framework`, `styling`, `testing`, `email`) — files land in `manifest.appDir` (e.g. `apps/web/`). For categories that wire the app shell or test it.
|
|
||||||
- **`package`** (`auth`, `db`, `orm`) — files land in `packages/<dir>/`, named `@<manifest.name>/<dir>`, and the app gets a `workspace:*` dep. `db` and `orm` share a single `packages/db/` package so the ORM client sits next to the schema it queries.
|
|
||||||
- **`repo`** (`tooling`, `deploy`, `monorepo`) — config files land at the repo root and scripts/devDeps merge into the root `package.json`, because one config governs every workspace.
|
|
||||||
|
|
||||||
The mapping lives in the canonical [`CATEGORIES`](packages/registry/src/module.ts) array as the `home` tagged union. `categoryHome(id)` and `PACKAGE_DIRS` are derived views. When adding a category, pick the home that matches: data layer/payments → `package`; app shell/router → `app`; repo-wide tooling → `repo`.
|
|
||||||
|
|
||||||
## Adding a category
|
|
||||||
|
|
||||||
A **one-line edit**: append a `Category` entry to `CATEGORIES` with `{ id, label, description, cardinality, home }`. `CategoryId`, `KNOWN_CATEGORIES`, `PEER_CATEGORIES`, and `PACKAGE_DIRS` all derive from it — nothing else to keep in sync. Order is topological: a category must appear after every category it can peer on, and `many` (leaf) categories come last. Authoring a **module** sets a single `category` field on `defineModule`; the runner/CLI/web handle single- vs multi-choice off the category's `cardinality`. Existing `stanza.json` files don't break (new categories are optional).
|
|
||||||
@@ -28,7 +28,7 @@ Session before that: pre-1.0 architectural cleanup landed.
|
|||||||
- **A3**: deleted `Module.provides`/`Module.requires`/`Capability` — they were declared but never read by the resolver. Peer constraints already encode the same info more precisely.
|
- **A3**: deleted `Module.provides`/`Module.requires`/`Capability` — they were declared but never read by the resolver. Peer constraints already encode the same info more precisely.
|
||||||
- **A1**: hoisted module-level install fields (`dependencies`, `devDependencies`, `env`, `scripts`, `consumesPackages`). Adapters override per-key when needed. Better Auth's 6 adapters no longer duplicate `dependencies`, `env`, or workspace deps.
|
- **A1**: hoisted module-level install fields (`dependencies`, `devDependencies`, `env`, `scripts`, `consumesPackages`). Adapters override per-key when needed. Better Auth's 6 adapters no longer duplicate `dependencies`, `env`, or workspace deps.
|
||||||
- **A4**: web app is now the canonical registry host. `prebuild` builds + copies the registry into `apps/web/public/registry/`; deployed Vercel output ships it; the CLI's default URL points at the same path.
|
- **A4**: web app is now the canonical registry host. `prebuild` builds + copies the registry into `apps/web/public/registry/`; deployed Vercel output ships it; the CLI's default URL points at the same path.
|
||||||
- **A2 deferred**: the add-on schema (`manifest.addons[]` + `kind` discriminator) is intentionally postponed until the first real add-on module forces concrete design decisions. Categorization documented in [REGISTRY.md](REGISTRY.md).
|
- **A2 deferred**: the add-on schema (`manifest.addons[]` + `kind` discriminator) is intentionally postponed until the first real add-on module forces concrete design decisions. Categorization documented in the [module registry](apps/web/content/docs/registry.mdx).
|
||||||
- Cross-package wiring is now declared via module-level `consumesPackages` (was per-adapter `peerPackages`). `stanza add` composes modules across slots (verified: `framework next` → `db postgres` → `orm drizzle` → `auth better-auth` produces a working tree with the right adapter selection and the auth package depending on the db package). 49 unit tests pass. apps/web is a minimal Vite-native TanStack Start scaffold — needs the actual builder UI.
|
- Cross-package wiring is now declared via module-level `consumesPackages` (was per-adapter `peerPackages`). `stanza add` composes modules across slots (verified: `framework next` → `db postgres` → `orm drizzle` → `auth better-auth` produces a working tree with the right adapter selection and the auth package depending on the db package). 49 unit tests pass. apps/web is a minimal Vite-native TanStack Start scaffold — needs the actual builder UI.
|
||||||
|
|
||||||
## Web app (apps/web) — priority
|
## Web app (apps/web) — priority
|
||||||
@@ -111,7 +111,7 @@ Functional but a few real issues to fix.
|
|||||||
|
|
||||||
## Registry expansion
|
## Registry expansion
|
||||||
|
|
||||||
The full first-party module roadmap lives in [REGISTRY.md](REGISTRY.md). 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`, `ui`, `payments` (all `cardinality: one`). Decide topological order for the wizard prompts (api/ai after framework)
|
||||||
@@ -143,4 +143,4 @@ These are real future work but consciously deferred — don't pull them in oppor
|
|||||||
- `stanza update` — pinned-version 3-way merge
|
- `stanza update` — pinned-version 3-way merge
|
||||||
- Third-party registry hosting — the spec exists implicitly; publish it formally later
|
- Third-party registry hosting — the spec exists implicitly; publish it formally later
|
||||||
- React Native / Expo modules — needs the `native` capability + cross-platform framework slot
|
- React Native / Expo modules — needs the `native` capability + cross-platform framework slot
|
||||||
- Additional first-party modules — full catalog tracked in [REGISTRY.md](REGISTRY.md); land the slot taxonomy changes above first
|
- Additional first-party modules — full catalog tracked in the [module registry](apps/web/content/docs/registry.mdx); land the slot taxonomy changes above first
|
||||||
|
|||||||
@@ -29,23 +29,21 @@
|
|||||||
".": "./src/index.ts"
|
".": "./src/index.ts"
|
||||||
},
|
},
|
||||||
"publishConfig": {
|
"publishConfig": {
|
||||||
|
"access": "public",
|
||||||
"bin": {
|
"bin": {
|
||||||
"stanza": "./dist/bin.mjs"
|
"stanza": "./dist/bin.mjs"
|
||||||
},
|
},
|
||||||
"main": "./dist/index.mjs",
|
|
||||||
"types": "./dist/index.d.mts",
|
|
||||||
"exports": {
|
"exports": {
|
||||||
".": {
|
".": {
|
||||||
"types": "./dist/index.d.mts",
|
"types": "./dist/index.d.mts",
|
||||||
"default": "./dist/index.mjs"
|
"default": "./dist/index.mjs"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"access": "public"
|
"main": "./dist/index.mjs",
|
||||||
|
"types": "./dist/index.d.mts"
|
||||||
},
|
},
|
||||||
"scripts": {
|
"scripts": {
|
||||||
"build": "tsdown",
|
"build": "vp pack"
|
||||||
"check-types": "tsc --noEmit",
|
|
||||||
"test": "vitest run"
|
|
||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@clack/prompts": "^1.4.0",
|
"@clack/prompts": "^1.4.0",
|
||||||
@@ -60,8 +58,8 @@
|
|||||||
"@stanza/registry": "workspace:*",
|
"@stanza/registry": "workspace:*",
|
||||||
"@types/node": "^25.9.1",
|
"@types/node": "^25.9.1",
|
||||||
"@types/semver": "^7.7.1",
|
"@types/semver": "^7.7.1",
|
||||||
"tsdown": "^0.22.0",
|
|
||||||
"typescript": "^6.0.3",
|
"typescript": "^6.0.3",
|
||||||
"vitest": "^4.1.7"
|
"vite-plus": "catalog:",
|
||||||
|
"vitest": "catalog:"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,6 +1,11 @@
|
|||||||
import * as p from "@clack/prompts";
|
import * as p from "@clack/prompts";
|
||||||
import type { CategoryId } from "@stanza/registry";
|
import {
|
||||||
import { isMulti, KNOWN_CATEGORIES, resolveAdapter, selectedAll } from "@stanza/registry";
|
isCategoryId,
|
||||||
|
isMulti,
|
||||||
|
KNOWN_CATEGORIES,
|
||||||
|
resolveAdapter,
|
||||||
|
selectedAll,
|
||||||
|
} from "@stanza/registry";
|
||||||
import { defineCommand } from "citty";
|
import { defineCommand } from "citty";
|
||||||
import pc from "picocolors";
|
import pc from "picocolors";
|
||||||
|
|
||||||
@@ -30,12 +35,12 @@ export async function cmdAdd(args: CliArgs): Promise<void> {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (!(KNOWN_CATEGORIES as readonly string[]).includes(slot)) {
|
if (!isCategoryId(slot)) {
|
||||||
p.log.error(`Unknown category: ${slot}. Categories: ${KNOWN_CATEGORIES.join(", ")}`);
|
p.log.error(`Unknown category: ${slot}. Categories: ${KNOWN_CATEGORIES.join(", ")}`);
|
||||||
process.exitCode = 1;
|
process.exitCode = 1;
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
const category = slot as CategoryId;
|
const category = slot;
|
||||||
const group = category;
|
const group = category;
|
||||||
|
|
||||||
const projectRoot = findProjectRoot();
|
const projectRoot = findProjectRoot();
|
||||||
|
|||||||
@@ -2,7 +2,7 @@ import fs from "node:fs";
|
|||||||
import os from "node:os";
|
import os from "node:os";
|
||||||
import path from "node:path";
|
import path from "node:path";
|
||||||
|
|
||||||
import { afterEach, beforeEach, describe, expect, it } from "vitest";
|
import { afterEach, beforeEach, describe, expect, it } from "vite-plus/test";
|
||||||
|
|
||||||
import { cmdAdd } from "./add";
|
import { cmdAdd } from "./add";
|
||||||
import { cmdInit } from "./init";
|
import { cmdInit } from "./init";
|
||||||
|
|||||||
@@ -3,8 +3,8 @@ import path from "node:path";
|
|||||||
|
|
||||||
import * as p from "@clack/prompts";
|
import * as p from "@clack/prompts";
|
||||||
import { removePackageDependency, removeEnvVar } from "@stanza/codemods";
|
import { removePackageDependency, removeEnvVar } from "@stanza/codemods";
|
||||||
import type { CategoryId, StanzaModuleRecord } from "@stanza/registry";
|
import type { StanzaModuleRecord } from "@stanza/registry";
|
||||||
import { isMulti, KNOWN_CATEGORIES, PACKAGE_DIRS, selectedAll } from "@stanza/registry";
|
import { isCategoryId, isMulti, PACKAGE_DIRS, selectedAll } from "@stanza/registry";
|
||||||
import { defineCommand } from "citty";
|
import { defineCommand } from "citty";
|
||||||
import pc from "picocolors";
|
import pc from "picocolors";
|
||||||
|
|
||||||
@@ -41,12 +41,12 @@ export async function cmdRemove(args: CliArgs): Promise<void> {
|
|||||||
process.exitCode = 1;
|
process.exitCode = 1;
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
if (!(KNOWN_CATEGORIES as readonly string[]).includes(slot)) {
|
if (!isCategoryId(slot)) {
|
||||||
p.log.error(`Unknown category: ${slot}`);
|
p.log.error(`Unknown category: ${slot}`);
|
||||||
process.exitCode = 1;
|
process.exitCode = 1;
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
const category = slot as CategoryId;
|
const category = slot;
|
||||||
const group = category;
|
const group = category;
|
||||||
|
|
||||||
const projectRoot = findProjectRoot();
|
const projectRoot = findProjectRoot();
|
||||||
|
|||||||
@@ -344,9 +344,9 @@ function ensureSlotPackage(args: {
|
|||||||
// Wire the workspace dep into the host app's package.json on first
|
// Wire the workspace dep into the host app's package.json on first
|
||||||
// bootstrap. Not region-tracked — sweep cleans it up.
|
// bootstrap. Not region-tracked — sweep cleans it up.
|
||||||
if (fs.existsSync(appPkgPath)) {
|
if (fs.existsSync(appPkgPath)) {
|
||||||
const appPkg = JSON.parse(fs.readFileSync(appPkgPath, "utf8")) as {
|
const appPkg: { dependencies?: Record<string, string> } = JSON.parse(
|
||||||
dependencies?: Record<string, string>;
|
fs.readFileSync(appPkgPath, "utf8"),
|
||||||
};
|
);
|
||||||
if (appPkg.dependencies?.[packageName] !== "workspace:*") {
|
if (appPkg.dependencies?.[packageName] !== "workspace:*") {
|
||||||
created = true;
|
created = true;
|
||||||
if (!dryRun) addPackageDependency(appPkgPath, packageName, "workspace:*");
|
if (!dryRun) addPackageDependency(appPkgPath, packageName, "workspace:*");
|
||||||
@@ -363,9 +363,9 @@ function ensureSlotPackage(args: {
|
|||||||
if (!PACKAGE_DIRS.has(peer)) continue;
|
if (!PACKAGE_DIRS.has(peer)) continue;
|
||||||
const peerName = `@${args.manifest.name}/${peer}`;
|
const peerName = `@${args.manifest.name}/${peer}`;
|
||||||
if (!fs.existsSync(ownPkgJson)) continue;
|
if (!fs.existsSync(ownPkgJson)) continue;
|
||||||
const pkg = JSON.parse(fs.readFileSync(ownPkgJson, "utf8")) as {
|
const pkg: { dependencies?: Record<string, string> } = JSON.parse(
|
||||||
dependencies?: Record<string, string>;
|
fs.readFileSync(ownPkgJson, "utf8"),
|
||||||
};
|
);
|
||||||
if (pkg.dependencies?.[peerName] !== "workspace:*") {
|
if (pkg.dependencies?.[peerName] !== "workspace:*") {
|
||||||
created = true;
|
created = true;
|
||||||
if (!dryRun) addPackageDependency(ownPkgJson, peerName, "workspace:*");
|
if (!dryRun) addPackageDependency(ownPkgJson, peerName, "workspace:*");
|
||||||
@@ -396,7 +396,9 @@ function renderArgs(
|
|||||||
}
|
}
|
||||||
return value;
|
return value;
|
||||||
};
|
};
|
||||||
return visit(args) as Record<string, JsonValue>;
|
const out: Record<string, JsonValue> = {};
|
||||||
|
for (const [k, v] of Object.entries(args)) out[k] = visit(v);
|
||||||
|
return out;
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
import { afterEach, beforeEach, describe, expect, it, vi } from "vite-plus/test";
|
||||||
|
|
||||||
import { clearVersionCacheForTests, resolveRange, resolveRanges } from "./npm-version";
|
import { clearVersionCacheForTests, resolveRange, resolveRanges } from "./npm-version";
|
||||||
|
|
||||||
|
|||||||
@@ -6,6 +6,10 @@ const NPM_REGISTRY = process.env.STANZA_NPM_REGISTRY ?? "https://registry.npmjs.
|
|||||||
// the CLI process so a multi-module `init` hits npm at most once per package.
|
// the CLI process so a multi-module `init` hits npm at most once per package.
|
||||||
const cache = new Map<string, string[] | null>();
|
const cache = new Map<string, string[] | null>();
|
||||||
|
|
||||||
|
function isObject(value: unknown): value is Record<string, unknown> {
|
||||||
|
return typeof value === "object" && value !== null;
|
||||||
|
}
|
||||||
|
|
||||||
/** `"^"`/`"~"` for shapes we re-resolve; `null` means leave the range verbatim. */
|
/** `"^"`/`"~"` for shapes we re-resolve; `null` means leave the range verbatim. */
|
||||||
function rangeModifier(range: string): "^" | "~" | null {
|
function rangeModifier(range: string): "^" | "~" | null {
|
||||||
const t = range.trim();
|
const t = range.trim();
|
||||||
@@ -32,8 +36,9 @@ async function fetchVersions(name: string): Promise<string[] | null> {
|
|||||||
cache.set(name, null);
|
cache.set(name, null);
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
const body = (await res.json()) as { versions?: Record<string, unknown> };
|
const body: unknown = await res.json();
|
||||||
const versions = Object.keys(body.versions ?? {});
|
const versionsMap = isObject(body) && isObject(body.versions) ? body.versions : {};
|
||||||
|
const versions = Object.keys(versionsMap);
|
||||||
cache.set(name, versions);
|
cache.set(name, versions);
|
||||||
return versions;
|
return versions;
|
||||||
} catch {
|
} catch {
|
||||||
|
|||||||
@@ -3,7 +3,7 @@ import path from "node:path";
|
|||||||
import { fileURLToPath } from "node:url";
|
import { fileURLToPath } from "node:url";
|
||||||
|
|
||||||
import type { Module, RegistryIndex } from "@stanza/registry";
|
import type { Module, RegistryIndex } from "@stanza/registry";
|
||||||
import { CATEGORIES } from "@stanza/registry";
|
import { CATEGORIES, ModuleSchema, RegistryIndexSchema } from "@stanza/registry";
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* In dev (when running from the stanza monorepo), modules are imported
|
* In dev (when running from the stanza monorepo), modules are imported
|
||||||
@@ -111,7 +111,7 @@ async function loadHttpRegistry(baseUrl: string): Promise<Registry> {
|
|||||||
if (!indexRes.ok) {
|
if (!indexRes.ok) {
|
||||||
throw new Error(`Failed to load stanza registry from ${baseUrl}: ${indexRes.status}`);
|
throw new Error(`Failed to load stanza registry from ${baseUrl}: ${indexRes.status}`);
|
||||||
}
|
}
|
||||||
const index = (await indexRes.json()) as RegistryIndex;
|
const index = RegistryIndexSchema.parse(await indexRes.json());
|
||||||
|
|
||||||
return {
|
return {
|
||||||
index,
|
index,
|
||||||
@@ -119,14 +119,14 @@ async function loadHttpRegistry(baseUrl: string): Promise<Registry> {
|
|||||||
const url = `${baseUrl}/modules/${slot}-${id}.json`;
|
const url = `${baseUrl}/modules/${slot}-${id}.json`;
|
||||||
const res = await fetch(url);
|
const res = await fetch(url);
|
||||||
if (!res.ok) throw new Error(`Module fetch failed: ${url} (${res.status})`);
|
if (!res.ok) throw new Error(`Module fetch failed: ${url} (${res.status})`);
|
||||||
return (await res.json()) as Module;
|
return ModuleSchema.parse(await res.json());
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
async function importModule(modulesDir: string, dirName: string): Promise<Module> {
|
async function importModule(modulesDir: string, dirName: string): Promise<Module> {
|
||||||
const entry = path.join(modulesDir, dirName, "module.ts");
|
const entry = path.join(modulesDir, dirName, "module.ts");
|
||||||
const mod = (await import(entry)) as { default: Module };
|
const mod: { default: Module } = await import(entry);
|
||||||
if (!mod.default) {
|
if (!mod.default) {
|
||||||
throw new Error(`Module ${dirName} has no default export at ${entry}`);
|
throw new Error(`Module ${dirName} has no default export at ${entry}`);
|
||||||
}
|
}
|
||||||
|
|||||||
+13
-14
@@ -1,5 +1,5 @@
|
|||||||
import * as p from "@clack/prompts";
|
import * as p from "@clack/prompts";
|
||||||
import type { CategoryId, Module, RegistryIndex } from "@stanza/registry";
|
import type { CategoryId, Module, PackageManager, RegistryIndex } from "@stanza/registry";
|
||||||
import {
|
import {
|
||||||
categoryLabel,
|
categoryLabel,
|
||||||
categoryOrder,
|
categoryOrder,
|
||||||
@@ -81,7 +81,7 @@ export async function runInitWizard(args: {
|
|||||||
p.cancel("Cancelled.");
|
p.cancel("Cancelled.");
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
const ids = picks as string[];
|
const ids = picks;
|
||||||
if (ids.length === 0) continue;
|
if (ids.length === 0) continue;
|
||||||
selections[category] = await Promise.all(ids.map((id) => registry.loadModule(category, id)));
|
selections[category] = await Promise.all(ids.map((id) => registry.loadModule(category, id)));
|
||||||
} else {
|
} else {
|
||||||
@@ -95,13 +95,13 @@ export async function runInitWizard(args: {
|
|||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
if (choice === "__skip__") continue;
|
if (choice === "__skip__") continue;
|
||||||
const full = await registry.loadModule(category, choice as string);
|
const full = await registry.loadModule(category, choice);
|
||||||
selections[category] = [full];
|
selections[category] = [full];
|
||||||
pending[category] = full;
|
pending[category] = full;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
const pmChoice = await p.select({
|
const pmChoice = await p.select<PackageManager>({
|
||||||
message: "Package manager?",
|
message: "Package manager?",
|
||||||
options: [
|
options: [
|
||||||
{ value: "pnpm", label: "pnpm", hint: "Recommended" },
|
{ value: "pnpm", label: "pnpm", hint: "Recommended" },
|
||||||
@@ -116,16 +116,15 @@ export async function runInitWizard(args: {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Summary screen — what we're about to write.
|
// Summary screen — what we're about to write.
|
||||||
const rows = (Object.entries(selections) as [CategoryId, Module[]][]).flatMap(
|
const rows = KNOWN_CATEGORIES.flatMap((category) =>
|
||||||
([category, mods]) =>
|
(selections[category] ?? []).map(
|
||||||
mods.map(
|
(mod) =>
|
||||||
(mod) =>
|
`${pc.bold(categoryLabel(category).padEnd(16))} ${mod.label} ${pc.dim(`(${mod.id})`)}`,
|
||||||
`${pc.bold(categoryLabel(category).padEnd(16))} ${mod.label} ${pc.dim(`(${mod.id})`)}`,
|
),
|
||||||
),
|
|
||||||
);
|
);
|
||||||
const summary = [
|
const summary = [
|
||||||
`${pc.bold("Name:")} ${String(name)}`,
|
`${pc.bold("Name:")} ${name}`,
|
||||||
`${pc.bold("Package manager:")} ${String(pmChoice)}`,
|
`${pc.bold("Package manager:")} ${pmChoice}`,
|
||||||
"",
|
"",
|
||||||
...rows,
|
...rows,
|
||||||
].join("\n");
|
].join("\n");
|
||||||
@@ -138,9 +137,9 @@ export async function runInitWizard(args: {
|
|||||||
}
|
}
|
||||||
|
|
||||||
return {
|
return {
|
||||||
name: String(name),
|
name,
|
||||||
appDir: "apps/web",
|
appDir: "apps/web",
|
||||||
packageManager: pmChoice as "pnpm" | "bun" | "npm",
|
packageManager: pmChoice,
|
||||||
selections,
|
selections,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,11 +0,0 @@
|
|||||||
import { defineConfig } from "tsdown";
|
|
||||||
|
|
||||||
export default defineConfig({
|
|
||||||
entry: ["./src/bin.ts", "./src/index.ts"],
|
|
||||||
format: "esm",
|
|
||||||
target: "node22",
|
|
||||||
platform: "node",
|
|
||||||
deps: { alwaysBundle: [/^@stanza\//] },
|
|
||||||
dts: true,
|
|
||||||
clean: true,
|
|
||||||
});
|
|
||||||
@@ -0,0 +1,13 @@
|
|||||||
|
import { defineConfig } from "vite-plus";
|
||||||
|
|
||||||
|
export default defineConfig({
|
||||||
|
pack: {
|
||||||
|
entry: ["./src/bin.ts", "./src/index.ts"],
|
||||||
|
format: "esm",
|
||||||
|
target: "node22",
|
||||||
|
platform: "node",
|
||||||
|
deps: { alwaysBundle: [/^@stanza\//] },
|
||||||
|
dts: true,
|
||||||
|
clean: true,
|
||||||
|
},
|
||||||
|
});
|
||||||
@@ -1,3 +0,0 @@
|
|||||||
import { defineConfig } from "vitest/config";
|
|
||||||
|
|
||||||
export default defineConfig({});
|
|
||||||
@@ -70,9 +70,9 @@ stanza search [query]
|
|||||||
Use the printed **id** (not the display label) when passing a module to `add`.
|
Use the printed **id** (not the display label) when passing a module to `add`.
|
||||||
|
|
||||||
<Callout type="info">
|
<Callout type="info">
|
||||||
**Planned:** `swap` (replace a module with another in the same category) and
|
**Planned:** `swap` (replace a module with another in the same category) and `update` (re-pull a
|
||||||
`update` (re-pull a module at a newer version) are on the roadmap. The manifest
|
module at a newer version) are on the roadmap. The manifest already reserves the fields they need;
|
||||||
already reserves the fields they need; the verbs aren't implemented yet.
|
the verbs aren't implemented yet.
|
||||||
</Callout>
|
</Callout>
|
||||||
|
|
||||||
## Global flags
|
## Global flags
|
||||||
@@ -14,15 +14,15 @@ 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`.
|
||||||
|
|
||||||
| Category | Cardinality | Home |
|
| Category | Cardinality | Home |
|
||||||
| ----------- | ----------- | --------- |
|
| ----------- | ----------- | ------- |
|
||||||
| `framework` | one | app |
|
| `framework` | one | app |
|
||||||
| `styling` | one | app |
|
| `styling` | one | app |
|
||||||
| `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. A `many` category lets modules coexist (e.g. Vitest and
|
remove the first. A `many` category lets modules coexist (e.g. Vitest and
|
||||||
@@ -36,10 +36,10 @@ share a single `packages/db/` so the ORM client sits next to the schema it
|
|||||||
queries.
|
queries.
|
||||||
|
|
||||||
<Callout type="info">
|
<Callout type="info">
|
||||||
**Planned:** more categories are on the roadmap — `api`, `ai`, `payments`
|
**Planned:** more categories are on the roadmap — `api`, `ai`, `payments`, `email` (single-choice)
|
||||||
(single-choice) and `email`, `deploy` (coexisting). They aren't shipping yet,
|
and `deploy` (coexisting). They aren't shipping yet, but the taxonomy is designed to absorb them
|
||||||
but the taxonomy is designed to absorb them without breaking existing
|
without breaking existing projects. See the [module registry](/docs/registry) for the full
|
||||||
projects.
|
roadmap.
|
||||||
</Callout>
|
</Callout>
|
||||||
|
|
||||||
## Vendoring
|
## Vendoring
|
||||||
@@ -55,7 +55,8 @@ Modules declare **peers** — the other categories they care about. When you `ad
|
|||||||
a module, stanza reads your current selections and picks the **adapter** that
|
a module, stanza reads your current selections and picks the **adapter** that
|
||||||
matches. Better Auth, for instance, peers on both `framework` and `orm`, so
|
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
|
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.
|
|
||||||
|
- Prisma app. You pick the module; stanza picks the right variant.
|
||||||
|
|
||||||
## The manifest (`stanza.json`)
|
## The manifest (`stanza.json`)
|
||||||
|
|
||||||
|
|||||||
@@ -34,6 +34,6 @@ and out without you hand-editing the wiring.
|
|||||||
|
|
||||||
- **[Getting started](/docs/getting-started)** — create your first project and add a module.
|
- **[Getting started](/docs/getting-started)** — create your first project and add a module.
|
||||||
- **[Concepts](/docs/concepts)** — the mental model: categories, vendoring, peers, and the manifest.
|
- **[Concepts](/docs/concepts)** — the mental model: categories, vendoring, peers, and the manifest.
|
||||||
- **[CLI reference](/docs/cli-reference)** — every verb, flag, and environment variable.
|
- **[CLI reference](/docs/cli)** — every verb, flag, and environment variable.
|
||||||
|
|
||||||
Or skip ahead and assemble a stack visually with the [builder](/).
|
Or skip ahead and assemble a stack visually with the [builder](/).
|
||||||
|
|||||||
@@ -1,3 +1,3 @@
|
|||||||
{
|
{
|
||||||
"pages": ["index", "getting-started", "concepts", "cli-reference"]
|
"pages": ["index", "getting-started", "concepts", "cli", "registry"]
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,175 @@
|
|||||||
|
---
|
||||||
|
title: Module registry
|
||||||
|
description: The roadmap of first-party modules stanza ships — what's available today and what's planned.
|
||||||
|
---
|
||||||
|
|
||||||
|
Every module fills exactly one **category**. This page is the canonical roadmap
|
||||||
|
of the first-party modules stanza ships: what's available today, and what's on
|
||||||
|
the way. Self-hosted registries can add their own modules — see
|
||||||
|
[the open registry](/docs/concepts#open-registry).
|
||||||
|
|
||||||
|
## Categories
|
||||||
|
|
||||||
|
A category has two independent properties: its **cardinality** — `one`
|
||||||
|
(single-choice, like `framework` or `auth`) or `many` (coexisting, like
|
||||||
|
`testing`) — and its **home**, where a module's output lands (`app`, `repo`, or
|
||||||
|
`package`). See [Concepts](/docs/concepts#categories) for the full mental model.
|
||||||
|
|
||||||
|
| Category | Cardinality | Home | What it covers |
|
||||||
|
| ----------- | ----------- | ------- | ---------------------------------------------- |
|
||||||
|
| `framework` | one | app | App shell + router (Next, TanStack Start, …) |
|
||||||
|
| `api` | one | package | Typed RPC layer between framework and services |
|
||||||
|
| `ai` | one | package | AI SDK + provider wiring |
|
||||||
|
| `auth` | one | package | Authentication (Better Auth, Clerk, …) |
|
||||||
|
| `orm` | one | package | Schema + query layer (Drizzle, Prisma) |
|
||||||
|
| `db` | one | package | Database driver (Postgres, SQLite) |
|
||||||
|
| `styling` | one | app | CSS toolchain (Tailwind, shadcn, …) |
|
||||||
|
| `payments` | one | package | Checkout + webhooks (Stripe, Polar, …) |
|
||||||
|
| `tooling` | one | repo | Lint/format toolchain |
|
||||||
|
| `email` | one | package | Transactional email (Resend) |
|
||||||
|
| `testing` | many | app | Unit + e2e (Vitest, Playwright) |
|
||||||
|
| `deploy` | many | repo | Deploy targets (Vercel, Cloudflare, …) |
|
||||||
|
| `monorepo` | many | repo | Monorepo task runner (Turborepo) |
|
||||||
|
|
||||||
|
## framework
|
||||||
|
|
||||||
|
Single-choice. Provides the app shell, router, and React (or future) runtime.
|
||||||
|
|
||||||
|
| Module | Status | Notes |
|
||||||
|
| ---------------- | --------- | ---------------------------------------------------------------------------------------- |
|
||||||
|
| `tanstack-start` | Available | TanStack Start on Vite (no Vinxi). Provides `web`, `react`, `ssr`, `node`. |
|
||||||
|
| `next` | Available | Next.js 16 (App Router). Provides `web`, `react`, `ssr`, `rsc`, `node`, `edge`. |
|
||||||
|
| `nuxt` | Planned | Vue. Requires relaxing the React-implicit assumption in peer modules (capability `vue`). |
|
||||||
|
| `svelte` | Planned | SvelteKit (capability `svelte`). |
|
||||||
|
| `solid` | Planned | SolidStart (capability `solid`). |
|
||||||
|
|
||||||
|
## api
|
||||||
|
|
||||||
|
Single-choice, constraint-bearing. An optional layer between the framework and
|
||||||
|
the database or services, installed as its own workspace package.
|
||||||
|
|
||||||
|
| Module | Status | Notes |
|
||||||
|
| ------ | ------- | ---------------------------------------------------------------------- |
|
||||||
|
| `trpc` | Planned | tRPC v11; per-framework adapters (next, tanstack-start, nuxt, svelte). |
|
||||||
|
| `orpc` | Planned | oRPC. |
|
||||||
|
|
||||||
|
## ai
|
||||||
|
|
||||||
|
Single-choice, constraint-bearing. Installs into its own workspace package.
|
||||||
|
|
||||||
|
| Module | Status | Notes |
|
||||||
|
| --------------- | ------- | ------------------------------------ |
|
||||||
|
| `vercel-ai-sdk` | Planned | `ai` package + provider sub-recipes. |
|
||||||
|
| `tanstack-ai` | Planned | TanStack AI. |
|
||||||
|
|
||||||
|
## auth
|
||||||
|
|
||||||
|
Single-choice, installs into `packages/auth/`.
|
||||||
|
|
||||||
|
| Module | Status | Notes |
|
||||||
|
| ------------- | --------- | ---------------------------------------------------------------------------------- |
|
||||||
|
| `better-auth` | Available | Headless. Peers on `orm` (drizzle, prisma) and `framework` (next, tanstack-start). |
|
||||||
|
| `clerk` | Available | Hosted UI. Peers on `framework` (next); TanStack Start adapter planned. |
|
||||||
|
| `workos` | Planned | WorkOS AuthKit. |
|
||||||
|
|
||||||
|
## orm
|
||||||
|
|
||||||
|
Single-choice, shares `packages/db/` with the database driver.
|
||||||
|
|
||||||
|
| Module | Status | Notes |
|
||||||
|
| --------- | --------- | --------------------------------------------------- |
|
||||||
|
| `drizzle` | Available | Drizzle ORM 0.45. Peers on `db` (postgres, sqlite). |
|
||||||
|
| `prisma` | Available | Prisma 7. Peers on `db` (postgres, sqlite). |
|
||||||
|
|
||||||
|
## db
|
||||||
|
|
||||||
|
Single-choice, installs into `packages/db/`.
|
||||||
|
|
||||||
|
| Module | Status | Notes |
|
||||||
|
| ---------- | --------- | ------------------------ |
|
||||||
|
| `postgres` | Available | `postgres` driver 3.4.x. |
|
||||||
|
| `sqlite` | Available | `better-sqlite3` 12.x. |
|
||||||
|
|
||||||
|
## styling
|
||||||
|
|
||||||
|
Single-choice.
|
||||||
|
|
||||||
|
| Module | Status | Notes |
|
||||||
|
| -------------- | --------- | -------------------------------------------- |
|
||||||
|
| `tailwind` | Available | Vanilla Tailwind v4, adapters per framework. |
|
||||||
|
| `shadcn-radix` | Planned | Classic shadcn/ui (Radix primitives). |
|
||||||
|
| `shadcn-base` | Planned | shadcn on react-base-ui. |
|
||||||
|
|
||||||
|
## payments
|
||||||
|
|
||||||
|
Single-choice, constraint-bearing. Installs into its own workspace package, plus
|
||||||
|
a Better Auth plugin when auth is selected.
|
||||||
|
|
||||||
|
| Module | Status | Notes |
|
||||||
|
| -------- | ------- | ----------------------------- |
|
||||||
|
| `stripe` | Planned | Checkout Sessions + webhooks. |
|
||||||
|
| `polar` | Planned | Polar SDK. |
|
||||||
|
| `autumn` | Planned | Autumn. |
|
||||||
|
| `dodo` | Planned | Dodo Payments. |
|
||||||
|
|
||||||
|
## email
|
||||||
|
|
||||||
|
Single-choice, installs into its own workspace package.
|
||||||
|
|
||||||
|
| Module | Status | Notes |
|
||||||
|
| -------- | ------- | ----------------------------------- |
|
||||||
|
| `resend` | Planned | Resend SDK + React Email templates. |
|
||||||
|
|
||||||
|
## tooling
|
||||||
|
|
||||||
|
Single-choice, repo-scoped. The three toolchains are mutually exclusive
|
||||||
|
substitutes.
|
||||||
|
|
||||||
|
| Module | Status | Notes |
|
||||||
|
| ----------------- | --------- | ------------------------------------------------------ |
|
||||||
|
| `eslint-prettier` | Available | ESLint flat config + Prettier; per-framework adapters. |
|
||||||
|
| `biome` | Available | Biome (lint + format), framework-agnostic. |
|
||||||
|
| `oxlint-oxfmt` | Available | Oxlint + oxfmt, framework-agnostic. |
|
||||||
|
|
||||||
|
## testing
|
||||||
|
|
||||||
|
Coexisting, app-scoped. Vitest and Playwright are independent and routinely run
|
||||||
|
side by side.
|
||||||
|
|
||||||
|
| Module | Status | Notes |
|
||||||
|
| ------------ | --------- | ----------------------------------------------------------------- |
|
||||||
|
| `vitest` | Available | Unit + integration; `jsdom` + RTL; `test`/`test:watch` scripts. |
|
||||||
|
| `playwright` | Available | e2e; per-framework `webServer`; `test:e2e`/`test:e2e:ui` scripts. |
|
||||||
|
|
||||||
|
## deploy
|
||||||
|
|
||||||
|
Coexisting, repo-scoped.
|
||||||
|
|
||||||
|
| Module | Status | Notes |
|
||||||
|
| ------------ | ------- | --------------------------------------------- |
|
||||||
|
| `vercel` | Planned | `vercel.json` + framework-specific output. |
|
||||||
|
| `cloudflare` | Planned | Workers / Pages adapter per framework. |
|
||||||
|
| `railway` | Planned | `railway.toml` + Dockerfile. |
|
||||||
|
| `docker` | Planned | Generic `Dockerfile` + compose for self-host. |
|
||||||
|
|
||||||
|
## monorepo
|
||||||
|
|
||||||
|
Coexisting, repo-scoped. Currently hardcoded as Turborepo; becomes a real
|
||||||
|
configurable category when a second option (Nx, Moonrepo) lands.
|
||||||
|
|
||||||
|
| Module | Status | Notes |
|
||||||
|
| ----------- | --------- | ---------------------------------------------- |
|
||||||
|
| `turborepo` | Available | Turbo 2.x (current default; not yet a choice). |
|
||||||
|
|
||||||
|
## Package manager
|
||||||
|
|
||||||
|
Not a category — a top-level field in `stanza.json`
|
||||||
|
(`packageManager: "pnpm" | "bun" | "npm"`). The wizard prompts for it; codemods
|
||||||
|
only ever touch `package.json`, never lockfiles.
|
||||||
|
|
||||||
|
| Manager | Status |
|
||||||
|
| ------- | ---------------------------------------------------- |
|
||||||
|
| pnpm | Available (default) |
|
||||||
|
| bun | Available |
|
||||||
|
| npm | Available |
|
||||||
|
| yarn | Planned (needs distinct lockfile/workspace handling) |
|
||||||
@@ -5,13 +5,11 @@
|
|||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"type": "module",
|
"type": "module",
|
||||||
"scripts": {
|
"scripts": {
|
||||||
"dev": "vite dev",
|
"build": "vp build",
|
||||||
"build": "vite build",
|
"dev": "vp dev",
|
||||||
"predev": "./scripts/prepare-registry.sh",
|
|
||||||
"prebuild": "./scripts/prepare-registry.sh",
|
"prebuild": "./scripts/prepare-registry.sh",
|
||||||
"start": "node .output/server/index.mjs",
|
"predev": "./scripts/prepare-registry.sh",
|
||||||
"check-types": "tsc --noEmit",
|
"start": "node .output/server/index.mjs"
|
||||||
"test": "vitest run"
|
|
||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@base-ui/react": "^1.5.0",
|
"@base-ui/react": "^1.5.0",
|
||||||
@@ -61,8 +59,9 @@
|
|||||||
"@vitejs/plugin-react": "^6.0.2",
|
"@vitejs/plugin-react": "^6.0.2",
|
||||||
"jsdom": "^29.1.1",
|
"jsdom": "^29.1.1",
|
||||||
"typescript": "^6.0.3",
|
"typescript": "^6.0.3",
|
||||||
"vite": "^8.0.14",
|
"vite": "catalog:",
|
||||||
"vitest": "^4.1.7",
|
"vite-plus": "catalog:",
|
||||||
|
"vitest": "catalog:",
|
||||||
"web-vitals": "^5.2.0"
|
"web-vitals": "^5.2.0"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -27,7 +27,7 @@ export function ProjectSetup({
|
|||||||
}, [name]);
|
}, [name]);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (draft === name) return;
|
if (draft === name) return undefined;
|
||||||
const timer = setTimeout(() => onNameChangeRef.current(draft), 300);
|
const timer = setTimeout(() => onNameChangeRef.current(draft), 300);
|
||||||
return () => clearTimeout(timer);
|
return () => clearTimeout(timer);
|
||||||
}, [draft, name]);
|
}, [draft, name]);
|
||||||
|
|||||||
@@ -175,5 +175,8 @@ function describeError(error: ResolveError): string {
|
|||||||
return `Doesn't pair with ${error.peer} (your ${categoryLabel(error.category)} pick).`;
|
return `Doesn't pair with ${error.peer} (your ${categoryLabel(error.category)} pick).`;
|
||||||
case "no-adapter":
|
case "no-adapter":
|
||||||
return "No adapter matches your current stack.";
|
return "No adapter matches your current stack.";
|
||||||
|
default:
|
||||||
|
error satisfies never;
|
||||||
|
throw new Error(`Unknown resolve error: ${String(error)}`);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -44,7 +44,7 @@ export function CommandPreview({
|
|||||||
value={command}
|
value={command}
|
||||||
showLabel={false}
|
showLabel={false}
|
||||||
copyLabel="Copy command"
|
copyLabel="Copy command"
|
||||||
className="flex-1 [font-variant-ligatures:none]"
|
className="flex-1"
|
||||||
onCopy={onCopy}
|
onCopy={onCopy}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
import type { CategoryId, ModuleSummary, RegistryIndex } from "@stanza/registry";
|
import type { CategoryId, ModuleSummary, RegistryIndex } from "@stanza/registry";
|
||||||
import { categoryLabel } from "@stanza/registry";
|
import { categoryLabel, KNOWN_CATEGORIES } from "@stanza/registry";
|
||||||
|
|
||||||
import { ToggleGroup, ToggleGroupItem } from "@/components/ui/toggle-group";
|
import { ToggleGroup, ToggleGroupItem } from "@/components/ui/toggle-group";
|
||||||
|
|
||||||
@@ -19,9 +19,10 @@ export function AdapterSwitcher({
|
|||||||
resolvedPeers: Partial<Record<CategoryId, string>>;
|
resolvedPeers: Partial<Record<CategoryId, string>>;
|
||||||
onChange: (category: CategoryId, id: string) => void;
|
onChange: (category: CategoryId, id: string) => void;
|
||||||
}) {
|
}) {
|
||||||
const switchable = (Object.entries(peerOptions) as [CategoryId, string[]][]).filter(
|
const switchable = KNOWN_CATEGORIES.flatMap((category): [CategoryId, string[]][] => {
|
||||||
([, opts]) => opts.length > 1,
|
const opts = peerOptions[category];
|
||||||
);
|
return opts && opts.length > 1 ? [[category, opts]] : [];
|
||||||
|
});
|
||||||
if (switchable.length === 0) return null;
|
if (switchable.length === 0) return null;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
|
|||||||
@@ -9,7 +9,7 @@ import {
|
|||||||
DropdownMenuRadioItem,
|
DropdownMenuRadioItem,
|
||||||
DropdownMenuTrigger,
|
DropdownMenuTrigger,
|
||||||
} from "@/components/ui/dropdown-menu";
|
} from "@/components/ui/dropdown-menu";
|
||||||
import { PACKAGE_MANAGERS, type PackageManager } from "@/lib/package-manager";
|
import { isPackageManager, PACKAGE_MANAGERS, type PackageManager } from "@/lib/package-manager";
|
||||||
|
|
||||||
function NpmLogo(props: ComponentProps<"svg">) {
|
function NpmLogo(props: ComponentProps<"svg">) {
|
||||||
return (
|
return (
|
||||||
@@ -84,7 +84,9 @@ export function PackageManagerSelect({
|
|||||||
<DropdownMenuContent align="start">
|
<DropdownMenuContent align="start">
|
||||||
<DropdownMenuRadioGroup
|
<DropdownMenuRadioGroup
|
||||||
value={value}
|
value={value}
|
||||||
onValueChange={(next) => onValueChange(next as PackageManager)}
|
onValueChange={(next) => {
|
||||||
|
if (isPackageManager(next)) onValueChange(next);
|
||||||
|
}}
|
||||||
>
|
>
|
||||||
{PACKAGE_MANAGERS.map((pm) => (
|
{PACKAGE_MANAGERS.map((pm) => (
|
||||||
<DropdownMenuRadioItem
|
<DropdownMenuRadioItem
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
import posthog from "posthog-js";
|
import { posthog } from "posthog-js";
|
||||||
import { PostHogProvider as PostHogReactProvider } from "posthog-js/react";
|
import { PostHogProvider as PostHogReactProvider } from "posthog-js/react";
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|||||||
@@ -69,7 +69,7 @@ export function ThemeProvider({
|
|||||||
}, [theme, mounted]);
|
}, [theme, mounted]);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!mounted || theme !== "system") return;
|
if (!mounted || theme !== "system") return undefined;
|
||||||
const media = window.matchMedia("(prefers-color-scheme: dark)");
|
const media = window.matchMedia("(prefers-color-scheme: dark)");
|
||||||
const onChange = () => setResolvedTheme(applyTheme("system"));
|
const onChange = () => setResolvedTheme(applyTheme("system"));
|
||||||
media.addEventListener("change", onChange);
|
media.addEventListener("change", onChange);
|
||||||
|
|||||||
@@ -59,7 +59,7 @@ function InputGroupAddon({
|
|||||||
data-align={align}
|
data-align={align}
|
||||||
className={cn(inputGroupAddonVariants({ align }), className)}
|
className={cn(inputGroupAddonVariants({ align }), className)}
|
||||||
onClick={(e) => {
|
onClick={(e) => {
|
||||||
if ((e.target as HTMLElement).closest("button")) {
|
if (e.target instanceof HTMLElement && e.target.closest("button")) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
e.currentTarget.parentElement?.querySelector("input")?.focus();
|
e.currentTarget.parentElement?.querySelector("input")?.focus();
|
||||||
|
|||||||
@@ -17,6 +17,9 @@ const ICONS = {
|
|||||||
loading: <IconLoader className="size-4 animate-spin" />,
|
loading: <IconLoader className="size-4 animate-spin" />,
|
||||||
};
|
};
|
||||||
|
|
||||||
|
// CSS custom properties aren't representable in React.CSSProperties (csstype
|
||||||
|
// has no `--*` index signature), so the cast is the standard escape hatch.
|
||||||
|
// oxlint-disable-next-line typescript/no-unsafe-type-assertion
|
||||||
const STYLE = {
|
const STYLE = {
|
||||||
"--normal-bg": "var(--popover)",
|
"--normal-bg": "var(--popover)",
|
||||||
"--normal-text": "var(--popover-foreground)",
|
"--normal-text": "var(--popover-foreground)",
|
||||||
@@ -31,7 +34,7 @@ const TOAST_OPTIONS = {
|
|||||||
};
|
};
|
||||||
|
|
||||||
const Toaster = ({ ...props }: ToasterProps) => {
|
const Toaster = ({ ...props }: ToasterProps) => {
|
||||||
const { theme = "system" } = useTheme();
|
const { resolvedTheme: theme } = useTheme();
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Sonner
|
<Sonner
|
||||||
|
|||||||
@@ -38,6 +38,8 @@ function ToggleGroup({
|
|||||||
data-size={size}
|
data-size={size}
|
||||||
data-spacing={spacing}
|
data-spacing={spacing}
|
||||||
data-orientation={orientation}
|
data-orientation={orientation}
|
||||||
|
// CSS custom property — not representable in React.CSSProperties (csstype).
|
||||||
|
// oxlint-disable-next-line typescript/no-unsafe-type-assertion
|
||||||
style={{ "--gap": spacing } as React.CSSProperties}
|
style={{ "--gap": spacing } as React.CSSProperties}
|
||||||
className={cn(
|
className={cn(
|
||||||
"group/toggle-group flex w-fit flex-row items-center gap-[--spacing(var(--gap))] rounded-none data-[size=sm]:rounded-none data-vertical:flex-col data-vertical:items-stretch",
|
"group/toggle-group flex w-fit flex-row items-center gap-[--spacing(var(--gap))] rounded-none data-[size=sm]:rounded-none data-vertical:flex-col data-vertical:items-stretch",
|
||||||
|
|||||||
@@ -1,313 +0,0 @@
|
|||||||
import { defineModule, type Module } from "@stanza/registry";
|
|
||||||
import { describe, expect, it } from "vitest";
|
|
||||||
|
|
||||||
import {
|
|
||||||
buildCommand,
|
|
||||||
DEFAULT_NAME,
|
|
||||||
parseSelections,
|
|
||||||
pruneUnresolved,
|
|
||||||
resolveSelected,
|
|
||||||
selectedFiles,
|
|
||||||
toSearchParams,
|
|
||||||
} from "./selection";
|
|
||||||
|
|
||||||
describe("parseSelections", () => {
|
|
||||||
it("returns defaults when search is empty", () => {
|
|
||||||
const { name, pm, selections } = parseSelections({});
|
|
||||||
expect(name).toBe(DEFAULT_NAME);
|
|
||||||
expect(pm).toBe("pnpm");
|
|
||||||
expect(selections).toEqual({});
|
|
||||||
});
|
|
||||||
|
|
||||||
it("parses a recognized package manager and falls back to pnpm otherwise", () => {
|
|
||||||
expect(parseSelections({ pm: "npm" }).pm).toBe("npm");
|
|
||||||
expect(parseSelections({ pm: "bun" }).pm).toBe("bun");
|
|
||||||
expect(parseSelections({ pm: "yarn" }).pm).toBe("pnpm");
|
|
||||||
expect(parseSelections({ pm: "" }).pm).toBe("pnpm");
|
|
||||||
});
|
|
||||||
|
|
||||||
it("preserves recognized category keys and ignores unknown keys", () => {
|
|
||||||
const { name, selections } = parseSelections({
|
|
||||||
name: "my-thing",
|
|
||||||
framework: "next",
|
|
||||||
orm: "drizzle",
|
|
||||||
// @ts-expect-error unknown category — should be silently dropped
|
|
||||||
flooble: "ignored",
|
|
||||||
});
|
|
||||||
expect(name).toBe("my-thing");
|
|
||||||
expect(selections).toEqual({ framework: ["next"], orm: ["drizzle"] });
|
|
||||||
});
|
|
||||||
|
|
||||||
it("splits comma-joined multi-choice categories into id lists", () => {
|
|
||||||
const { selections } = parseSelections({ testing: "vitest,playwright" });
|
|
||||||
expect(selections.testing).toEqual(["vitest", "playwright"]);
|
|
||||||
});
|
|
||||||
|
|
||||||
it("drops empty category values", () => {
|
|
||||||
const { selections } = parseSelections({ framework: "" });
|
|
||||||
expect(selections.framework).toBeUndefined();
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
describe("toSearchParams", () => {
|
|
||||||
it("round-trips a populated state", () => {
|
|
||||||
const input = {
|
|
||||||
name: "my-app",
|
|
||||||
pm: "pnpm" as const,
|
|
||||||
selections: { framework: ["next"], orm: ["drizzle"] },
|
|
||||||
};
|
|
||||||
const search = toSearchParams(input);
|
|
||||||
expect(parseSelections(search)).toEqual(input);
|
|
||||||
});
|
|
||||||
|
|
||||||
it("round-trips multi-choice selections as comma-joined params", () => {
|
|
||||||
const input = {
|
|
||||||
name: DEFAULT_NAME,
|
|
||||||
pm: "pnpm" as const,
|
|
||||||
selections: { framework: ["next"], testing: ["vitest", "playwright"] },
|
|
||||||
};
|
|
||||||
const search = toSearchParams(input);
|
|
||||||
expect(search.testing).toBe("vitest,playwright");
|
|
||||||
expect(parseSelections(search)).toEqual(input);
|
|
||||||
});
|
|
||||||
|
|
||||||
it("omits the default name and package manager to keep the URL terse", () => {
|
|
||||||
expect(toSearchParams({ name: DEFAULT_NAME, pm: "pnpm", selections: {} })).toEqual({});
|
|
||||||
});
|
|
||||||
|
|
||||||
it("emits a non-default package manager", () => {
|
|
||||||
expect(toSearchParams({ name: DEFAULT_NAME, pm: "npm", selections: {} })).toEqual({
|
|
||||||
pm: "npm",
|
|
||||||
});
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
describe("buildCommand", () => {
|
|
||||||
it("emits only the selected flags", () => {
|
|
||||||
expect(
|
|
||||||
buildCommand({
|
|
||||||
name: "my-app",
|
|
||||||
selections: { framework: ["next"], db: ["sqlite"] },
|
|
||||||
}),
|
|
||||||
).toBe("pnpm create stanza my-app --framework=next --db=sqlite");
|
|
||||||
});
|
|
||||||
|
|
||||||
it("emits multi-choice flags as comma-joined ids", () => {
|
|
||||||
expect(
|
|
||||||
buildCommand({
|
|
||||||
name: "my-app",
|
|
||||||
selections: { framework: ["next"], testing: ["vitest", "playwright"] },
|
|
||||||
}),
|
|
||||||
).toBe("pnpm create stanza my-app --framework=next --testing=vitest,playwright");
|
|
||||||
});
|
|
||||||
|
|
||||||
it("keeps the bare command when nothing is selected", () => {
|
|
||||||
expect(buildCommand({ name: "my-app", selections: {} })).toBe("pnpm create stanza my-app");
|
|
||||||
});
|
|
||||||
|
|
||||||
it("uses the chosen package manager as the prefix", () => {
|
|
||||||
expect(buildCommand({ name: "my-app", selections: { framework: ["next"] }, pm: "bun" })).toBe(
|
|
||||||
"bun create stanza my-app --framework=next",
|
|
||||||
);
|
|
||||||
});
|
|
||||||
|
|
||||||
it("inserts a -- separator before flags for npm", () => {
|
|
||||||
expect(buildCommand({ name: "my-app", selections: { framework: ["next"] }, pm: "npm" })).toBe(
|
|
||||||
"npm create stanza my-app -- --framework=next",
|
|
||||||
);
|
|
||||||
});
|
|
||||||
|
|
||||||
it("omits the -- separator for npm when there are no flags", () => {
|
|
||||||
expect(buildCommand({ name: "my-app", selections: {}, pm: "npm" })).toBe(
|
|
||||||
"npm create stanza my-app",
|
|
||||||
);
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
describe("selectedFiles", () => {
|
|
||||||
const drizzle: Module = defineModule({
|
|
||||||
id: "drizzle",
|
|
||||||
category: "orm",
|
|
||||||
label: "Drizzle",
|
|
||||||
description: "",
|
|
||||||
version: "0.1.0",
|
|
||||||
adapters: [
|
|
||||||
{
|
|
||||||
key: "default",
|
|
||||||
match: {},
|
|
||||||
templates: [
|
|
||||||
{ src: "schema.ts", dest: "src/index.ts", scope: "package" },
|
|
||||||
{ src: "drizzle.config.ts", dest: "drizzle.config.ts", scope: "package" },
|
|
||||||
],
|
|
||||||
},
|
|
||||||
],
|
|
||||||
});
|
|
||||||
|
|
||||||
it("routes scope:package to packages/<dir> and scope:repo bare", () => {
|
|
||||||
const adapter = drizzle.adapters[0]!;
|
|
||||||
const adapterWithRepo = {
|
|
||||||
...adapter,
|
|
||||||
templates: [
|
|
||||||
...(adapter.templates ?? []),
|
|
||||||
{ src: "turbo.json", dest: "turbo.json", scope: "repo" as const },
|
|
||||||
],
|
|
||||||
};
|
|
||||||
const files = selectedFiles({
|
|
||||||
orm: [{ module: drizzle, adapter: adapterWithRepo }],
|
|
||||||
});
|
|
||||||
expect(files.map((f) => f.path)).toEqual([
|
|
||||||
"packages/db/src/index.ts",
|
|
||||||
"packages/db/drizzle.config.ts",
|
|
||||||
"turbo.json",
|
|
||||||
]);
|
|
||||||
});
|
|
||||||
|
|
||||||
it("groups files by category order", () => {
|
|
||||||
const framework: Module = defineModule({
|
|
||||||
id: "next",
|
|
||||||
category: "framework",
|
|
||||||
label: "Next.js",
|
|
||||||
description: "",
|
|
||||||
version: "0.1.0",
|
|
||||||
adapters: [
|
|
||||||
{
|
|
||||||
key: "default",
|
|
||||||
match: {},
|
|
||||||
templates: [{ src: "layout.tsx", dest: "app/layout.tsx", scope: "app" }],
|
|
||||||
},
|
|
||||||
],
|
|
||||||
});
|
|
||||||
const files = selectedFiles({
|
|
||||||
orm: [{ module: drizzle, adapter: drizzle.adapters[0]! }],
|
|
||||||
framework: [{ module: framework, adapter: framework.adapters[0]! }],
|
|
||||||
});
|
|
||||||
// framework comes before orm in categoryOrder, so its files appear first.
|
|
||||||
expect(files[0]!.path).toBe("apps/web/app/layout.tsx");
|
|
||||||
});
|
|
||||||
|
|
||||||
it("emits multi-choice templates after one-choice templates", () => {
|
|
||||||
const vitest: Module = defineModule({
|
|
||||||
id: "vitest",
|
|
||||||
category: "testing",
|
|
||||||
label: "Vitest",
|
|
||||||
description: "",
|
|
||||||
version: "0.1.0",
|
|
||||||
adapters: [
|
|
||||||
{
|
|
||||||
key: "default",
|
|
||||||
match: {},
|
|
||||||
templates: [{ src: "vitest.config.ts", dest: "vitest.config.ts", scope: "app" }],
|
|
||||||
},
|
|
||||||
],
|
|
||||||
});
|
|
||||||
const files = selectedFiles({
|
|
||||||
orm: [{ module: drizzle, adapter: drizzle.adapters[0]! }],
|
|
||||||
testing: [{ module: vitest, adapter: vitest.adapters[0]! }],
|
|
||||||
});
|
|
||||||
expect(files.at(-1)!.path).toBe("apps/web/vitest.config.ts");
|
|
||||||
expect(files.at(-1)!.owner.category).toBe("testing");
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
describe("resolveSelected", () => {
|
|
||||||
it("picks the adapter whose match aligns with active peers", () => {
|
|
||||||
const drizzleMod: Module = defineModule({
|
|
||||||
id: "drizzle",
|
|
||||||
category: "orm",
|
|
||||||
label: "Drizzle",
|
|
||||||
description: "",
|
|
||||||
version: "0.1.0",
|
|
||||||
peers: { db: ["postgres", "sqlite"] },
|
|
||||||
adapters: [
|
|
||||||
{ key: "postgres", match: { db: "postgres" } },
|
|
||||||
{ key: "sqlite", match: { db: "sqlite" } },
|
|
||||||
],
|
|
||||||
});
|
|
||||||
const postgresMod: Module = defineModule({
|
|
||||||
id: "postgres",
|
|
||||||
category: "db",
|
|
||||||
label: "Postgres",
|
|
||||||
description: "",
|
|
||||||
version: "0.1.0",
|
|
||||||
adapters: [{ key: "default", match: {} }],
|
|
||||||
});
|
|
||||||
const modules = {
|
|
||||||
"orm:drizzle": drizzleMod,
|
|
||||||
"db:postgres": postgresMod,
|
|
||||||
};
|
|
||||||
const out = resolveSelected(modules, { orm: ["drizzle"], db: ["postgres"] });
|
|
||||||
expect(out.orm?.[0]?.adapter.key).toBe("postgres");
|
|
||||||
expect(out.db?.[0]?.adapter.key).toBe("default");
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
describe("pruneUnresolved", () => {
|
|
||||||
const postgres: Module = defineModule({
|
|
||||||
id: "postgres",
|
|
||||||
category: "db",
|
|
||||||
label: "Postgres",
|
|
||||||
description: "",
|
|
||||||
version: "0.1.0",
|
|
||||||
adapters: [{ key: "default", match: {} }],
|
|
||||||
});
|
|
||||||
const drizzle: Module = defineModule({
|
|
||||||
id: "drizzle",
|
|
||||||
category: "orm",
|
|
||||||
label: "Drizzle",
|
|
||||||
description: "",
|
|
||||||
version: "0.1.0",
|
|
||||||
peers: { db: ["postgres"] },
|
|
||||||
adapters: [{ key: "postgres", match: { db: "postgres" } }],
|
|
||||||
});
|
|
||||||
const next: Module = defineModule({
|
|
||||||
id: "next",
|
|
||||||
category: "framework",
|
|
||||||
label: "Next.js",
|
|
||||||
description: "",
|
|
||||||
version: "0.1.0",
|
|
||||||
adapters: [{ key: "default", match: {} }],
|
|
||||||
});
|
|
||||||
const vitest: Module = defineModule({
|
|
||||||
id: "vitest",
|
|
||||||
category: "testing",
|
|
||||||
label: "Vitest",
|
|
||||||
description: "",
|
|
||||||
version: "0.1.0",
|
|
||||||
peers: { framework: ["next"] },
|
|
||||||
adapters: [{ key: "next", match: { framework: "next" } }],
|
|
||||||
});
|
|
||||||
const modules: Record<string, Module> = {
|
|
||||||
"db:postgres": postgres,
|
|
||||||
"orm:drizzle": drizzle,
|
|
||||||
"framework:next": next,
|
|
||||||
"testing:vitest": vitest,
|
|
||||||
};
|
|
||||||
|
|
||||||
it("drops a category whose peer is missing", () => {
|
|
||||||
expect(pruneUnresolved(modules, { orm: ["drizzle"] })).toEqual({});
|
|
||||||
});
|
|
||||||
|
|
||||||
it("keeps a category once its peer is present", () => {
|
|
||||||
expect(pruneUnresolved(modules, { orm: ["drizzle"], db: ["postgres"] })).toEqual({
|
|
||||||
orm: ["drizzle"],
|
|
||||||
db: ["postgres"],
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
it("drops a multi-choice module whose framework peer is missing", () => {
|
|
||||||
expect(pruneUnresolved(modules, { testing: ["vitest"] })).toEqual({});
|
|
||||||
});
|
|
||||||
|
|
||||||
it("keeps a multi-choice module once its framework peer is present", () => {
|
|
||||||
expect(pruneUnresolved(modules, { framework: ["next"], testing: ["vitest"] })).toEqual({
|
|
||||||
framework: ["next"],
|
|
||||||
testing: ["vitest"],
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
it("cascades: dropping db strands orm and its dependents in one call", () => {
|
|
||||||
expect(pruneUnresolved(modules, { framework: ["next"], orm: ["drizzle"] })).toEqual({
|
|
||||||
framework: ["next"],
|
|
||||||
});
|
|
||||||
});
|
|
||||||
});
|
|
||||||
@@ -13,14 +13,8 @@ import { getRegistryIndex } from "@/server/registry-index.functions";
|
|||||||
|
|
||||||
import appCss from "../styles.css?url";
|
import appCss from "../styles.css?url";
|
||||||
|
|
||||||
const BACK_LINK = <Link to="/" />;
|
|
||||||
const DEVTOOLS_CONFIG = { position: "bottom-right" } as const;
|
|
||||||
const DEVTOOLS_PLUGINS = [{ name: "Tanstack Router", render: <TanStackRouterDevtoolsPanel /> }];
|
|
||||||
|
|
||||||
export const Route = createRootRoute({
|
export const Route = createRootRoute({
|
||||||
loader: () => getRegistryIndex(),
|
loader: () => getRegistryIndex(),
|
||||||
// Per-route `head()` overrides supply title / OG / canonical. The root just
|
|
||||||
// sets the always-present basics and ships the global stylesheet.
|
|
||||||
head: () => ({
|
head: () => ({
|
||||||
meta: [
|
meta: [
|
||||||
{ charSet: "utf-8" },
|
{ charSet: "utf-8" },
|
||||||
@@ -57,7 +51,7 @@ function NotFound() {
|
|||||||
title="Page not found"
|
title="Page not found"
|
||||||
description="That page doesn’t exist. It may have moved, or the URL might be wrong."
|
description="That page doesn’t exist. It may have moved, or the URL might be wrong."
|
||||||
>
|
>
|
||||||
<Button render={BACK_LINK} nativeButton={false} variant="outline" size="sm">
|
<Button render={<Link to="/" />} nativeButton={false} variant="outline" size="sm">
|
||||||
← Back to builder
|
← Back to builder
|
||||||
</Button>
|
</Button>
|
||||||
</CenteredMessage>
|
</CenteredMessage>
|
||||||
@@ -70,7 +64,7 @@ function ErrorState({ error }: { error: Error }) {
|
|||||||
title="Something went wrong"
|
title="Something went wrong"
|
||||||
description={error?.message || "An unexpected error occurred while rendering this page."}
|
description={error?.message || "An unexpected error occurred while rendering this page."}
|
||||||
>
|
>
|
||||||
<Button render={BACK_LINK} nativeButton={false} variant="outline" size="sm">
|
<Button render={<Link to="/" />} nativeButton={false} variant="outline" size="sm">
|
||||||
← Back to builder
|
← Back to builder
|
||||||
</Button>
|
</Button>
|
||||||
</CenteredMessage>
|
</CenteredMessage>
|
||||||
@@ -83,7 +77,7 @@ function RootComponent() {
|
|||||||
<head>
|
<head>
|
||||||
<HeadContent />
|
<HeadContent />
|
||||||
</head>
|
</head>
|
||||||
<body className="min-h-svh bg-background font-sans text-foreground antialiased">
|
<body className="min-h-svh bg-background font-sans text-foreground tabular-nums antialiased">
|
||||||
<PostHogProvider>
|
<PostHogProvider>
|
||||||
<ThemeProvider defaultTheme="system" storageKey="stanza-theme">
|
<ThemeProvider defaultTheme="system" storageKey="stanza-theme">
|
||||||
<div className="flex min-h-svh flex-col">
|
<div className="flex min-h-svh flex-col">
|
||||||
@@ -96,7 +90,10 @@ function RootComponent() {
|
|||||||
<Toaster />
|
<Toaster />
|
||||||
</ThemeProvider>
|
</ThemeProvider>
|
||||||
</PostHogProvider>
|
</PostHogProvider>
|
||||||
<TanStackDevtools config={DEVTOOLS_CONFIG} plugins={DEVTOOLS_PLUGINS} />
|
<TanStackDevtools
|
||||||
|
config={{ position: "bottom-right" }}
|
||||||
|
plugins={[{ name: "TanStack Router", render: <TanStackRouterDevtoolsPanel /> }]}
|
||||||
|
/>
|
||||||
<Analytics />
|
<Analytics />
|
||||||
<Scripts />
|
<Scripts />
|
||||||
</body>
|
</body>
|
||||||
|
|||||||
@@ -30,16 +30,26 @@ type Payload = {
|
|||||||
events: IncomingEvent[];
|
events: IncomingEvent[];
|
||||||
};
|
};
|
||||||
|
|
||||||
|
function isObject(value: unknown): value is Record<string, unknown> {
|
||||||
|
return typeof value === "object" && value !== null;
|
||||||
|
}
|
||||||
|
|
||||||
function parsePayload(body: unknown): Payload | null {
|
function parsePayload(body: unknown): Payload | null {
|
||||||
if (typeof body !== "object" || body === null) return null;
|
if (!isObject(body)) return null;
|
||||||
const { distinctId, events } = body as Record<string, unknown>;
|
const { distinctId, events } = body;
|
||||||
if (typeof distinctId !== "string" || distinctId.length === 0) return null;
|
if (typeof distinctId !== "string" || distinctId.length === 0) return null;
|
||||||
if (!Array.isArray(events) || events.length === 0 || events.length > MAX_EVENTS) return null;
|
if (!Array.isArray(events) || events.length === 0 || events.length > MAX_EVENTS) return null;
|
||||||
|
const parsed: IncomingEvent[] = [];
|
||||||
for (const e of events) {
|
for (const e of events) {
|
||||||
if (typeof e !== "object" || e === null) return null;
|
if (!isObject(e)) return null;
|
||||||
if (typeof (e as IncomingEvent).event !== "string") return null;
|
if (typeof e.event !== "string") return null;
|
||||||
|
parsed.push({
|
||||||
|
event: e.event,
|
||||||
|
properties: isObject(e.properties) ? e.properties : undefined,
|
||||||
|
timestamp: typeof e.timestamp === "string" ? e.timestamp : undefined,
|
||||||
|
});
|
||||||
}
|
}
|
||||||
return { distinctId, events: events as IncomingEvent[] };
|
return { distinctId, events: parsed };
|
||||||
}
|
}
|
||||||
|
|
||||||
export const Route = createFileRoute("/api/events")({
|
export const Route = createFileRoute("/api/events")({
|
||||||
|
|||||||
@@ -82,8 +82,9 @@ function ModuleDetailPage() {
|
|||||||
// arrays (the unified selection shape). CommandPreview turns this into the
|
// arrays (the unified selection shape). CommandPreview turns this into the
|
||||||
// package-manager-specific command string (`--framework=next --testing=vitest`).
|
// package-manager-specific command string (`--framework=next --testing=vitest`).
|
||||||
const selections: Selections = {};
|
const selections: Selections = {};
|
||||||
for (const [category, id] of Object.entries(resolvedPeers)) {
|
for (const category of KNOWN_CATEGORIES) {
|
||||||
selections[category as CategoryId] = [id];
|
const id = resolvedPeers[category];
|
||||||
|
if (id !== undefined) selections[category] = [id];
|
||||||
}
|
}
|
||||||
selections[module.category] = [module.id];
|
selections[module.category] = [module.id];
|
||||||
const tryItParts = { selections };
|
const tryItParts = { selections };
|
||||||
|
|||||||
@@ -7,13 +7,18 @@ import type {
|
|||||||
PeerRequirement,
|
PeerRequirement,
|
||||||
RegistryIndex,
|
RegistryIndex,
|
||||||
} from "@stanza/registry";
|
} from "@stanza/registry";
|
||||||
import { emptyManifest, PEER_CATEGORIES, resolveAdapter } from "@stanza/registry";
|
import { emptyManifest, KNOWN_CATEGORIES, PEER_CATEGORIES, resolveAdapter } from "@stanza/registry";
|
||||||
import { createServerFn } from "@tanstack/react-start";
|
import { createServerFn } from "@tanstack/react-start";
|
||||||
|
|
||||||
import type { Preview } from "@/server/highlighter";
|
import type { Preview } from "@/server/highlighter";
|
||||||
import { renderPreview } from "@/server/highlighter.server";
|
import { renderPreview } from "@/server/highlighter.server";
|
||||||
import { loadRegistryFile } from "@/server/registry-base.server";
|
import { loadRegistryFile } from "@/server/registry-base.server";
|
||||||
|
|
||||||
|
/** Typed `Object.keys` for a partial category-record (avoids a key-widening cast). */
|
||||||
|
function categoryKeys(record: Partial<Record<CategoryId, unknown>>): CategoryId[] {
|
||||||
|
return KNOWN_CATEGORIES.filter((category) => record[category] !== undefined);
|
||||||
|
}
|
||||||
|
|
||||||
export type ModuleDetailInput = {
|
export type ModuleDetailInput = {
|
||||||
category: CategoryId;
|
category: CategoryId;
|
||||||
id: string;
|
id: string;
|
||||||
@@ -98,9 +103,9 @@ function computePeerOptions(
|
|||||||
const out: Partial<Record<CategoryId, string[]>> = {};
|
const out: Partial<Record<CategoryId, string[]>> = {};
|
||||||
const declared = module.peers ?? ({} as PeerRequirement);
|
const declared = module.peers ?? ({} as PeerRequirement);
|
||||||
const referenced = new Set<CategoryId>();
|
const referenced = new Set<CategoryId>();
|
||||||
for (const slot of Object.keys(declared) as CategoryId[]) referenced.add(slot);
|
for (const slot of categoryKeys(declared)) referenced.add(slot);
|
||||||
for (const adapter of module.adapters) {
|
for (const adapter of module.adapters) {
|
||||||
for (const slot of Object.keys(adapter.match) as CategoryId[]) referenced.add(slot);
|
for (const slot of categoryKeys(adapter.match)) referenced.add(slot);
|
||||||
}
|
}
|
||||||
|
|
||||||
for (const slot of referenced) {
|
for (const slot of referenced) {
|
||||||
@@ -144,7 +149,7 @@ function applyAutoDefaults(
|
|||||||
peerOptions: Partial<Record<CategoryId, string[]>>,
|
peerOptions: Partial<Record<CategoryId, string[]>>,
|
||||||
): Partial<Record<CategoryId, string>> {
|
): Partial<Record<CategoryId, string>> {
|
||||||
const out: Partial<Record<CategoryId, string>> = { ...peers };
|
const out: Partial<Record<CategoryId, string>> = { ...peers };
|
||||||
for (const slot of Object.keys(peerOptions) as CategoryId[]) {
|
for (const slot of categoryKeys(peerOptions)) {
|
||||||
if (out[slot]) continue;
|
if (out[slot]) continue;
|
||||||
const opts = peerOptions[slot];
|
const opts = peerOptions[slot];
|
||||||
if (opts && opts.length > 0) out[slot] = opts[0];
|
if (opts && opts.length > 0) out[slot] = opts[0];
|
||||||
|
|||||||
@@ -25,7 +25,8 @@ export async function loadRegistryFile<T>(relativePath: string): Promise<T> {
|
|||||||
const { resolve } = await import("node:path");
|
const { resolve } = await import("node:path");
|
||||||
const filePath = resolve(process.cwd(), "public/registry", relativePath);
|
const filePath = resolve(process.cwd(), "public/registry", relativePath);
|
||||||
try {
|
try {
|
||||||
return JSON.parse(await readFile(filePath, "utf8")) as T;
|
const parsed: T = JSON.parse(await readFile(filePath, "utf8"));
|
||||||
|
return parsed;
|
||||||
} catch {
|
} catch {
|
||||||
throw new Error(
|
throw new Error(
|
||||||
`Registry asset not found: ${filePath} (run \`pnpm --filter @stanza/web prebuild\` to populate public/registry/)`,
|
`Registry asset not found: ${filePath} (run \`pnpm --filter @stanza/web prebuild\` to populate public/registry/)`,
|
||||||
@@ -38,5 +39,8 @@ export async function loadRegistryFile<T>(relativePath: string): Promise<T> {
|
|||||||
throw new Error(`Registry asset not found: assets:registry:${relativePath}`);
|
throw new Error(`Registry asset not found: assets:registry:${relativePath}`);
|
||||||
}
|
}
|
||||||
// unstorage destr-parses JSON, but be explicit if a driver returns raw text.
|
// unstorage destr-parses JSON, but be explicit if a driver returns raw text.
|
||||||
return (typeof data === "string" ? JSON.parse(data) : data) as T;
|
const value: unknown = typeof data === "string" ? JSON.parse(data) : data;
|
||||||
|
// Registry assets are first-party build output; the caller declares the shape.
|
||||||
|
// oxlint-disable-next-line typescript/no-unsafe-type-assertion
|
||||||
|
return value as T;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -182,6 +182,13 @@
|
|||||||
scrollbar-color: var(--scrollbar-thumb-current) transparent;
|
scrollbar-color: var(--scrollbar-thumb-current) transparent;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
pre,
|
||||||
|
code,
|
||||||
|
.font-mono {
|
||||||
|
/* TODO: temporary fix for https://github.com/vercel/geist-font/pull/217 */
|
||||||
|
font-variant-ligatures: none !important;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@utility no-scrollbar {
|
@utility no-scrollbar {
|
||||||
|
|||||||
@@ -1,9 +0,0 @@
|
|||||||
{
|
|
||||||
"$schema": "https://turborepo.com/schema.json",
|
|
||||||
"extends": ["//"],
|
|
||||||
"tasks": {
|
|
||||||
"build": {
|
|
||||||
"env": ["VITE_PUBLIC_POSTHOG_KEY", "VITE_PUBLIC_POSTHOG_HOST"]
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -4,17 +4,17 @@ import { tanstackStart } from "@tanstack/react-start/plugin/vite";
|
|||||||
import react from "@vitejs/plugin-react";
|
import react from "@vitejs/plugin-react";
|
||||||
import mdx from "fumadocs-mdx/vite";
|
import mdx from "fumadocs-mdx/vite";
|
||||||
import { nitro } from "nitro/vite";
|
import { nitro } from "nitro/vite";
|
||||||
import { defineConfig } from "vite";
|
import { defineConfig, lazyPlugins } from "vite-plus";
|
||||||
|
|
||||||
export default defineConfig({
|
export default defineConfig({
|
||||||
plugins: [
|
plugins: lazyPlugins(() => [
|
||||||
mdx(),
|
mdx(),
|
||||||
nitro({ serverAssets: [{ baseName: "registry", dir: "public/registry" }] }),
|
|
||||||
devtools(),
|
devtools(),
|
||||||
tailwindcss(),
|
tailwindcss(),
|
||||||
tanstackStart(),
|
tanstackStart(),
|
||||||
react(),
|
react(),
|
||||||
],
|
nitro({ serverAssets: [{ baseName: "registry", dir: "public/registry" }] }),
|
||||||
|
]),
|
||||||
resolve: {
|
resolve: {
|
||||||
tsconfigPaths: true,
|
tsconfigPaths: true,
|
||||||
alias: {
|
alias: {
|
||||||
|
|||||||
+15
-14
@@ -5,29 +5,30 @@
|
|||||||
"description": "Modular monorepo template CLI — shadcn for stacks.",
|
"description": "Modular monorepo template CLI — shadcn for stacks.",
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"author": "Jake Jarvis <jakejarvis@gmail.com>",
|
"author": "Jake Jarvis <jakejarvis@gmail.com>",
|
||||||
|
"type": "module",
|
||||||
"scripts": {
|
"scripts": {
|
||||||
"build": "turbo run build",
|
"build": "vp run -r build",
|
||||||
"dev": "turbo run dev",
|
"build:cli": "vp run stanza-cli#build && vp run create-stanza#build",
|
||||||
"test": "turbo run test",
|
"build:web": "vp run @stanza/web#build",
|
||||||
"check-types": "turbo run check-types",
|
"dev": "vp run -r dev",
|
||||||
"lint": "oxlint",
|
"test": "vp test",
|
||||||
"lint:fix": "oxlint --fix",
|
"lint": "vp lint",
|
||||||
"fmt": "oxfmt",
|
"lint:fix": "vp lint --fix",
|
||||||
"fmt:check": "oxfmt --check",
|
"fmt": "vp fmt",
|
||||||
|
"fmt:check": "vp fmt --check",
|
||||||
"registry:build": "bun scripts/registry-build.ts",
|
"registry:build": "bun scripts/registry-build.ts",
|
||||||
"changeset": "changeset",
|
"changeset": "vp exec changeset",
|
||||||
"release": "pnpm --filter stanza-cli --filter create-stanza build && changeset publish"
|
"release": "vp run build:cli && changeset publish",
|
||||||
|
"prepare": "vp config"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@changesets/changelog-github": "^0.7.0",
|
"@changesets/changelog-github": "^0.7.0",
|
||||||
"@changesets/cli": "^2.31.0",
|
"@changesets/cli": "^2.31.0",
|
||||||
"@stanza/registry": "workspace:*",
|
"@stanza/registry": "workspace:*",
|
||||||
"@types/node": "^25.9.1",
|
"@types/node": "^25.9.1",
|
||||||
"oxfmt": "^0.51.0",
|
|
||||||
"oxlint": "^1.66.0",
|
|
||||||
"tsx": "^4.22.3",
|
"tsx": "^4.22.3",
|
||||||
"turbo": "^2.9.14",
|
"typescript": "^6.0.3",
|
||||||
"typescript": "^6.0.3"
|
"vite-plus": "catalog:"
|
||||||
},
|
},
|
||||||
"engines": {
|
"engines": {
|
||||||
"node": ">=22",
|
"node": ">=22",
|
||||||
|
|||||||
@@ -10,10 +10,6 @@
|
|||||||
".": "./src/index.ts",
|
".": "./src/index.ts",
|
||||||
"./builtins": "./src/builtins/index.ts"
|
"./builtins": "./src/builtins/index.ts"
|
||||||
},
|
},
|
||||||
"scripts": {
|
|
||||||
"check-types": "tsc --noEmit",
|
|
||||||
"test": "vitest run"
|
|
||||||
},
|
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@stanza/registry": "workspace:*",
|
"@stanza/registry": "workspace:*",
|
||||||
"ts-morph": "^28.0.0"
|
"ts-morph": "^28.0.0"
|
||||||
@@ -21,6 +17,7 @@
|
|||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@types/node": "^25.9.1",
|
"@types/node": "^25.9.1",
|
||||||
"typescript": "^6.0.3",
|
"typescript": "^6.0.3",
|
||||||
"vitest": "^4.1.7"
|
"vite-plus": "catalog:",
|
||||||
|
"vitest": "catalog:"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
import { emptyManifest } from "@stanza/registry";
|
import { emptyManifest } from "@stanza/registry";
|
||||||
import { describe, expect, it, vi } from "vitest";
|
import { describe, expect, it, vi } from "vite-plus/test";
|
||||||
|
|
||||||
import { openProject, type CodemodContext, type Project } from "../index";
|
import { openProject, type CodemodContext, type Project } from "../index";
|
||||||
import addVitePlugin from "./add-vite-plugin";
|
import addVitePlugin from "./add-vite-plugin";
|
||||||
|
|||||||
@@ -3,7 +3,7 @@ import os from "node:os";
|
|||||||
import path from "node:path";
|
import path from "node:path";
|
||||||
|
|
||||||
import { emptyManifest } from "@stanza/registry";
|
import { emptyManifest } from "@stanza/registry";
|
||||||
import { afterEach, beforeEach, describe, expect, it } from "vitest";
|
import { afterEach, beforeEach, describe, expect, it } from "vite-plus/test";
|
||||||
|
|
||||||
import { openProject, type CodemodContext, type Project } from "../index";
|
import { openProject, type CodemodContext, type Project } from "../index";
|
||||||
import appendToFile from "./append-to-file";
|
import appendToFile from "./append-to-file";
|
||||||
|
|||||||
@@ -204,6 +204,9 @@ function commentLine(style: CommentStyle, body: string): string {
|
|||||||
return `# ${body}`;
|
return `# ${body}`;
|
||||||
case "block":
|
case "block":
|
||||||
return `/* ${body} */`;
|
return `/* ${body} */`;
|
||||||
|
default:
|
||||||
|
style satisfies never;
|
||||||
|
throw new Error(`append-to-file: unknown comment style ${String(style)}`);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -26,10 +26,10 @@ import reExport from "./re-export";
|
|||||||
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> = {
|
||||||
[addVitePlugin.id]: addVitePlugin as unknown as Codemod,
|
[addVitePlugin.id]: addVitePlugin,
|
||||||
[appendToFile.id]: appendToFile as unknown as Codemod,
|
[appendToFile.id]: appendToFile,
|
||||||
[reExport.id]: reExport as unknown as Codemod,
|
[reExport.id]: reExport,
|
||||||
[wrapRootLayout.id]: wrapRootLayout as unknown as Codemod,
|
[wrapRootLayout.id]: wrapRootLayout,
|
||||||
};
|
};
|
||||||
|
|
||||||
export { addVitePlugin, appendToFile, reExport, wrapRootLayout };
|
export { addVitePlugin, appendToFile, reExport, wrapRootLayout };
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
import { emptyManifest } from "@stanza/registry";
|
import { emptyManifest } from "@stanza/registry";
|
||||||
import { describe, expect, it } from "vitest";
|
import { describe, expect, it } from "vite-plus/test";
|
||||||
|
|
||||||
import { openProject, type CodemodContext, type Project } from "../index";
|
import { openProject, type CodemodContext, type Project } from "../index";
|
||||||
import reExport from "./re-export";
|
import reExport from "./re-export";
|
||||||
|
|||||||
@@ -48,8 +48,9 @@ const reExport: Codemod<ReExportArgs> = {
|
|||||||
const fileRel = path.relative(ctx.projectRoot, fileAbs);
|
const fileRel = path.relative(ctx.projectRoot, fileAbs);
|
||||||
const sf = openOrThrow(ctx, fileAbs);
|
const sf = openOrThrow(ctx, fileAbs);
|
||||||
|
|
||||||
const wantsStar = args.names === undefined || args.names === "all";
|
const names = args.names ?? "all";
|
||||||
const wantNames = wantsStar ? [] : (args.names as string[]);
|
const wantsStar = names === "all";
|
||||||
|
const wantNames = names === "all" ? [] : names;
|
||||||
|
|
||||||
const existing = sf
|
const existing = sf
|
||||||
.getExportDeclarations()
|
.getExportDeclarations()
|
||||||
|
|||||||
@@ -2,7 +2,7 @@ import fs from "node:fs";
|
|||||||
import os from "node:os";
|
import os from "node:os";
|
||||||
import path from "node:path";
|
import path from "node:path";
|
||||||
|
|
||||||
import { describe, expect, it, beforeEach } from "vitest";
|
import { describe, expect, it, beforeEach } from "vite-plus/test";
|
||||||
|
|
||||||
import { addEnvVar, removeEnvVar } from "./env";
|
import { addEnvVar, removeEnvVar } from "./env";
|
||||||
|
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
import { Project } from "ts-morph";
|
import { Project } from "ts-morph";
|
||||||
import { describe, expect, it } from "vitest";
|
import { describe, expect, it } from "vite-plus/test";
|
||||||
|
|
||||||
import { addNamedImport, addDefaultImport, removeImport } from "./imports";
|
import { addNamedImport, addDefaultImport, removeImport } from "./imports";
|
||||||
|
|
||||||
|
|||||||
@@ -2,7 +2,7 @@ import fs from "node:fs";
|
|||||||
import os from "node:os";
|
import os from "node:os";
|
||||||
import path from "node:path";
|
import path from "node:path";
|
||||||
|
|
||||||
import { describe, expect, it, beforeEach } from "vitest";
|
import { describe, expect, it, beforeEach } from "vite-plus/test";
|
||||||
|
|
||||||
import { addPackageDependency, addPackageScript, mergeJson, removePackageDependency } from "./json";
|
import { addPackageDependency, addPackageScript, mergeJson, removePackageDependency } from "./json";
|
||||||
|
|
||||||
|
|||||||
@@ -1,7 +1,18 @@
|
|||||||
import fs from "node:fs";
|
import fs from "node:fs";
|
||||||
|
|
||||||
export function readJson<T = unknown>(path: string): T {
|
/** Narrow an unknown JSON value to a plain object (excludes arrays and null). */
|
||||||
return JSON.parse(fs.readFileSync(path, "utf8")) as T;
|
function isRecord(value: unknown): value is Record<string, unknown> {
|
||||||
|
return typeof value === "object" && value !== null && !Array.isArray(value);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function readJson(path: string): unknown {
|
||||||
|
return JSON.parse(fs.readFileSync(path, "utf8"));
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Read a JSON file expected to hold an object, falling back to `{}` otherwise. */
|
||||||
|
function readRecord(path: string): Record<string, unknown> {
|
||||||
|
const value = readJson(path);
|
||||||
|
return isRecord(value) ? value : {};
|
||||||
}
|
}
|
||||||
|
|
||||||
export function writeJson(path: string, value: unknown): void {
|
export function writeJson(path: string, value: unknown): void {
|
||||||
@@ -13,9 +24,8 @@ export function writeJson(path: string, value: unknown): void {
|
|||||||
* Returns the dot-paths that were created or changed (for region claiming).
|
* Returns the dot-paths that were created or changed (for region claiming).
|
||||||
*/
|
*/
|
||||||
export function mergeJson(path: string, patch: Record<string, unknown>): string[] {
|
export function mergeJson(path: string, patch: Record<string, unknown>): string[] {
|
||||||
const original = readJson<Record<string, unknown>>(path);
|
|
||||||
const touched: string[] = [];
|
const touched: string[] = [];
|
||||||
const merged = mergeRecurse(original, patch, "", touched);
|
const merged = mergeRecurse(readRecord(path), patch, "", touched);
|
||||||
writeJson(path, merged);
|
writeJson(path, merged);
|
||||||
return touched;
|
return touched;
|
||||||
}
|
}
|
||||||
@@ -29,20 +39,9 @@ function mergeRecurse(
|
|||||||
const out: Record<string, unknown> = { ...target };
|
const out: Record<string, unknown> = { ...target };
|
||||||
for (const [k, v] of Object.entries(source)) {
|
for (const [k, v] of Object.entries(source)) {
|
||||||
const path = prefix ? `${prefix}.${k}` : k;
|
const path = prefix ? `${prefix}.${k}` : k;
|
||||||
if (
|
const existing = out[k];
|
||||||
v !== null &&
|
if (isRecord(v) && isRecord(existing)) {
|
||||||
typeof v === "object" &&
|
out[k] = mergeRecurse(existing, v, path, touched);
|
||||||
!Array.isArray(v) &&
|
|
||||||
typeof out[k] === "object" &&
|
|
||||||
out[k] !== null &&
|
|
||||||
!Array.isArray(out[k])
|
|
||||||
) {
|
|
||||||
out[k] = mergeRecurse(
|
|
||||||
out[k] as Record<string, unknown>,
|
|
||||||
v as Record<string, unknown>,
|
|
||||||
path,
|
|
||||||
touched,
|
|
||||||
);
|
|
||||||
} else {
|
} else {
|
||||||
if (out[k] !== v) touched.push(path);
|
if (out[k] !== v) touched.push(path);
|
||||||
out[k] = v;
|
out[k] = v;
|
||||||
@@ -52,36 +51,45 @@ function mergeRecurse(
|
|||||||
}
|
}
|
||||||
|
|
||||||
export function setJsonPath(file: string, dotPath: string, value: unknown): void {
|
export function setJsonPath(file: string, dotPath: string, value: unknown): void {
|
||||||
const root = readJson<Record<string, unknown>>(file);
|
const root = readRecord(file);
|
||||||
setPath(root, dotPath, value);
|
setPath(root, dotPath, value);
|
||||||
writeJson(file, root);
|
writeJson(file, root);
|
||||||
}
|
}
|
||||||
|
|
||||||
export function unsetJsonPath(file: string, dotPath: string): void {
|
export function unsetJsonPath(file: string, dotPath: string): void {
|
||||||
const root = readJson<Record<string, unknown>>(file);
|
const root = readRecord(file);
|
||||||
unsetPath(root, dotPath);
|
unsetPath(root, dotPath);
|
||||||
writeJson(file, root);
|
writeJson(file, root);
|
||||||
}
|
}
|
||||||
|
|
||||||
function setPath(root: Record<string, unknown>, dotPath: string, value: unknown): void {
|
function setPath(root: Record<string, unknown>, dotPath: string, value: unknown): void {
|
||||||
const parts = dotPath.split(".");
|
const parts = dotPath.split(".");
|
||||||
// parts is non-empty by construction (dotPath is non-empty caller contract)
|
const last = parts.pop();
|
||||||
const last = parts.pop() as string;
|
// dotPath is non-empty by caller contract, so `parts` was non-empty.
|
||||||
let node: Record<string, unknown> = root;
|
if (last === undefined) return;
|
||||||
|
let node = root;
|
||||||
for (const p of parts) {
|
for (const p of parts) {
|
||||||
if (typeof node[p] !== "object" || node[p] === null) node[p] = {};
|
const next = node[p];
|
||||||
node = node[p] as Record<string, unknown>;
|
if (isRecord(next)) {
|
||||||
|
node = next;
|
||||||
|
} else {
|
||||||
|
const created: Record<string, unknown> = {};
|
||||||
|
node[p] = created;
|
||||||
|
node = created;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
node[last] = value;
|
node[last] = value;
|
||||||
}
|
}
|
||||||
|
|
||||||
function unsetPath(root: Record<string, unknown>, dotPath: string): void {
|
function unsetPath(root: Record<string, unknown>, dotPath: string): void {
|
||||||
const parts = dotPath.split(".");
|
const parts = dotPath.split(".");
|
||||||
const last = parts.pop() as string;
|
const last = parts.pop();
|
||||||
let node: Record<string, unknown> = root;
|
if (last === undefined) return;
|
||||||
|
let node = root;
|
||||||
for (const p of parts) {
|
for (const p of parts) {
|
||||||
if (typeof node[p] !== "object" || node[p] === null) return;
|
const next = node[p];
|
||||||
node = node[p] as Record<string, unknown>;
|
if (!isRecord(next)) return;
|
||||||
|
node = next;
|
||||||
}
|
}
|
||||||
delete node[last];
|
delete node[last];
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -23,21 +23,20 @@
|
|||||||
],
|
],
|
||||||
"type": "module",
|
"type": "module",
|
||||||
"publishConfig": {
|
"publishConfig": {
|
||||||
|
"access": "public",
|
||||||
"bin": {
|
"bin": {
|
||||||
"create-stanza": "./dist/bin.mjs"
|
"create-stanza": "./dist/bin.mjs"
|
||||||
},
|
}
|
||||||
"access": "public"
|
|
||||||
},
|
},
|
||||||
"scripts": {
|
"scripts": {
|
||||||
"build": "tsdown",
|
"build": "vp pack"
|
||||||
"check-types": "tsc --noEmit"
|
|
||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"stanza-cli": "workspace:*"
|
"stanza-cli": "workspace:*"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@types/node": "^25.9.1",
|
"@types/node": "^25.9.1",
|
||||||
"tsdown": "^0.22.0",
|
"typescript": "^6.0.3",
|
||||||
"typescript": "^6.0.3"
|
"vite-plus": "catalog:"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,16 +0,0 @@
|
|||||||
import { defineConfig } from "tsdown";
|
|
||||||
|
|
||||||
/**
|
|
||||||
* `create-stanza` is a thin shim over `stanza-cli`. We compile its TS to
|
|
||||||
* ESM but externalize EVERYTHING — including `stanza-cli` itself, which
|
|
||||||
* the user installs alongside `create-stanza` via npm's normal dep chain
|
|
||||||
* (it's in `dependencies`). Inlining `stanza-cli` here would also pull in
|
|
||||||
* its transitive 12 MB of ts-morph and friends.
|
|
||||||
*/
|
|
||||||
export default defineConfig({
|
|
||||||
entry: ["./src/bin.ts"],
|
|
||||||
format: "esm",
|
|
||||||
target: "node22",
|
|
||||||
platform: "node",
|
|
||||||
clean: true,
|
|
||||||
});
|
|
||||||
@@ -0,0 +1,18 @@
|
|||||||
|
import { defineConfig } from "vite-plus";
|
||||||
|
|
||||||
|
export default defineConfig({
|
||||||
|
/**
|
||||||
|
* `create-stanza` is a thin shim over `stanza-cli`. We compile its TS to
|
||||||
|
* ESM but externalize EVERYTHING — including `stanza-cli` itself, which
|
||||||
|
* the user installs alongside `create-stanza` via npm's normal dep chain
|
||||||
|
* (it's in `dependencies`). Inlining `stanza-cli` here would also pull in
|
||||||
|
* its transitive 12 MB of ts-morph and friends.
|
||||||
|
*/
|
||||||
|
pack: {
|
||||||
|
entry: ["./src/bin.ts"],
|
||||||
|
format: "esm",
|
||||||
|
target: "node22",
|
||||||
|
platform: "node",
|
||||||
|
clean: true,
|
||||||
|
},
|
||||||
|
});
|
||||||
@@ -12,15 +12,12 @@
|
|||||||
"./module": "./src/module.ts",
|
"./module": "./src/module.ts",
|
||||||
"./resolver": "./src/resolver.ts"
|
"./resolver": "./src/resolver.ts"
|
||||||
},
|
},
|
||||||
"scripts": {
|
|
||||||
"check-types": "tsc --noEmit",
|
|
||||||
"test": "vitest run"
|
|
||||||
},
|
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"zod": "^4.4.3"
|
"zod": "^4.4.3"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"typescript": "^6.0.3",
|
"typescript": "^6.0.3",
|
||||||
"vitest": "^4.1.7"
|
"vite-plus": "catalog:",
|
||||||
|
"vitest": "catalog:"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -19,12 +19,16 @@ export {
|
|||||||
defineModule,
|
defineModule,
|
||||||
CATEGORIES,
|
CATEGORIES,
|
||||||
KNOWN_CATEGORIES,
|
KNOWN_CATEGORIES,
|
||||||
|
isCategoryId,
|
||||||
PEER_CATEGORIES,
|
PEER_CATEGORIES,
|
||||||
PACKAGE_DIRS,
|
PACKAGE_DIRS,
|
||||||
categoryLabel,
|
categoryLabel,
|
||||||
categoryHome,
|
categoryHome,
|
||||||
categoryCardinality,
|
categoryCardinality,
|
||||||
isMulti,
|
isMulti,
|
||||||
|
ModuleSchema,
|
||||||
|
ModuleSummarySchema,
|
||||||
|
RegistryIndexSchema,
|
||||||
} from "./module";
|
} from "./module";
|
||||||
|
|
||||||
export type { StanzaManifest, StanzaModuleRecord, RegionMap, RegionOwnership } from "./manifest";
|
export type { StanzaManifest, StanzaModuleRecord, RegionMap, RegionOwnership } from "./manifest";
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
import { assert, describe, expect, it } from "vitest";
|
import { assert, describe, expect, it } from "vite-plus/test";
|
||||||
|
|
||||||
import { CURRENT_MANIFEST_VERSION, emptyManifest, StanzaManifestSchema } from "./manifest";
|
import { CURRENT_MANIFEST_VERSION, emptyManifest, StanzaManifestSchema } from "./manifest";
|
||||||
|
|
||||||
|
|||||||
@@ -113,8 +113,20 @@ export const CATEGORIES = [
|
|||||||
export type CategoryId = (typeof CATEGORIES)[number]["id"];
|
export type CategoryId = (typeof CATEGORIES)[number]["id"];
|
||||||
|
|
||||||
/** Category ids as a non-empty tuple — the shape `z.enum` needs. Also the processing order. */
|
/** Category ids as a non-empty tuple — the shape `z.enum` needs. Also the processing order. */
|
||||||
export const KNOWN_CATEGORIES = CATEGORIES.map((c) => c.id) as [CategoryId, ...CategoryId[]];
|
const [firstCategory, ...restCategories] = CATEGORIES;
|
||||||
|
export const KNOWN_CATEGORIES: [CategoryId, ...CategoryId[]] = [
|
||||||
|
firstCategory.id,
|
||||||
|
...restCategories.map((c) => c.id),
|
||||||
|
];
|
||||||
|
|
||||||
|
/** Runtime guard narrowing an arbitrary string to a known `CategoryId`. */
|
||||||
|
export function isCategoryId(value: string): value is CategoryId {
|
||||||
|
return KNOWN_CATEGORIES.some((id) => id === value);
|
||||||
|
}
|
||||||
|
|
||||||
|
// `Object.fromEntries` widens keys to `string`; CATEGORIES covers every
|
||||||
|
// CategoryId by construction, so narrowing to the exhaustive record is sound.
|
||||||
|
// oxlint-disable-next-line typescript/no-unsafe-type-assertion
|
||||||
const CATEGORY_BY_ID = Object.fromEntries(CATEGORIES.map((c) => [c.id, c])) as Record<
|
const CATEGORY_BY_ID = Object.fromEntries(CATEGORIES.map((c) => [c.id, c])) as Record<
|
||||||
CategoryId,
|
CategoryId,
|
||||||
Category
|
Category
|
||||||
@@ -346,7 +358,8 @@ const adapterSchema = z.object({
|
|||||||
...installFieldsSchema,
|
...installFieldsSchema,
|
||||||
});
|
});
|
||||||
|
|
||||||
export const ModuleSchema = z.object({
|
/** Shape shared by a full `Module` and its index `ModuleSummary` (all but `adapters`). */
|
||||||
|
const moduleBaseShape = {
|
||||||
category: z.enum(KNOWN_CATEGORIES),
|
category: z.enum(KNOWN_CATEGORIES),
|
||||||
id: z.string(),
|
id: z.string(),
|
||||||
label: z.string(),
|
label: z.string(),
|
||||||
@@ -358,8 +371,39 @@ export const ModuleSchema = z.object({
|
|||||||
.optional(),
|
.optional(),
|
||||||
consumesPackages: z.array(z.string()).optional(),
|
consumesPackages: z.array(z.string()).optional(),
|
||||||
...installFieldsSchema,
|
...installFieldsSchema,
|
||||||
adapters: z.array(adapterSchema),
|
|
||||||
homepage: z.string().optional(),
|
homepage: z.string().optional(),
|
||||||
author: z.string().optional(),
|
author: z.string().optional(),
|
||||||
logo: z.union([z.string(), z.object({ light: z.string(), dark: z.string() })]).optional(),
|
logo: z.union([z.string(), z.object({ light: z.string(), dark: z.string() })]).optional(),
|
||||||
|
};
|
||||||
|
|
||||||
|
export const ModuleSchema = z.object({
|
||||||
|
...moduleBaseShape,
|
||||||
|
adapters: z.array(adapterSchema),
|
||||||
}) satisfies z.ZodType<Module>;
|
}) satisfies z.ZodType<Module>;
|
||||||
|
|
||||||
|
/** Category metadata as served in the registry `index.json`. */
|
||||||
|
const categorySchema = z.object({
|
||||||
|
id: z.enum(KNOWN_CATEGORIES),
|
||||||
|
label: z.string(),
|
||||||
|
description: z.string(),
|
||||||
|
cardinality: z.enum(["one", "many"]),
|
||||||
|
home: z.union([
|
||||||
|
z.object({ kind: z.literal("app") }),
|
||||||
|
z.object({ kind: z.literal("repo") }),
|
||||||
|
z.object({ kind: z.literal("package"), dir: z.string() }),
|
||||||
|
]),
|
||||||
|
}) satisfies z.ZodType<Category>;
|
||||||
|
|
||||||
|
/** Index summary: full module metadata with adapters reduced to `key` + `match`. */
|
||||||
|
export const ModuleSummarySchema = z.object({
|
||||||
|
...moduleBaseShape,
|
||||||
|
adapters: z.array(z.object({ key: z.string(), match: z.record(z.string(), z.string()) })),
|
||||||
|
}) satisfies z.ZodType<ModuleSummary>;
|
||||||
|
|
||||||
|
/** Runtime-validatable schema for the registry `index.json` payload. */
|
||||||
|
export const RegistryIndexSchema = z.object({
|
||||||
|
generatedAt: z.string(),
|
||||||
|
schemaVersion: z.literal(1),
|
||||||
|
categories: z.array(categorySchema),
|
||||||
|
modules: z.array(ModuleSummarySchema),
|
||||||
|
}) satisfies z.ZodType<RegistryIndex>;
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
import { assert, describe, expect, it } from "vitest";
|
import { assert, describe, expect, it } from "vite-plus/test";
|
||||||
|
|
||||||
import { emptyManifest } from "./manifest";
|
import { emptyManifest } from "./manifest";
|
||||||
import { defineModule, type Module } from "./module";
|
import { defineModule, type Module } from "./module";
|
||||||
|
|||||||
@@ -103,9 +103,10 @@ function matchSpecificity(
|
|||||||
peers: Partial<Record<CategoryId, string>>,
|
peers: Partial<Record<CategoryId, string>>,
|
||||||
): number {
|
): number {
|
||||||
let score = 0;
|
let score = 0;
|
||||||
for (const [category, required] of Object.entries(adapter.match) as [CategoryId, string][]) {
|
for (const category of KNOWN_CATEGORIES) {
|
||||||
const actual = peers[category];
|
const required = adapter.match[category];
|
||||||
if (actual !== required) return -1;
|
if (required === undefined) continue;
|
||||||
|
if (peers[category] !== required) return -1;
|
||||||
score += 1;
|
score += 1;
|
||||||
}
|
}
|
||||||
return score;
|
return score;
|
||||||
|
|||||||
Generated
+705
-686
File diff suppressed because it is too large
Load Diff
+14
-3
@@ -2,14 +2,25 @@ packages:
|
|||||||
- apps/*
|
- apps/*
|
||||||
- packages/*
|
- packages/*
|
||||||
- registry/modules/*
|
- registry/modules/*
|
||||||
|
catalog:
|
||||||
|
vite: npm:@voidzero-dev/vite-plus-core@^0.1.22
|
||||||
|
vite-plus: ^0.1.22
|
||||||
|
vitest: npm:@voidzero-dev/vite-plus-test@^0.1.22
|
||||||
ignoredBuiltDependencies:
|
ignoredBuiltDependencies:
|
||||||
- sharp
|
- sharp
|
||||||
|
|
||||||
ignoredOptionalDependencies: []
|
ignoredOptionalDependencies: []
|
||||||
|
|
||||||
onlyBuiltDependencies:
|
onlyBuiltDependencies:
|
||||||
- core-js
|
- core-js
|
||||||
- esbuild
|
- esbuild
|
||||||
- msw
|
- msw
|
||||||
- protobufjs
|
- protobufjs
|
||||||
|
overrides:
|
||||||
|
vite: "catalog:"
|
||||||
|
vitest: "catalog:"
|
||||||
|
peerDependencyRules:
|
||||||
|
allowAny:
|
||||||
|
- vite
|
||||||
|
- vitest
|
||||||
|
allowedVersions:
|
||||||
|
vite: "*"
|
||||||
|
vitest: "*"
|
||||||
|
|||||||
@@ -34,7 +34,8 @@ async function main() {
|
|||||||
const summaries = [];
|
const summaries = [];
|
||||||
for (const dir of dirs) {
|
for (const dir of dirs) {
|
||||||
const entry = path.join(modulesDir, dir, "module.ts");
|
const entry = path.join(modulesDir, dir, "module.ts");
|
||||||
const mod = ((await import(entry)) as { default: Module }).default;
|
const imported: { default: Module } = await import(entry);
|
||||||
|
const mod = imported.default;
|
||||||
if (!mod) throw new Error(`Module ${dir} has no default export.`);
|
if (!mod) throw new Error(`Module ${dir} has no default export.`);
|
||||||
|
|
||||||
// Inline each template's file contents. The per-module JSON is the
|
// Inline each template's file contents. The per-module JSON is the
|
||||||
|
|||||||
-25
@@ -1,25 +0,0 @@
|
|||||||
{
|
|
||||||
"$schema": "https://turborepo.com/schema.json",
|
|
||||||
"ui": "stream",
|
|
||||||
"tasks": {
|
|
||||||
"build": {
|
|
||||||
"dependsOn": ["^build"],
|
|
||||||
"outputs": ["dist/**", ".output/**", ".vercel/output/**"]
|
|
||||||
},
|
|
||||||
"dev": {
|
|
||||||
"cache": false,
|
|
||||||
"persistent": true
|
|
||||||
},
|
|
||||||
"test": {
|
|
||||||
"dependsOn": ["^build"],
|
|
||||||
"outputs": []
|
|
||||||
},
|
|
||||||
"lint": {
|
|
||||||
"outputs": []
|
|
||||||
},
|
|
||||||
"check-types": {
|
|
||||||
"dependsOn": ["^build"],
|
|
||||||
"outputs": []
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
+106
@@ -0,0 +1,106 @@
|
|||||||
|
import { defineConfig } from "vite-plus";
|
||||||
|
|
||||||
|
export default defineConfig({
|
||||||
|
test: {
|
||||||
|
// apps/web is intentionally omitted: its plugin-heavy SSR config trips a
|
||||||
|
// vite-plus alpha bug in the workspace project loader (`runner.config` is
|
||||||
|
// undefined during suite collection — same class as voidzero-dev/vite-plus#1076).
|
||||||
|
// Re-add it here once that's fixed upstream; it passes when run on its own.
|
||||||
|
projects: ["apps/*", "packages/*", "!apps/web"],
|
||||||
|
},
|
||||||
|
staged: {
|
||||||
|
"*": "vp check --fix",
|
||||||
|
},
|
||||||
|
fmt: {
|
||||||
|
sortImports: {},
|
||||||
|
sortTailwindcss: {
|
||||||
|
stylesheet: "apps/web/src/styles.css",
|
||||||
|
functions: ["clsx", "cn", "cva", "tw"],
|
||||||
|
preserveDuplicates: false,
|
||||||
|
preserveWhitespace: false,
|
||||||
|
},
|
||||||
|
sortPackageJson: true,
|
||||||
|
ignorePatterns: [
|
||||||
|
"**/node_modules/**",
|
||||||
|
"**/dist/**",
|
||||||
|
"**/.output/**",
|
||||||
|
"**/routeTree.gen.ts",
|
||||||
|
"**/coverage/**",
|
||||||
|
"apps/web/public/registry/**",
|
||||||
|
"registry/modules/*/logo*.svg",
|
||||||
|
"registry/modules/*/templates/**",
|
||||||
|
],
|
||||||
|
},
|
||||||
|
lint: {
|
||||||
|
plugins: ["oxc", "eslint", "typescript", "unicorn", "import", "promise", "vitest"],
|
||||||
|
categories: {
|
||||||
|
correctness: "error",
|
||||||
|
suspicious: "warn",
|
||||||
|
perf: "warn",
|
||||||
|
},
|
||||||
|
rules: {
|
||||||
|
"no-console": "off",
|
||||||
|
"no-empty": [
|
||||||
|
"error",
|
||||||
|
{
|
||||||
|
allowEmptyCatch: true,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
"no-await-in-loop": "off",
|
||||||
|
"unicorn/no-null": "off",
|
||||||
|
"unicorn/filename-case": "off",
|
||||||
|
"unicorn/no-array-reduce": "off",
|
||||||
|
"vite-plus/prefer-vite-plus-imports": "error",
|
||||||
|
},
|
||||||
|
ignorePatterns: [
|
||||||
|
"**/node_modules/**",
|
||||||
|
"**/dist/**",
|
||||||
|
"**/.output/**",
|
||||||
|
"**/*.gen.ts",
|
||||||
|
"**/coverage/**",
|
||||||
|
"registry/modules/*/templates/**",
|
||||||
|
],
|
||||||
|
overrides: [
|
||||||
|
{
|
||||||
|
files: ["**/*.test.ts", "**/*.test.tsx"],
|
||||||
|
rules: {
|
||||||
|
"typescript/no-explicit-any": "off",
|
||||||
|
// Codemod apply/revert are typed `Promise<R> | R` so authors can write
|
||||||
|
// async codemods, but the builtins under test are synchronous and the
|
||||||
|
// suites call them for their side effects — no promise to await.
|
||||||
|
"typescript/no-floating-promises": "off",
|
||||||
|
// Test setup constructs mock objects/results and casts them into shape.
|
||||||
|
"typescript/no-unsafe-type-assertion": "off",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
files: ["apps/web/**/*.tsx"],
|
||||||
|
plugins: [
|
||||||
|
"oxc",
|
||||||
|
"eslint",
|
||||||
|
"typescript",
|
||||||
|
"react",
|
||||||
|
"react-perf",
|
||||||
|
"promise",
|
||||||
|
"jsx-a11y",
|
||||||
|
"unicorn",
|
||||||
|
"import",
|
||||||
|
"vitest",
|
||||||
|
],
|
||||||
|
rules: {
|
||||||
|
"react/react-in-jsx-scope": "off",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
],
|
||||||
|
options: {
|
||||||
|
typeAware: true,
|
||||||
|
typeCheck: true,
|
||||||
|
},
|
||||||
|
jsPlugins: [
|
||||||
|
{
|
||||||
|
name: "vite-plus",
|
||||||
|
specifier: "vite-plus/oxlint-plugin",
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
});
|
||||||
Reference in New Issue
Block a user