refactor: extract @withstanza/schema and @withstanza/utils packages (#16)

This commit is contained in:
2026-05-30 21:47:16 -04:00
committed by GitHub
parent 06bd24838c
commit ea2d8c45bd
153 changed files with 1003 additions and 776 deletions
+10
View File
@@ -0,0 +1,10 @@
---
"@withstanza/schema": minor
"stanza-cli": patch
---
Extract the schema/contract layer into a standalone, npm-published `@withstanza/schema` package.
`@withstanza/schema` now owns the `stanza.json` manifest schema, the registry module/index schemas, the contract types, the canonical `CATEGORIES` taxonomy, and the package-manager + registry-config schemas — everything previously bundled into the private `@withstanza/registry`. It's published so third-party registry authors and editor tooling can validate against the exact same Zod source of truth the CLI uses; `StanzaManifestSchema` backs the JSON Schema served at <https://stanza.tools/schema.json>. A new private `@withstanza/utils` package holds the shared path-safety (`safeRelativePath`) and env-file (`appendEnvVar`) helpers.
No change to CLI behavior — this is an internal restructure. `@withstanza/registry` keeps only the resolver, install-field synthesis, and template rendering, depending on `@withstanza/schema`. The static registry build moves out of the registry package to the standalone `scripts/compile-registry.ts` (writing `index.json` + `modules/*.json` directly under its output dir, no `registry/` wrapper), and the manifest JSON Schema is served by the web app's `/schema.json` route rather than emitted as a build artifact.
+2 -4
View File
@@ -6,7 +6,6 @@ dist/
*.tsbuildinfo *.tsbuildinfo
.tanstack/ .tanstack/
.source/ .source/
.vercel
# IDE # IDE
.vscode/* .vscode/*
@@ -31,6 +30,5 @@ Thumbs.db
coverage/ coverage/
__snapshots__/.tmp/ __snapshots__/.tmp/
# misc # Vercel
apps/web/public/registry/ .vercel
apps/web/public/schema.json
+14 -11
View File
@@ -1,15 +1,18 @@
# Stanza # Stanza
Shadcn-style CLI that assembles modular full-stack TS monorepos. Two unusual properties: (1) generated code is vendored verbatim — no `@stanza/runtime` dep — and (2) `stanza add` works on existing projects, not just at init. Verbs today: `init`, `add`, `remove`, `list`, `search` (`swap` + `update` planned — manifest already reserves `modules[category][].version` and `regions`). The canonical category list lives in [packages/registry/src/module.ts](packages/registry/src/module.ts) (`CATEGORIES`); module roadmap in [registry.mdx](apps/web/content/docs/registry.mdx). Shadcn-style CLI that assembles modular full-stack TS monorepos. Two unusual properties: (1) generated code is vendored verbatim — no `@stanza/runtime` dep — and (2) `stanza add` works on existing projects, not just at init. Verbs today: `init`, `add`, `remove`, `list`, `search` (`swap` + `update` planned — manifest already reserves `modules[category][].version` and `regions`). The canonical category list lives in [packages/schema/src/category.ts](packages/schema/src/category.ts) (`CATEGORIES`); module roadmap in [registry.mdx](apps/web/content/docs/registry.mdx).
## Layout ## Layout
- `apps/cli/``stanza-cli`, entry `src/bin.ts` (publishable, ESM via tsdown) - `apps/cli/``stanza-cli`, entry `src/bin.ts` (publishable, ESM via tsdown)
- `apps/web/``@stanza/web`, TanStack Start visual builder (private, deployed to Vercel) - `apps/web/``@withstanza/web`, TanStack Start visual builder (private, deployed to Vercel)
- `packages/registry/` schema, resolver, manifest validator (private; inlined into CLI bundle) - `packages/schema/``@withstanza/schema`: the `stanza.json` + module schemas, contract types, `CATEGORIES` list, package-manager + registry-config schemas (publishable; also inlined into CLI bundle). The single Zod source of truth — `StanzaManifestSchema` backs the published JSON Schema
- `packages/registry/``@withstanza/registry`: peer/adapter resolver + `package.json`/env/README/template synthesis (private; inlined into CLI bundle). Depends on `@withstanza/schema` + `@withstanza/utils`
- `packages/codemods/` — ts-morph helpers, idempotent + reversible (private; inlined) - `packages/codemods/` — ts-morph helpers, idempotent + reversible (private; inlined)
- `packages/utils/``@withstanza/utils`: path-safety (`safeRelativePath`) + env-file (`appendEnvVar`) helpers shared by schema/registry/codemods (private; inlined)
- `packages/create-stanza/``pnpm create stanza` shim (publishable) - `packages/create-stanza/``pnpm create stanza` shim (publishable)
- `registry/modules/<category>-<id>/` — first-party modules: `module.ts` + `templates/` (private; data, not code) - `registry/modules/<category>-<id>/` — first-party modules: `module.ts` + `templates/` (private; data, not code)
- `scripts/compile-registry.ts` — standalone static-registry build (not part of any package; imports `@withstanza/schema`). Run via `jiti`; exports `compileRegistry()` for in-process callers
- `skills/stanza-cli/SKILL.md` — agent skill for the published CLI. Update whenever the public CLI contract changes; agents using this skill won't have the source repo - `skills/stanza-cli/SKILL.md` — agent skill for the published CLI. Update whenever the public CLI contract changes; agents using this skill won't have the source repo
## Commands ## Commands
@@ -20,10 +23,10 @@ The repo runs on the **Vite+ toolchain** (`vp`). All config lives in the root [`
- `vp check` — format + lint + type-check (oxfmt + oxlint + type-aware `tsgolint`). **Use this for validation loops.** `--fix` autofixes - `vp check` — format + lint + type-check (oxfmt + oxlint + type-aware `tsgolint`). **Use this for validation loops.** `--fix` autofixes
- `vp test` — Vitest 4 via `vite-plus/test`; project selection via `test.projects` in root config - `vp test` — Vitest 4 via `vite-plus/test`; project selection via `test.projects` in root config
- `vp run -r build` — build everything (`vp pack` per package, wraps tsdown) - `vp run -r build` — build everything (`vp pack` per package, wraps tsdown)
- `vp run @stanza/web#dev` — TanStack Start dev server. Its `dev`/`build` scripts first run the `compile-registry` vp task (defined under `run.tasks` in [`apps/web/vite.config.ts`](apps/web/vite.config.ts)) → `jiti packages/registry/src/build.ts apps/web/public`, emitting `apps/web/public/registry/{index,modules/*}.json` + `apps/web/public/schema.json` - `vp run @withstanza/web#dev` — TanStack Start dev server. Its `dev`/`build` scripts first run the `compile-registry` vp task (defined under `run.tasks` in [`apps/web/vite.config.ts`](apps/web/vite.config.ts)) → `jiti scripts/compile-registry.ts apps/web/public/registry`, emitting `apps/web/public/registry/{index.json,modules/*.json}`. The manifest JSON Schema is no longer a build artifact — it's served on request by the `/schema.json` route (`apps/web/src/routes/schema[.]json.ts`)
- `src/routeTree.gen.ts` is generated by `vp run @stanza/web#build` — run it before the first `vp check` if missing - `src/routeTree.gen.ts` is generated by `vp run @withstanza/web#build` — run it before the first `vp check` if missing
- E2E smoke: build the registry (`jiti packages/registry/src/build.ts $TMPDIR/reg`), seed `$TMPDIR/x` with `stanza.json` + `apps/web/package.json`, then `STANZA_REGISTRY=$TMPDIR/reg/registry/index.json tsx apps/cli/src/bin.ts add <category> <module>`. The CLI reads a built registry **main file** (full path/URL, any filename) — there is no source-tree loader; `STANZA_REGISTRY` unset hits the production registry - E2E smoke: build the registry (`jiti scripts/compile-registry.ts $TMPDIR/reg` → writes `$TMPDIR/reg/index.json` + `$TMPDIR/reg/modules/*.json` directly under the out dir, no `registry/` wrapper), seed `$TMPDIR/x` with `stanza.json` + `apps/web/package.json`, then `STANZA_REGISTRY=$TMPDIR/reg/index.json tsx apps/cli/src/bin.ts add <category> <module>`. The CLI reads a built registry **main file** (full path/URL, any filename) — there is no source-tree loader; `STANZA_REGISTRY` unset hits the production registry
- `pnpm changeset` — drop a markdown file after a substantive PR; the release workflow handles versioning + publish. Only `stanza-cli` + `create-stanza` ship to npm - `pnpm changeset` — drop a markdown file after a substantive PR; the release workflow handles versioning + publish. `stanza-cli`, `create-stanza`, and `@withstanza/schema` ship to npm; every other workspace is private
Use `agent-browser` for web automation (`agent-browser --help`); prefer it over built-in browser tools. Use `agent-browser` for web automation (`agent-browser --help`); prefer it over built-in browser tools.
@@ -43,16 +46,16 @@ Use `agent-browser` for web automation (`agent-browser --help`); prefer it over
- **Adding a generic codemod**: drop `<id>.ts` under `builtins/`, default-export a `Codemod<TArgs>`, register in [`builtins/index.ts`](packages/codemods/src/builtins/index.ts). Module-specific identifiers don't belong — factor them into args - **Adding a generic codemod**: drop `<id>.ts` under `builtins/`, default-export a `Codemod<TArgs>`, register in [`builtins/index.ts`](packages/codemods/src/builtins/index.ts). Module-specific identifiers don't belong — factor them into args
- **Codemod catalog ids are part of the public contract.** Once published, renaming an id breaks third-party manifests. Treat ids like npm package names — additions are free, renames need a deprecation cycle, removals need a manifest schema version bump - **Codemod catalog ids are part of the public contract.** Once published, renaming an id breaks third-party manifests. Treat ids like npm package names — additions are free, renames need a deprecation cycle, removals need a manifest schema version bump
- **Third-party codemods are deferred** until a sandboxed-execution + signing model lands. Third-party modules can invoke existing catalog ids but can't add new ones - **Third-party codemods are deferred** until a sandboxed-execution + signing model lands. Third-party modules can invoke existing catalog ids but can't add new ones
- **Template distribution**: [`packages/registry/src/build.ts`](packages/registry/src/build.ts) inlines each template file's contents into `templates[].content` so HTTP-loaded manifests are self-contained. The runner prefers `tpl.content` and only reads from `templates/` when absent (local dev). New templates need no build wiring - **Template distribution**: [`scripts/compile-registry.ts`](scripts/compile-registry.ts) (the standalone registry build) inlines each template file's contents into `templates[].content` so HTTP-loaded manifests are self-contained. The runner prefers `tpl.content` and only reads from `templates/` when absent (local dev). New templates need no build wiring
- **Categories** carry orthogonal **`cardinality`** (`one` / `many`) and **`home`** (`app` / `repo` / `package`); both are declared on each entry in `CATEGORIES`. Adding a category is a one-line edit there — `KNOWN_CATEGORIES`, `PEER_CATEGORIES`, `PACKAGE_DIRS`, and `categoryHome()`/`categoryLabel()` all derive. Peer-resolution iterates **`PEER_CATEGORIES`** (the `one`-cardinality ids) only — a `many` category never participates in others' dispatch. `CATEGORIES` order is topological (each category appears after everything it peers on; `many` categories last) - **Categories** carry orthogonal **`cardinality`** (`one` / `many`) and **`home`** (`app` / `repo` / `package`); both are declared on each entry in `CATEGORIES`. Adding a category is a one-line edit there — `KNOWN_CATEGORIES`, `PEER_CATEGORIES`, `PACKAGE_DIRS`, and `categoryHome()`/`categoryLabel()` all derive. Peer-resolution iterates **`PEER_CATEGORIES`** (the `one`-cardinality ids) only — a `many` category never participates in others' dispatch. `CATEGORIES` order is topological (each category appears after everything it peers on; `many` categories last)
- **Manifest layout**: selections live in one `modules` record keyed by `CategoryId`, each an array of `StanzaModuleRecord`. Records carry optional `apps?: string[]`**required** for `home: app`, **optional** for `home: package` (omitted means "ship app-scoped shims into every app"; an explicit list restricts), **forbidden** for `home: repo`. The app `id` doubles as the workspace package suffix (`id: "web"``@<project>/web`, regardless of `dir`). `cardinality: "one"` enforcement is **per-app** for `home: app` categories (`selectedOne(m, cat, appId)`), per-project otherwise - **Manifest layout**: selections live in one `modules` record keyed by `CategoryId`, each an array of `StanzaModuleRecord`. Records carry optional `apps?: string[]`**required** for `home: app`, **optional** for `home: package` (omitted means "ship app-scoped shims into every app"; an explicit list restricts), **forbidden** for `home: repo`. The app `id` doubles as the workspace package suffix (`id: "web"``@<project>/web`, regardless of `dir`). `cardinality: "one"` enforcement is **per-app** for `home: app` categories (`selectedOne(m, cat, appId)`), per-project otherwise
- **Install home routing**: `categoryHome(id)` is the single decision point, read by both the CLI runner and the web's `synthesizePackageJsons`. `home: package``packages/<dir>/` workspace package named `@<manifest.name>/<dir>` (auto-bootstrapped by `ensureSlotPackage`); every consuming app gets a `workspace:*` dep. `home: repo` → repo-root config + scripts in root `package.json`. `home: app` → the targeted app - **Install home routing**: `categoryHome(id)` is the single decision point, read by both the CLI runner and the web's `synthesizePackageJsons`. `home: package``packages/<dir>/` workspace package named `@<manifest.name>/<dir>` (auto-bootstrapped by `ensureSlotPackage`); every consuming app gets a `workspace:*` dep. `home: repo` → repo-root config + scripts in root `package.json`. `home: app` → the targeted app
- **Generated projects don't share a tsconfig base.** Every `apps/*/tsconfig.json` and `packages/*/tsconfig.json` is self-contained. The Stanza repo's root `tsconfig.json` exists only for this repo — never emit one into generated trees - **Generated projects don't share a tsconfig base.** Every `apps/*/tsconfig.json` and `packages/*/tsconfig.json` is self-contained. The Stanza repo's root `tsconfig.json` exists only for this repo — never emit one into generated trees
- **Cross-package wiring**: declare `consumesPackages: ["<dir>"]` at the **module level** (not per-adapter) when source imports another internal package. Reference other packages in templates / codemod `args` via `{{packages.<dir>.name}}` (e.g. `{{packages.db.name}}``@my-app/db`). Full template context: `{ project, app, package, packages }`; `app.*` is rebound per target app in the apply loop. `renderTemplate` lives in `@stanza/registry` so CLI apply and web preview produce byte-identical output - **Cross-package wiring**: declare `consumesPackages: ["<dir>"]` at the **module level** (not per-adapter) when source imports another internal package. Reference other packages in templates / codemod `args` via `{{packages.<dir>.name}}` (e.g. `{{packages.db.name}}``@my-app/db`). Full template context: `{ project, app, package, packages }`; `app.*` is rebound per target app in the apply loop. `renderTemplate` lives in `@withstanza/registry` so CLI apply and web preview produce byte-identical output
- **Region ownership** in `stanza.json` is the source of truth for `stanza remove`. Two modules claiming the same region throws `RegionConflictError`. Regions key on `module.id`, so disjoint dot-paths (vitest `scripts.test` vs playwright `scripts.test:e2e`) coexist. Package bootstrap files (`package.json`, `tsconfig.json`, workspace deps) are system-owned, not tracked in regions — `stanza remove`'s sweep over `PACKAGE_DIRS` cleans them when no claims remain. _Regions are not yet sufficient for `swap`_ — that verb needs adapter-region remapping; design pending - **Region ownership** in `stanza.json` is the source of truth for `stanza remove`. Two modules claiming the same region throws `RegionConflictError`. Regions key on `module.id`, so disjoint dot-paths (vitest `scripts.test` vs playwright `scripts.test:e2e`) coexist. Package bootstrap files (`package.json`, `tsconfig.json`, workspace deps) are system-owned, not tracked in regions — `stanza remove`'s sweep over `PACKAGE_DIRS` cleans them when no claims remain. _Regions are not yet sufficient for `swap`_ — that verb needs adapter-region remapping; design pending
- **Reserved manifest fields**: `modules[category][].version` and `regions` are written today but only fully consumed by upcoming `swap`/`update`. Don't drop them - **Reserved manifest fields**: `modules[category][].version` and `regions` are written today but only fully consumed by upcoming `swap`/`update`. Don't drop them
- **Web previews are SSR.** Shiki runs in [`highlighter.server.ts`](apps/web/src/server/highlighter.server.ts); the client-safe `Preview` type lives in [`highlighter.ts`](apps/web/src/server/highlighter.ts). Never import `shiki` from a client component. After `vp run @stanza/web#build`, grep `.output/public/assets/*.js` for the runtime (`createHighlighter`/`codeToHtml`/`loadWasm`) — must be absent. A bare `grep shiki` yields false positives because MDX docs ship statically-rendered code blocks with `class="shiki"` + CSS vars (fine) - **Web previews are SSR.** Shiki runs in [`highlighter.server.ts`](apps/web/src/server/highlighter.server.ts); the client-safe `Preview` type lives in [`highlighter.ts`](apps/web/src/server/highlighter.ts). Never import `shiki` from a client component. After `vp run @withstanza/web#build`, grep `.output/public/assets/*.js` for the runtime (`createHighlighter`/`codeToHtml`/`loadWasm`) — must be absent. A bare `grep shiki` yields false positives because MDX docs ship statically-rendered code blocks with `class="shiki"` + CSS vars (fine)
- **Web hosts the canonical registry.** The `compile-registry` task writes the registry into `apps/web/public/registry/`; the deployed site serves it at the published CLI's default `DEFAULT_REGISTRY_URL`. `STANZA_REGISTRY` (URL or FS path) overrides for self-hosters / CI. `public/registry/` is also a Nitro `serverAssets` dir (vite.config.ts) — SSR reads via `useStorage("assets:registry")` (see [`registry-base.server.ts`](apps/web/src/server/registry-base.server.ts)), which is why Vercel works where `public/` is absent from the function fs - **Web hosts the canonical registry.** The `compile-registry` task writes the registry into `apps/web/public/registry/`; the deployed site serves it at the published CLI's default `DEFAULT_REGISTRY_URL`. `STANZA_REGISTRY` (URL or FS path) overrides for self-hosters / CI. `public/registry/` is also a Nitro `serverAssets` dir (vite.config.ts) — SSR reads via `useStorage("assets:registry")` (see [`registry-base.server.ts`](apps/web/src/server/registry-base.server.ts)), which is why Vercel works where `public/` is absent from the function fs. The deployed site also serves the manifest JSON Schema at `/schema.json` (route `apps/web/src/routes/schema[.]json.ts`), compiled from `@withstanza/schema`'s `StanzaManifestSchema` on request rather than emitted to `public/` — this is the URL `MANIFEST_SCHEMA_URL` / a manifest's `$schema` resolves to
- **Third-party registries**: namespaces declared under `stanza.json#registries`. Modules addressed as `<category> @<ns>/<id>`. `telemetry.captureModule` redacts non-`@stanza` ids to `<redacted>`; stderr still prints full ids (CI log forwarders will see them) - **Third-party registries**: namespaces declared under `stanza.json#registries`. Modules addressed as `<category> @<ns>/<id>`. `telemetry.captureModule` redacts non-`@stanza` ids to `<redacted>`; stderr still prints full ids (CI log forwarders will see them)
## Module authoring ## Module authoring
+5 -1
View File
@@ -45,11 +45,15 @@ apps/
cli/ # stanza-cli — the CLI binary cli/ # stanza-cli — the CLI binary
web/ # https://stanza.tools (TanStack Start) web/ # https://stanza.tools (TanStack Start)
packages/ packages/
registry/ # shared schema, slot/peer/capability resolver schema/ # @withstanza/schema — stanza.json + module schema, contract types, category list
registry/ # slot/peer/capability resolver + package.json/env/template synthesis
codemods/ # ts-morph helpers for region-aware patching codemods/ # ts-morph helpers for region-aware patching
utils/ # shared path-safety + env-file helpers
create-stanza/ # `npm init stanza` template shim create-stanza/ # `npm init stanza` template shim
registry/ registry/
modules/ # first-party modules (framework, orm, db, auth, ui, tooling, testing) modules/ # first-party modules (framework, orm, db, auth, ui, tooling, testing)
scripts/
compile-registry.ts # builds the static registry JSON the CLI consumes
``` ```
## License ## License
+4 -2
View File
@@ -57,10 +57,12 @@
"zod": "^4.4.3" "zod": "^4.4.3"
}, },
"devDependencies": { "devDependencies": {
"@stanza/codemods": "workspace:*",
"@stanza/registry": "workspace:*",
"@types/node": "^25.9.1", "@types/node": "^25.9.1",
"@types/semver": "^7.7.1", "@types/semver": "^7.7.1",
"@withstanza/codemods": "workspace:*",
"@withstanza/registry": "workspace:*",
"@withstanza/schema": "workspace:*",
"@withstanza/utils": "workspace:*",
"typescript": "^6.0.3", "typescript": "^6.0.3",
"vite-plus": "catalog:", "vite-plus": "catalog:",
"vitest": "catalog:" "vitest": "catalog:"
+4 -4
View File
@@ -1,5 +1,6 @@
import * as p from "@clack/prompts"; import * as p from "@clack/prompts";
import type { AppSpec, StanzaManifest } from "@stanza/registry"; import { resolveAdapter } from "@withstanza/registry";
import type { AppSpec, StanzaManifest } from "@withstanza/schema";
import { import {
categoryHome, categoryHome,
DEFAULT_NAMESPACE, DEFAULT_NAMESPACE,
@@ -9,9 +10,8 @@ import {
isValidModuleId, isValidModuleId,
KNOWN_CATEGORIES, KNOWN_CATEGORIES,
parseModuleSpec, parseModuleSpec,
resolveAdapter,
selectedAll, selectedAll,
} from "@stanza/registry"; } from "@withstanza/schema";
import { defineCommand } from "citty"; import { defineCommand } from "citty";
import pc from "picocolors"; import pc from "picocolors";
@@ -71,7 +71,7 @@ export async function cmdAdd(args: CliArgs): Promise<void> {
// The id is about to be interpolated into a registry URL — reject anything // The id is about to be interpolated into a registry URL — reject anything
// that could escape its segment (path traversal, query strings, encoded // that could escape its segment (path traversal, query strings, encoded
// bytes). See `isValidModuleId` in @stanza/registry for the exact shape. // bytes). See `isValidModuleId` in @withstanza/registry for the exact shape.
if (!isValidModuleId(moduleId)) { if (!isValidModuleId(moduleId)) {
p.log.error( p.log.error(
`Invalid module id "${moduleId}". Ids must be alphanumeric segments ` + `Invalid module id "${moduleId}". Ids must be alphanumeric segments ` +
+15 -5
View File
@@ -1,12 +1,15 @@
import { execFileSync } from "node:child_process";
import fs from "node:fs"; import fs from "node:fs";
import { createServer, type Server } from "node:http"; import { createServer, type Server } from "node:http";
import os from "node:os"; import os from "node:os";
import path from "node:path"; import path from "node:path";
import { fileURLToPath } from "node:url";
import { CATEGORIES } from "@stanza/registry"; import { CATEGORIES } from "@withstanza/schema";
import { buildRegistry } from "@stanza/registry/build";
import { afterAll, afterEach, beforeAll, beforeEach, describe, expect, it } from "vite-plus/test"; import { afterAll, afterEach, beforeAll, beforeEach, describe, expect, it } from "vite-plus/test";
const repoRoot = path.resolve(path.dirname(fileURLToPath(import.meta.url)), "../../../..");
import { loadRegistries } from "../lib/registry-loader"; import { loadRegistries } from "../lib/registry-loader";
import { cmdAdd } from "./add"; import { cmdAdd } from "./add";
import { cmdDoctor } from "./doctor"; import { cmdDoctor } from "./doctor";
@@ -23,10 +26,17 @@ let prevExitCode: typeof process.exitCode;
let fixtureRoot: string; let fixtureRoot: string;
let fixtureMain: string; let fixtureMain: string;
beforeAll(async () => { beforeAll(() => {
fixtureRoot = fs.mkdtempSync(path.join(os.tmpdir(), "stanza-reg-")); fixtureRoot = fs.mkdtempSync(path.join(os.tmpdir(), "stanza-reg-"));
await buildRegistry({ outBase: fixtureRoot }); // Build the real first-party registry via the standalone build script — the
fixtureMain = path.join(fixtureRoot, "registry", "index.json"); // same entrypoint CI and the web prebuild use — so the test exercises the
// production build path rather than an in-process import.
execFileSync(
path.join(repoRoot, "node_modules/.bin/jiti"),
["scripts/compile-registry.ts", fixtureRoot],
{ cwd: repoRoot, stdio: "inherit" },
);
fixtureMain = path.join(fixtureRoot, "index.json");
}); });
afterAll(() => { afterAll(() => {
+1 -1
View File
@@ -2,7 +2,7 @@ import fs from "node:fs";
import path from "node:path"; import path from "node:path";
import * as p from "@clack/prompts"; import * as p from "@clack/prompts";
import { PACKAGE_DIRS } from "@stanza/registry"; import { PACKAGE_DIRS } from "@withstanza/schema";
import { defineCommand } from "citty"; import { defineCommand } from "citty";
import pc from "picocolors"; import pc from "picocolors";
+10 -7
View File
@@ -2,21 +2,24 @@ import fs from "node:fs";
import path from "node:path"; import path from "node:path";
import * as p from "@clack/prompts"; import * as p from "@clack/prompts";
import type { AppSpec, CategoryId, Module, Resolved, ResolvedEntry } from "@stanza/registry"; import type { Resolved, ResolvedEntry } from "@withstanza/registry";
import { import {
appPackageJsonBase, appPackageJsonBase,
categoryHome,
categoryOrder, categoryOrder,
DEFAULT_NAMESPACE,
ENV_EXAMPLE_HEADER, ENV_EXAMPLE_HEADER,
KNOWN_CATEGORIES,
PEER_CATEGORIES,
pmRun, pmRun,
PM_FLOOR_VERSION, PM_FLOOR_VERSION,
resolveAdapter, resolveAdapter,
rootPackageJson, rootPackageJson,
synthesizeReadme, synthesizeReadme,
} from "@stanza/registry"; } from "@withstanza/registry";
import type { AppSpec, CategoryId, Module, PackageManager } from "@withstanza/schema";
import {
categoryHome,
DEFAULT_NAMESPACE,
KNOWN_CATEGORIES,
PEER_CATEGORIES,
} from "@withstanza/schema";
import { type ArgsDef, defineCommand } from "citty"; import { type ArgsDef, defineCommand } from "citty";
import pc from "picocolors"; import pc from "picocolors";
@@ -228,7 +231,7 @@ export async function cmdInit(args: CliArgs): Promise<void> {
async function bootstrapShell( async function bootstrapShell(
projectRoot: string, projectRoot: string,
opts: { name: string; packageManager: "pnpm" | "bun" | "npm"; apps: AppSpec[] }, opts: { name: string; packageManager: PackageManager; apps: AppSpec[] },
) { ) {
// Pin `packageManager` to the latest released version that still satisfies // Pin `packageManager` to the latest released version that still satisfies
// the floor (Corepack requires an exact version, so the modifier is stripped). // the floor (Corepack requires an exact version, so the modifier is stripped).
+2 -1
View File
@@ -1,5 +1,6 @@
import * as p from "@clack/prompts"; import * as p from "@clack/prompts";
import { categoryOrder, DEFAULT_NAMESPACE, isMulti, selectedAll } from "@stanza/registry"; import { categoryOrder } from "@withstanza/registry";
import { DEFAULT_NAMESPACE, isMulti, selectedAll } from "@withstanza/schema";
import { defineCommand } from "citty"; import { defineCommand } from "citty";
import pc from "picocolors"; import pc from "picocolors";
+3 -3
View File
@@ -2,8 +2,8 @@ import fs from "node:fs";
import path from "node:path"; 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 "@withstanza/codemods";
import type { AppSpec, StanzaManifest, StanzaModuleRecord } from "@stanza/registry"; import type { AppSpec, StanzaManifest, StanzaModuleRecord } from "@withstanza/schema";
import { import {
appsForRecord, appsForRecord,
categoryHome, categoryHome,
@@ -15,7 +15,7 @@ import {
PACKAGE_DIRS, PACKAGE_DIRS,
parseModuleSpec, parseModuleSpec,
selectedAll, selectedAll,
} from "@stanza/registry"; } from "@withstanza/schema";
import { defineCommand } from "citty"; import { defineCommand } from "citty";
import pc from "picocolors"; import pc from "picocolors";
+1 -1
View File
@@ -1,4 +1,4 @@
import { DEFAULT_NAMESPACE, parseModuleSpec, type StanzaManifest } from "@stanza/registry"; import { DEFAULT_NAMESPACE, parseModuleSpec, type StanzaManifest } from "@withstanza/schema";
import { defineCommand } from "citty"; import { defineCommand } from "citty";
import pc from "picocolors"; import pc from "picocolors";
+1 -1
View File
@@ -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 { defineModule, emptyManifest, type Module } from "@stanza/registry"; import { defineModule, type Module, emptyManifest } from "@withstanza/schema";
import { afterEach, beforeEach, describe, expect, it } from "vite-plus/test"; import { afterEach, beforeEach, describe, expect, it } from "vite-plus/test";
import { planSlotPackageBootstrap, recordFor, writeDepKeepingHigher } from "./codemod-runner"; import { planSlotPackageBootstrap, recordFor, writeDepKeepingHigher } from "./codemod-runner";
+17 -19
View File
@@ -1,10 +1,19 @@
import fs from "node:fs"; import fs from "node:fs";
import path from "node:path"; import path from "node:path";
import { openProject } from "@stanza/codemods"; import { openProject } from "@withstanza/codemods";
import { addPackageDependency, addPackageScript, addEnvVar } from "@stanza/codemods"; import { addPackageDependency, addPackageScript, addEnvVar } from "@withstanza/codemods";
import type { CodemodContext, Project } from "@stanza/codemods"; import type { CodemodContext, Project } from "@withstanza/codemods";
import { CODEMOD_CATALOG } from "@stanza/codemods/builtins"; import { CODEMOD_CATALOG } from "@withstanza/codemods/builtins";
import type { TemplateContext } from "@withstanza/registry";
import {
activePeerIds,
buildRenderContext,
installPackageJsonTargets,
mergeInstallFields,
renderTemplate,
slotPackageJsonBase,
} from "@withstanza/registry";
import type { import type {
AppSpec, AppSpec,
CategoryId, CategoryId,
@@ -12,21 +21,10 @@ import type {
Module, Module,
ModuleAdapter, ModuleAdapter,
StanzaManifest, StanzaManifest,
TemplateContext,
TemplateRef, TemplateRef,
} from "@stanza/registry"; } from "@withstanza/schema";
import { import { categoryHome, declaredEnvNames, PACKAGE_DIRS } from "@withstanza/schema";
activePeerIds, import { assertSafeRelativePath } from "@withstanza/utils";
assertSafeRelativePath,
buildRenderContext,
categoryHome,
declaredEnvNames,
installPackageJsonTargets,
mergeInstallFields,
PACKAGE_DIRS,
renderTemplate,
slotPackageJsonBase,
} from "@stanza/registry";
import semver from "semver"; import semver from "semver";
import { FileTx } from "./file-tx"; import { FileTx } from "./file-tx";
@@ -117,7 +115,7 @@ export async function applyModule(args: {
// on conflicts; env merges by `name`. // on conflicts; env merges by `name`.
const installFields = mergeInstallFields(module, adapter); const installFields = mergeInstallFields(module, adapter);
// `categoryHome` (in @stanza/registry) is the single decision point for where // `categoryHome` (in @withstanza/schema) is the single decision point for where
// a module's templates/deps/scripts land — package (`packages/<dir>/`), repo // a module's templates/deps/scripts land — package (`packages/<dir>/`), repo
// root, or each targeted app. // root, or each targeted app.
const packageDir = home.kind === "package" ? home.dir : null; const packageDir = home.kind === "package" ? home.dir : null;
+1 -1
View File
@@ -8,7 +8,7 @@ import {
MANIFEST_SCHEMA_URL, MANIFEST_SCHEMA_URL,
StanzaManifestSchema, StanzaManifestSchema,
type StanzaManifest, type StanzaManifest,
} from "@stanza/registry"; } from "@withstanza/schema";
const MANIFEST_FILENAME = "stanza.json"; const MANIFEST_FILENAME = "stanza.json";
+3 -2
View File
@@ -2,8 +2,9 @@ import { createHash } from "node:crypto";
import fs from "node:fs"; import fs from "node:fs";
import path from "node:path"; import path from "node:path";
import type { Resolved, ResolvedEntry, StanzaManifest } from "@stanza/registry"; import type { Resolved, ResolvedEntry } from "@withstanza/registry";
import { activePeerIds, categoryOrder, synthesizeReadme } from "@stanza/registry"; import { activePeerIds, categoryOrder, synthesizeReadme } from "@withstanza/registry";
import type { StanzaManifest } from "@withstanza/schema";
import type { Registries } from "./registry-loader"; import type { Registries } from "./registry-loader";
+1 -1
View File
@@ -1,4 +1,4 @@
import type { StanzaManifest } from "@stanza/registry"; import type { StanzaManifest } from "@withstanza/schema";
/** /**
* Region ownership operations on a manifest. Pure functions that return a * Region ownership operations on a manifest. Pure functions that return a
+7 -2
View File
@@ -2,8 +2,13 @@ import fs from "node:fs";
import path from "node:path"; import path from "node:path";
import { fileURLToPath } from "node:url"; import { fileURLToPath } from "node:url";
import type { Module, RegistryConfig, RegistryIndex, StanzaManifest } from "@stanza/registry"; import type { Module, RegistryConfig, RegistryIndex, StanzaManifest } from "@withstanza/schema";
import { DEFAULT_NAMESPACE, expandEnv, ModuleSchema, RegistryIndexSchema } from "@stanza/registry"; import {
DEFAULT_NAMESPACE,
expandEnv,
ModuleSchema,
RegistryIndexSchema,
} from "@withstanza/schema";
/** /**
* The published Stanza website hosts the canonical first-party registry. A * The published Stanza website hosts the canonical first-party registry. A
+11 -7
View File
@@ -2,17 +2,21 @@ import fs from "node:fs";
import path from "node:path"; import path from "node:path";
import * as p from "@clack/prompts"; import * as p from "@clack/prompts";
import type { AppSpec, CategoryId, Module, PackageManager, RegistryIndex } from "@stanza/registry"; import { categoryOrder, resolveAdapter, validateProjectName } from "@withstanza/registry";
import type {
AppSpec,
CategoryId,
Module,
PackageManager,
RegistryIndex,
} from "@withstanza/schema";
import { import {
categoryLabel, categoryLabel,
categoryOrder,
defaultWebApp, defaultWebApp,
emptyManifest, emptyManifest,
isMulti, isMulti,
KNOWN_CATEGORIES, KNOWN_CATEGORIES,
resolveAdapter, } from "@withstanza/schema";
validateProjectName,
} from "@stanza/registry";
import pc from "picocolors"; import pc from "picocolors";
import type { Registries } from "./registry-loader"; import type { Registries } from "./registry-loader";
@@ -25,7 +29,7 @@ export type WizardResult = {
* multi-app-shaped. * multi-app-shaped.
*/ */
apps: AppSpec[]; apps: AppSpec[];
packageManager: "pnpm" | "bun" | "npm"; packageManager: PackageManager;
/** Chosen modules, keyed by category. Single-choice categories hold one. */ /** Chosen modules, keyed by category. Single-choice categories hold one. */
selections: Partial<Record<CategoryId, Module[]>>; selections: Partial<Record<CategoryId, Module[]>>;
}; };
@@ -37,7 +41,7 @@ export type WizardResult = {
*/ */
export type WizardOverrides = { export type WizardOverrides = {
name?: string; name?: string;
packageManager?: "pnpm" | "bun" | "npm"; packageManager?: PackageManager;
/** Category → module ids (comma-separated on the CLI). Missing = skipped. */ /** Category → module ids (comma-separated on the CLI). Missing = skipped. */
selections: Partial<Record<CategoryId, string[]>>; selections: Partial<Record<CategoryId, string[]>>;
}; };
+2
View File
@@ -0,0 +1,2 @@
public/registry/
public/schema.json
+17 -19
View File
@@ -18,7 +18,7 @@ A module lives in a directory named `<category>-<id>/` and exports a default `de
```ts ```ts
// registry/modules/testing-vitest/module.ts // registry/modules/testing-vitest/module.ts
import { defineModule } from "@stanza/registry"; import { defineModule } from "@withstanza/schema";
export default defineModule({ export default defineModule({
id: "vitest", id: "vitest",
@@ -151,7 +151,7 @@ Conditional blocks use Handlebars' [built-ins](https://handlebarsjs.com/guide/bu
The render context is rebound per target app, so a package-home module shipped into both a web and a native app renders each correctly. The render context is rebound per target app, so a package-home module shipped into both a web and a native app renders each correctly.
**Template bodies are inlined by the build.** [`packages/registry/src/build.ts`](https://github.com/jakejarvis/stanza/blob/main/packages/registry/src/build.ts) reads every template file and bakes its contents onto `tpl.content` in the per-module JSON. HTTP-loaded modules are self-contained — the CLI never makes follow-up requests for template files. **Template bodies are inlined by the build.** [`scripts/compile-registry.ts`](https://github.com/jakejarvis/stanza/blob/main/scripts/compile-registry.ts) reads every template file and bakes its contents onto `tpl.content` in the per-module JSON. HTTP-loaded modules are self-contained — the CLI never makes follow-up requests for template files.
## Codemod invocations ## Codemod invocations
@@ -286,31 +286,29 @@ Drop these next to `module.ts`:
| `logo-light.svg` + `logo-dark.svg` | Theme pair — inlined as `mod.logo = { light, dark }`. Takes precedence over a single `logo.svg`. | | `logo-light.svg` + `logo-dark.svg` | Theme pair — inlined as `mod.logo = { light, dark }`. Takes precedence over a single `logo.svg`. |
| `readme.md` | Markdown contribution to the generated project's README. Renders with the same Handlebars context as templates. | | `readme.md` | Markdown contribution to the generated project's README. Renders with the same Handlebars context as templates. |
The build runs SVGO on logos and prefixes every `id` so multiple module logos on the same page can't collide. First-party logos generally come from [svgl.app](https://svgl.app). The build runs [SVGO](https://svgo.dev/) on logos and prefixes every `id` so multiple module logos on the same page can't collide. First-party logos generally come from [svgl.app](https://svgl.app).
## Validating ## Validating
Module authors don't run a separate validation step — the same Zod schemas the CLI uses are the source of truth: Module authors don't run a separate validation step — the same Zod schemas the CLI uses are the source of truth:
- `defineModule` (in [`packages/registry/src/module.ts`](https://github.com/jakejarvis/stanza/blob/main/packages/registry/src/module.ts)) throws at runtime on illegal field combinations. - `defineModule` (in [`packages/schema/src/module.ts`](https://github.com/jakejarvis/stanza/blob/main/packages/schema/src/module.ts)) throws at runtime on illegal field combinations.
- `ModuleSchema.parse` runs on every HTTP fetch and rejects malformed manifests with structured errors. - `ModuleSchema.parse` runs on every HTTP fetch and rejects malformed manifests with structured errors.
- `vp test` runs the whole repo's schema + resolver tests; add a fixture under `registry/modules/<your-id>/` and the existing suite picks it up. - `vp test` runs the whole repo's schema + resolver tests; add a fixture under `registry/modules/<your-id>/` and the existing suite picks it up.
- `vp check` does a type-aware lint pass that catches most authoring mistakes statically. - `vp check` does a type-aware lint pass that catches most authoring mistakes statically.
For a third-party registry, the same `ModuleSchema` ships from `@stanza/registry`. The simplest test loop is to build your registry and point the consumer's `STANZA_REGISTRY` at the built main file (`STANZA_REGISTRY=./out/registry/index.json stanza search`) and watch for parse errors. For a third-party registry, the same `ModuleSchema` ships from `@withstanza/schema` (published to npm). The simplest test loop is to build your registry and point the consumer's `STANZA_REGISTRY` at the built main file (`STANZA_REGISTRY=./out/index.json stanza search`) and watch for parse errors.
## Building a registry ## Building a registry
The build script ([`packages/registry/src/build.ts`](https://github.com/jakejarvis/stanza/blob/main/packages/registry/src/build.ts)) scans `registry/modules/*` and emits: The build script ([`scripts/compile-registry.ts`](https://github.com/jakejarvis/stanza/blob/main/scripts/compile-registry.ts)) scans `registry/modules/*` and writes the main file plus one JSON per module directly under the output directory:
``` ```
<out>/ <out>/
├── registry/ ├── index.json # main file: categories + per-module summaries (each with a `path`)
│ ├── index.json # main file: categories + per-module summaries (each with a `path`) └── modules/
── modules/ ── <category>-<id>.json # one file per module, templates inlined
── <category>-<id>.json # one file per module, templates inlined ──
│ └── …
└── schema.json # JSON Schema for stanza.json
``` ```
Each per-module JSON is self-contained: template bodies, deps, env vars, codemod invocations, the optimized logo SVG, and the rendered readme are all in one document. The CLI never makes follow-up requests for module assets. Each per-module JSON is self-contained: template bodies, deps, env vars, codemod invocations, the optimized logo SVG, and the rendered readme are all in one document. The CLI never makes follow-up requests for module assets.
@@ -318,14 +316,14 @@ Each per-module JSON is self-contained: template bodies, deps, env vars, codemod
To run the build: To run the build:
```sh ```sh
# Default output: <repoRoot>/dist # Default output: <repoRoot>/dist (writes dist/index.json + dist/modules/*.json)
jiti packages/registry/src/build.ts jiti scripts/compile-registry.ts
# Or point it elsewhere (this is what the web app's prebuild does): # Or point it elsewhere (this is what the web app's prebuild does):
jiti packages/registry/src/build.ts apps/web/public jiti scripts/compile-registry.ts apps/web/public/registry
``` ```
For a third-party registry, fork the build script (or call it as a library) against your own `registry/modules/` tree. The output shape is identical. The script is a thin wrapper over `@withstanza/schema` (`CATEGORIES` + the contract types) and SVGO — for a third-party registry, fork it (or copy it) against your own `registry/modules/` tree. `compileRegistry({ outDir })` is also exported for in-process callers. The output shape is identical.
## Hosting a registry ## Hosting a registry
@@ -405,7 +403,7 @@ A minimal third-party module from scratch.
**1. Author `module.ts`** at `registry/modules/testing-cosmos/module.ts` in your registry repo: **1. Author `module.ts`** at `registry/modules/testing-cosmos/module.ts` in your registry repo:
```ts ```ts
import { defineModule } from "@stanza/registry"; import { defineModule } from "@withstanza/schema";
export default defineModule({ export default defineModule({
id: "cosmos", id: "cosmos",
@@ -423,13 +421,13 @@ export default defineModule({
**2. Build the registry** with the script above. Output: **2. Build the registry** with the script above. Output:
``` ```
out/registry/ out/
├── index.json ├── index.json
└── modules/ └── modules/
└── testing-cosmos.json └── testing-cosmos.json
``` ```
**3. Host it.** Push `out/registry/` (or its parent) to wherever you serve static files. Say the main file ends up at `https://reg.acme.example/index.json`. **3. Host it.** Push `out/` to wherever you serve static files. Say the main file ends up at `https://reg.acme.example/index.json`.
**4. Tell users how to install.** Their `stanza.json`: **4. Tell users how to install.** Their `stanza.json`:
+3 -2
View File
@@ -1,5 +1,5 @@
{ {
"name": "@stanza/web", "name": "@withstanza/web",
"version": "0.0.0", "version": "0.0.0",
"private": true, "private": true,
"license": "MIT", "license": "MIT",
@@ -16,7 +16,6 @@
"@number-flow/react": "^0.6.0", "@number-flow/react": "^0.6.0",
"@orama/orama": "^3.1.18", "@orama/orama": "^3.1.18",
"@pierre/trees": "1.0.0-beta.4", "@pierre/trees": "1.0.0-beta.4",
"@stanza/registry": "workspace:*",
"@tabler/icons-react": "^3.44.0", "@tabler/icons-react": "^3.44.0",
"@tailwindcss/vite": "^4.3.0", "@tailwindcss/vite": "^4.3.0",
"@takumi-rs/image-response": "^1.6.0", "@takumi-rs/image-response": "^1.6.0",
@@ -28,6 +27,8 @@
"@tanstack/react-start": "^1.168.18", "@tanstack/react-start": "^1.168.18",
"@vercel/analytics": "^2.0.1", "@vercel/analytics": "^2.0.1",
"@vercel/functions": "^3.6.1", "@vercel/functions": "^3.6.1",
"@withstanza/registry": "workspace:*",
"@withstanza/schema": "workspace:*",
"class-variance-authority": "^0.7.1", "class-variance-authority": "^0.7.1",
"clsx": "^2.1.1", "clsx": "^2.1.1",
"fumadocs-core": "^16.9.3", "fumadocs-core": "^16.9.3",
+2 -3
View File
@@ -1,14 +1,13 @@
import type { CategoryId, ModuleMetadata } from "@stanza/registry";
import { isMulti } from "@stanza/registry";
import { useNavigate, useRouterState } from "@tanstack/react-router"; import { useNavigate, useRouterState } from "@tanstack/react-router";
import { track } from "@vercel/analytics"; import { track } from "@vercel/analytics";
import type { CategoryId, ModuleMetadata, PackageManager } from "@withstanza/schema";
import { isMulti } from "@withstanza/schema";
import { startTransition, useCallback, useMemo, useOptimistic, useRef } from "react"; import { startTransition, useCallback, useMemo, useOptimistic, useRef } from "react";
import { FilePreview } from "@/components/builder/file-preview"; import { FilePreview } from "@/components/builder/file-preview";
import { ModuleCards } from "@/components/builder/module-cards"; import { ModuleCards } from "@/components/builder/module-cards";
import { ProjectSetup } from "@/components/builder/project-setup"; import { ProjectSetup } from "@/components/builder/project-setup";
import { CommandPreview } from "@/components/command-preview"; import { CommandPreview } from "@/components/command-preview";
import type { PackageManager } from "@/lib/package-manager";
import { import {
type BuilderSearch, type BuilderSearch,
type Selections, type Selections,
@@ -1,14 +1,15 @@
import type { CategoryId, ModuleMetadata, ResolveError } from "@stanza/registry"; import { IconCheck, IconInfoCircle } from "@tabler/icons-react";
import { Link } from "@tanstack/react-router";
import type { ResolveError } from "@withstanza/registry";
import { resolveAdapter } from "@withstanza/registry";
import type { CategoryId, ModuleMetadata } from "@withstanza/schema";
import { import {
categoryLabel, categoryLabel,
emptyManifest, emptyManifest,
isMulti, isMulti,
KNOWN_CATEGORIES, KNOWN_CATEGORIES,
PEER_CATEGORIES, PEER_CATEGORIES,
resolveAdapter, } from "@withstanza/schema";
} from "@stanza/registry";
import { IconCheck, IconInfoCircle } from "@tabler/icons-react";
import { Link } from "@tanstack/react-router";
import { memo, useCallback, useMemo } from "react"; import { memo, useCallback, useMemo } from "react";
import { ModuleLogo } from "@/components/module-logo"; import { ModuleLogo } from "@/components/module-logo";
@@ -1,6 +1,6 @@
import { validateProjectName } from "@stanza/registry";
import { IconAlertCircle } from "@tabler/icons-react"; import { IconAlertCircle } from "@tabler/icons-react";
import { useDebouncedCallback } from "@tanstack/react-pacer"; import { useDebouncedCallback } from "@tanstack/react-pacer";
import { validateProjectName } from "@withstanza/registry";
import type { ChangeEvent } from "react"; import type { ChangeEvent } from "react";
import { useId, useState } from "react"; import { useId, useState } from "react";
+1 -1
View File
@@ -1,9 +1,9 @@
import { track } from "@vercel/analytics"; import { track } from "@vercel/analytics";
import type { PackageManager } from "@withstanza/schema";
import { PackageManagerSelect } from "@/components/package-manager-select"; import { PackageManagerSelect } from "@/components/package-manager-select";
import { CopyableField } from "@/components/ui/copyable-field"; import { CopyableField } from "@/components/ui/copyable-field";
import { selectionProperties } from "@/lib/analytics"; import { selectionProperties } from "@/lib/analytics";
import type { PackageManager } from "@/lib/package-manager";
import { buildCommand, DEFAULT_NAME, type Selections } from "@/lib/selection"; import { buildCommand, DEFAULT_NAME, type Selections } from "@/lib/selection";
/** /**
@@ -1,5 +1,5 @@
import type { CategoryId, ModuleMetadata, RegistryIndex } from "@stanza/registry"; import type { CategoryId, ModuleMetadata, RegistryIndex } from "@withstanza/schema";
import { categoryLabel, KNOWN_CATEGORIES } from "@stanza/registry"; import { categoryLabel, KNOWN_CATEGORIES } from "@withstanza/schema";
import { useMemo } from "react"; import { useMemo } from "react";
import { ToggleGroup, ToggleGroupItem } from "@/components/ui/toggle-group"; import { ToggleGroup, ToggleGroupItem } from "@/components/ui/toggle-group";
+1 -1
View File
@@ -1,8 +1,8 @@
import { DEFAULT_PACKAGE_MANAGER, type PackageManager } from "@withstanza/schema";
import { useState } from "react"; import { useState } from "react";
import { CommandPreview } from "@/components/command-preview"; import { CommandPreview } from "@/components/command-preview";
import { Section } from "@/components/detail/section"; import { Section } from "@/components/detail/section";
import { DEFAULT_PACKAGE_MANAGER, type PackageManager } from "@/lib/package-manager";
import type { Selections } from "@/lib/selection"; import type { Selections } from "@/lib/selection";
export function Install({ name, selections }: { name: string; selections: Selections }) { export function Install({ name, selections }: { name: string; selections: Selections }) {
+1 -1
View File
@@ -1,5 +1,5 @@
import type { EnvVar } from "@stanza/registry";
import { IconExternalLink } from "@tabler/icons-react"; import { IconExternalLink } from "@tabler/icons-react";
import type { EnvVar } from "@withstanza/schema";
import { Section, SectionList } from "@/components/detail/section"; import { Section, SectionList } from "@/components/detail/section";
import { Badge } from "@/components/ui/badge"; import { Badge } from "@/components/ui/badge";
@@ -1,4 +1,4 @@
import type { TemplateRef } from "@stanza/registry"; import type { TemplateRef } from "@withstanza/schema";
import { useMemo, useState } from "react"; import { useMemo, useState } from "react";
import { Section, SectionList } from "@/components/detail/section"; import { Section, SectionList } from "@/components/detail/section";
+1 -1
View File
@@ -1,4 +1,4 @@
import type { Logo } from "@stanza/registry"; import type { Logo } from "@withstanza/schema";
import { useMemo } from "react"; import { useMemo } from "react";
import { cn } from "@/lib/utils"; import { cn } from "@/lib/utils";
@@ -1,4 +1,5 @@
import { IconChevronDown } from "@tabler/icons-react"; import { IconChevronDown } from "@tabler/icons-react";
import { isPackageManager, type PackageManager } from "@withstanza/schema";
import type { ComponentProps, ReactNode } from "react"; import type { ComponentProps, ReactNode } from "react";
import { Button } from "@/components/ui/button"; import { Button } from "@/components/ui/button";
@@ -9,7 +10,7 @@ import {
DropdownMenuRadioItem, DropdownMenuRadioItem,
DropdownMenuTrigger, DropdownMenuTrigger,
} from "@/components/ui/dropdown-menu"; } from "@/components/ui/dropdown-menu";
import { isPackageManager, PACKAGE_MANAGERS, type PackageManager } from "@/lib/package-manager"; import { PACKAGE_MANAGERS } from "@/lib/package-manager";
function NpmLogo(props: ComponentProps<"svg">) { function NpmLogo(props: ComponentProps<"svg">) {
return ( return (
@@ -1,11 +1,10 @@
"use client"; "use client";
import type { ModuleMetadata, RegistryIndex } from "@stanza/registry";
import { categoryLabel } from "@stanza/registry";
import { IconBookmark, IconSearch } from "@tabler/icons-react"; import { IconBookmark, IconSearch } from "@tabler/icons-react";
import { formatForDisplay, useHotkey } from "@tanstack/react-hotkeys"; import { formatForDisplay, useHotkey } from "@tanstack/react-hotkeys";
import { useDebouncedCallback } from "@tanstack/react-pacer"; import { useDebouncedCallback } from "@tanstack/react-pacer";
import { useNavigate } from "@tanstack/react-router"; import { useNavigate } from "@tanstack/react-router";
import { categoryLabel, type ModuleMetadata, type RegistryIndex } from "@withstanza/schema";
import type { SortedResult } from "fumadocs-core/search"; import type { SortedResult } from "fumadocs-core/search";
import { useDocsSearch } from "fumadocs-core/search/client"; import { useDocsSearch } from "fumadocs-core/search/client";
import { useCallback, useEffect, useMemo, useRef, useState, useSyncExternalStore } from "react"; import { useCallback, useEffect, useMemo, useRef, useState, useSyncExternalStore } from "react";
+1 -1
View File
@@ -1,4 +1,4 @@
import { KNOWN_CATEGORIES } from "@stanza/registry"; import { KNOWN_CATEGORIES } from "@withstanza/schema";
import type { Selections } from "@/lib/selection"; import type { Selections } from "@/lib/selection";
+1 -1
View File
@@ -1,4 +1,4 @@
import type { CategoryId, ModuleMetadata } from "@stanza/registry"; import type { CategoryId, ModuleMetadata } from "@withstanza/schema";
export function groupByCategory( export function groupByCategory(
modules: ModuleMetadata[], modules: ModuleMetadata[],
+2 -8
View File
@@ -1,14 +1,8 @@
export type PackageManager = "pnpm" | "npm" | "bun"; import type { PackageManager } from "@withstanza/schema";
/** Ordered list shown in the dropdown — pnpm first (the default). */ /** Ordered list shown in the dropdown — pnpm first (the default). UI-only. */
export const PACKAGE_MANAGERS: { id: PackageManager; label: string }[] = [ export const PACKAGE_MANAGERS: { id: PackageManager; label: string }[] = [
{ id: "pnpm", label: "pnpm" }, { id: "pnpm", label: "pnpm" },
{ id: "npm", label: "npm" }, { id: "npm", label: "npm" },
{ id: "bun", label: "bun" }, { id: "bun", label: "bun" },
]; ];
export const DEFAULT_PACKAGE_MANAGER: PackageManager = "pnpm";
export function isPackageManager(value: string | null | undefined): value is PackageManager {
return PACKAGE_MANAGERS.some((pm) => pm.id === value);
}
+2 -1
View File
@@ -42,7 +42,7 @@ function listRegistryPaths(): string[] {
} catch (error) { } catch (error) {
throw new Error( throw new Error(
`Prerender enumeration failed: ${registryPath} is missing. ` + `Prerender enumeration failed: ${registryPath} is missing. ` +
`Run \`pnpm --filter @stanza/web prebuild\` (or \`vp run @stanza/web#build\`) first.`, `Run \`jiti scripts/compile-registry.ts apps/web/public/registry\` first.`,
{ cause: error }, { cause: error },
); );
} }
@@ -67,6 +67,7 @@ export function listPrerenderPages() {
"/docs/llms-full.txt", "/docs/llms-full.txt",
...registry, ...registry,
"/stats", "/stats",
"/schema.json",
]; ];
return paths.map((path) => ({ path, prerender: { enabled: true } })); return paths.map((path) => ({ path, prerender: { enabled: true } }));
} }
+9 -14
View File
@@ -1,25 +1,20 @@
import type { Resolved, ResolvedEntry } from "@withstanza/registry";
import { categoryOrder, resolveAdapter } from "@withstanza/registry";
import type { import type {
AppSpec, AppSpec,
CategoryId, CategoryId,
Module, Module,
ModuleMetadata, ModuleMetadata,
Resolved, PackageManager,
ResolvedEntry, } from "@withstanza/schema";
} from "@stanza/registry";
import {
categoryOrder,
defaultWebApp,
emptyManifest,
KNOWN_CATEGORIES,
PEER_CATEGORIES,
resolveAdapter,
} from "@stanza/registry";
import { import {
DEFAULT_PACKAGE_MANAGER, DEFAULT_PACKAGE_MANAGER,
defaultWebApp,
emptyManifest,
isPackageManager, isPackageManager,
type PackageManager, KNOWN_CATEGORIES,
} from "@/lib/package-manager"; PEER_CATEGORIES,
} from "@withstanza/schema";
/** Selected module ids per category. Single-choice categories hold ≤ 1. */ /** Selected module ids per category. Single-choice categories hold ≤ 1. */
export type Selections = Partial<Record<CategoryId, string[]>>; export type Selections = Partial<Record<CategoryId, string[]>>;
+21
View File
@@ -10,6 +10,7 @@
import { Route as rootRouteImport } from './routes/__root' import { Route as rootRouteImport } from './routes/__root'
import { Route as StatsRouteImport } from './routes/stats' import { Route as StatsRouteImport } from './routes/stats'
import { Route as SchemaDotjsonRouteImport } from './routes/schema[.]json'
import { Route as OgDotwebpRouteImport } from './routes/og[.]webp' import { Route as OgDotwebpRouteImport } from './routes/og[.]webp'
import { Route as DocsDotmdRouteImport } from './routes/docs[.]md' import { Route as DocsDotmdRouteImport } from './routes/docs[.]md'
import { Route as IndexRouteImport } from './routes/index' import { Route as IndexRouteImport } from './routes/index'
@@ -31,6 +32,11 @@ const StatsRoute = StatsRouteImport.update({
path: '/stats', path: '/stats',
getParentRoute: () => rootRouteImport, getParentRoute: () => rootRouteImport,
} as any) } as any)
const SchemaDotjsonRoute = SchemaDotjsonRouteImport.update({
id: '/schema.json',
path: '/schema.json',
getParentRoute: () => rootRouteImport,
} as any)
const OgDotwebpRoute = OgDotwebpRouteImport.update({ const OgDotwebpRoute = OgDotwebpRouteImport.update({
id: '/og.webp', id: '/og.webp',
path: '/og.webp', path: '/og.webp',
@@ -113,6 +119,7 @@ export interface FileRoutesByFullPath {
'/': typeof IndexRoute '/': typeof IndexRoute
'/docs.md': typeof DocsDotmdRoute '/docs.md': typeof DocsDotmdRoute
'/og.webp': typeof OgDotwebpRoute '/og.webp': typeof OgDotwebpRoute
'/schema.json': typeof SchemaDotjsonRoute
'/stats': typeof StatsRoute '/stats': typeof StatsRoute
'/api/events': typeof ApiEventsRoute '/api/events': typeof ApiEventsRoute
'/docs/$': typeof DocsSplatRoute '/docs/$': typeof DocsSplatRoute
@@ -131,6 +138,7 @@ export interface FileRoutesByTo {
'/': typeof IndexRoute '/': typeof IndexRoute
'/docs.md': typeof DocsDotmdRoute '/docs.md': typeof DocsDotmdRoute
'/og.webp': typeof OgDotwebpRoute '/og.webp': typeof OgDotwebpRoute
'/schema.json': typeof SchemaDotjsonRoute
'/stats': typeof StatsRoute '/stats': typeof StatsRoute
'/api/events': typeof ApiEventsRoute '/api/events': typeof ApiEventsRoute
'/docs/$': typeof DocsSplatRoute '/docs/$': typeof DocsSplatRoute
@@ -150,6 +158,7 @@ export interface FileRoutesById {
'/': typeof IndexRoute '/': typeof IndexRoute
'/docs.md': typeof DocsDotmdRoute '/docs.md': typeof DocsDotmdRoute
'/og.webp': typeof OgDotwebpRoute '/og.webp': typeof OgDotwebpRoute
'/schema.json': typeof SchemaDotjsonRoute
'/stats': typeof StatsRoute '/stats': typeof StatsRoute
'/api/events': typeof ApiEventsRoute '/api/events': typeof ApiEventsRoute
'/docs/$': typeof DocsSplatRoute '/docs/$': typeof DocsSplatRoute
@@ -170,6 +179,7 @@ export interface FileRouteTypes {
| '/' | '/'
| '/docs.md' | '/docs.md'
| '/og.webp' | '/og.webp'
| '/schema.json'
| '/stats' | '/stats'
| '/api/events' | '/api/events'
| '/docs/$' | '/docs/$'
@@ -188,6 +198,7 @@ export interface FileRouteTypes {
| '/' | '/'
| '/docs.md' | '/docs.md'
| '/og.webp' | '/og.webp'
| '/schema.json'
| '/stats' | '/stats'
| '/api/events' | '/api/events'
| '/docs/$' | '/docs/$'
@@ -206,6 +217,7 @@ export interface FileRouteTypes {
| '/' | '/'
| '/docs.md' | '/docs.md'
| '/og.webp' | '/og.webp'
| '/schema.json'
| '/stats' | '/stats'
| '/api/events' | '/api/events'
| '/docs/$' | '/docs/$'
@@ -225,6 +237,7 @@ export interface RootRouteChildren {
IndexRoute: typeof IndexRoute IndexRoute: typeof IndexRoute
DocsDotmdRoute: typeof DocsDotmdRoute DocsDotmdRoute: typeof DocsDotmdRoute
OgDotwebpRoute: typeof OgDotwebpRoute OgDotwebpRoute: typeof OgDotwebpRoute
SchemaDotjsonRoute: typeof SchemaDotjsonRoute
StatsRoute: typeof StatsRoute StatsRoute: typeof StatsRoute
ApiEventsRoute: typeof ApiEventsRoute ApiEventsRoute: typeof ApiEventsRoute
DocsSplatRoute: typeof DocsSplatRoute DocsSplatRoute: typeof DocsSplatRoute
@@ -249,6 +262,13 @@ declare module '@tanstack/react-router' {
preLoaderRoute: typeof StatsRouteImport preLoaderRoute: typeof StatsRouteImport
parentRoute: typeof rootRouteImport parentRoute: typeof rootRouteImport
} }
'/schema.json': {
id: '/schema.json'
path: '/schema.json'
fullPath: '/schema.json'
preLoaderRoute: typeof SchemaDotjsonRouteImport
parentRoute: typeof rootRouteImport
}
'/og.webp': { '/og.webp': {
id: '/og.webp' id: '/og.webp'
path: '/og.webp' path: '/og.webp'
@@ -361,6 +381,7 @@ const rootRouteChildren: RootRouteChildren = {
IndexRoute: IndexRoute, IndexRoute: IndexRoute,
DocsDotmdRoute: DocsDotmdRoute, DocsDotmdRoute: DocsDotmdRoute,
OgDotwebpRoute: OgDotwebpRoute, OgDotwebpRoute: OgDotwebpRoute,
SchemaDotjsonRoute: SchemaDotjsonRoute,
StatsRoute: StatsRoute, StatsRoute: StatsRoute,
ApiEventsRoute: ApiEventsRoute, ApiEventsRoute: ApiEventsRoute,
DocsSplatRoute: DocsSplatRoute, DocsSplatRoute: DocsSplatRoute,
+1 -1
View File
@@ -1,6 +1,6 @@
import { create, insert, search } from "@orama/orama"; import { create, insert, search } from "@orama/orama";
import type { Module, ModuleMetadata, RegistryIndex } from "@stanza/registry";
import { createFileRoute } from "@tanstack/react-router"; import { createFileRoute } from "@tanstack/react-router";
import type { Module, ModuleMetadata, RegistryIndex } from "@withstanza/schema";
import { cache } from "react"; import { cache } from "react";
import { loadRegistryFile } from "@/server/registry-base.server"; import { loadRegistryFile } from "@/server/registry-base.server";
+1 -1
View File
@@ -86,7 +86,7 @@ export const Route = createFileRoute("/docs/$")({
title, title,
description, description,
path, path,
ogImage: loaderData ? `/og${loaderData.url}.webp` : undefined, ogImage: loaderData ? `/og${path}.webp` : undefined,
markdownPath: loaderData ? `${path}.md` : undefined, markdownPath: loaderData ? `${path}.md` : undefined,
jsonLd: jsonLd:
loaderData && title loaderData && title
@@ -1,6 +1,6 @@
import type { RegistryIndex } from "@stanza/registry";
import { ImageResponse } from "@takumi-rs/image-response"; import { ImageResponse } from "@takumi-rs/image-response";
import { createFileRoute } from "@tanstack/react-router"; import { createFileRoute } from "@tanstack/react-router";
import type { RegistryIndex } from "@withstanza/schema";
import { OgCard } from "@/server/og-card.server"; import { OgCard } from "@/server/og-card.server";
import { loadRegistryFile } from "@/server/registry-base.server"; import { loadRegistryFile } from "@/server/registry-base.server";
@@ -1,7 +1,7 @@
import type { CategoryId } from "@stanza/registry";
import { categoryLabel, KNOWN_CATEGORIES, PEER_CATEGORIES } from "@stanza/registry";
import { IconArrowLeft, IconExternalLink } from "@tabler/icons-react"; import { IconArrowLeft, IconExternalLink } from "@tabler/icons-react";
import { Link, createFileRoute, notFound, useNavigate } from "@tanstack/react-router"; import { Link, createFileRoute, notFound, useNavigate } from "@tanstack/react-router";
import type { CategoryId } from "@withstanza/schema";
import { categoryLabel, KNOWN_CATEGORIES, PEER_CATEGORIES } from "@withstanza/schema";
import { useCallback, useMemo } from "react"; import { useCallback, useMemo } from "react";
import { AdapterSwitcher } from "@/components/detail/adapter-switcher"; import { AdapterSwitcher } from "@/components/detail/adapter-switcher";
@@ -1,6 +1,6 @@
import { categoryDescription, categoryLabel, isCategoryId } from "@stanza/registry";
import { IconArrowLeft } from "@tabler/icons-react"; import { IconArrowLeft } from "@tabler/icons-react";
import { Link, createFileRoute, notFound, useLoaderData } from "@tanstack/react-router"; import { Link, createFileRoute, notFound, useLoaderData } from "@tanstack/react-router";
import { categoryDescription, categoryLabel, isCategoryId } from "@withstanza/schema";
import { useMemo } from "react"; import { useMemo } from "react";
import { Section, SectionList } from "@/components/detail/section"; import { Section, SectionList } from "@/components/detail/section";
+1 -1
View File
@@ -1,6 +1,6 @@
import type { Category, ModuleMetadata } from "@stanza/registry";
import { IconArrowLeft } from "@tabler/icons-react"; import { IconArrowLeft } from "@tabler/icons-react";
import { Link, createFileRoute, useLoaderData } from "@tanstack/react-router"; import { Link, createFileRoute, useLoaderData } from "@tanstack/react-router";
import type { Category, ModuleMetadata } from "@withstanza/schema";
import { useMemo } from "react"; import { useMemo } from "react";
import { SectionList } from "@/components/detail/section"; import { SectionList } from "@/components/detail/section";
+24
View File
@@ -0,0 +1,24 @@
import { createFileRoute } from "@tanstack/react-router";
import { StanzaManifestSchema } from "@withstanza/schema";
import { z } from "zod";
export const Route = createFileRoute("/schema.json")({
server: {
handlers: {
GET: () => {
const compiled = {
$id: "https://stanza.tools/schema.json",
title: "Stanza manifest",
description: "Schema for stanza.json — a Stanza monorepo manifest.",
...z.toJSONSchema(StanzaManifestSchema),
};
return new Response(`${JSON.stringify(compiled, null, 2)}\n`, {
headers: {
"content-type": "application/json; charset=utf-8",
},
});
},
},
},
});
+2 -2
View File
@@ -1,7 +1,7 @@
import NumberFlow from "@number-flow/react"; import NumberFlow from "@number-flow/react";
import type { CategoryId, ModuleMetadata } from "@stanza/registry";
import { categoryLabel, KNOWN_CATEGORIES } from "@stanza/registry";
import { createFileRoute, Link, useLoaderData } from "@tanstack/react-router"; import { createFileRoute, Link, useLoaderData } from "@tanstack/react-router";
import type { CategoryId, ModuleMetadata } from "@withstanza/schema";
import { categoryLabel, KNOWN_CATEGORIES } from "@withstanza/schema";
import { lazy, Suspense, useEffect, useState } from "react"; import { lazy, Suspense, useEffect, useState } from "react";
import { ModuleLogo } from "@/components/module-logo"; import { ModuleLogo } from "@/components/module-logo";
@@ -1,11 +1,11 @@
import { createServerFn } from "@tanstack/react-start";
import { import {
synthesizeEnvExample, synthesizeEnvExample,
synthesizeManifest, synthesizeManifest,
synthesizePackageJsons, synthesizePackageJsons,
synthesizeReadme, synthesizeReadme,
synthesizeTemplates, synthesizeTemplates,
} from "@stanza/registry"; } from "@withstanza/registry";
import { createServerFn } from "@tanstack/react-start";
import { import {
DEFAULT_BUILDER_APPS, DEFAULT_BUILDER_APPS,
@@ -1,3 +1,5 @@
import { createServerFn } from "@tanstack/react-start";
import { mergeInstallFields, resolveAdapter } from "@withstanza/registry";
import type { import type {
CategoryId, CategoryId,
EnvVar, EnvVar,
@@ -6,17 +8,14 @@ import type {
ModuleMetadata, ModuleMetadata,
PeerRequirement, PeerRequirement,
RegistryIndex, RegistryIndex,
} from "@stanza/registry"; } from "@withstanza/schema";
import { import {
emptyManifest, emptyManifest,
isCategoryId, isCategoryId,
isValidModuleId, isValidModuleId,
KNOWN_CATEGORIES, KNOWN_CATEGORIES,
mergeInstallFields,
PEER_CATEGORIES, PEER_CATEGORIES,
resolveAdapter, } from "@withstanza/schema";
} from "@stanza/registry";
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";
+1 -2
View File
@@ -1,5 +1,4 @@
import type { ModuleMetadata } from "@stanza/registry"; import { categoryLabel, type ModuleMetadata } from "@withstanza/schema";
import { categoryLabel } from "@stanza/registry";
import type { CSSProperties, ReactElement } from "react"; import type { CSSProperties, ReactElement } from "react";
/** /**
+1 -1
View File
@@ -29,7 +29,7 @@ export async function loadRegistryFile<T>(relativePath: string): Promise<T> {
return parsed; 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 @withstanza/web prebuild\` to populate public/registry/)`,
); );
} }
} }
@@ -1,5 +1,5 @@
import type { RegistryIndex } from "@stanza/registry";
import { createServerFn } from "@tanstack/react-start"; import { createServerFn } from "@tanstack/react-start";
import type { RegistryIndex } from "@withstanza/schema";
import { loadRegistryFile } from "@/server/registry-base.server"; import { loadRegistryFile } from "@/server/registry-base.server";
@@ -1,4 +1,4 @@
import type { Module, RegistryIndex } from "@stanza/registry"; import type { Module, RegistryIndex } from "@withstanza/schema";
import { loadRegistryFile } from "@/server/registry-base.server"; import { loadRegistryFile } from "@/server/registry-base.server";
@@ -7,7 +7,7 @@ let modulesPromise: Promise<Record<string, Module>> | undefined;
/** /**
* Per-process cache for the full module catalog. Registry data is immutable * Per-process cache for the full module catalog. Registry data is immutable
* per deployment, so cache lifetime = process lifetime (restart the dev server * per deployment, so cache lifetime = process lifetime (restart the dev server
* after `vp run @stanza/web#prebuild` to pick up local edits). Per-module * after `vp run @withstanza/web#prebuild` to pick up local edits). Per-module
* failures are isolated — the failing module is dropped and reported. * failures are isolated — the failing module is dropped and reported.
*/ */
export function getAllModules(): Promise<Record<string, Module>> { export function getAllModules(): Promise<Record<string, Module>> {
+2 -2
View File
@@ -1,7 +1,7 @@
import type { CategoryId } from "@stanza/registry";
import { KNOWN_CATEGORIES } from "@stanza/registry";
import { createServerFn } from "@tanstack/react-start"; import { createServerFn } from "@tanstack/react-start";
import { getCache, waitUntil } from "@vercel/functions"; import { getCache, waitUntil } from "@vercel/functions";
import type { CategoryId } from "@withstanza/schema";
import { KNOWN_CATEGORIES } from "@withstanza/schema";
import { getQueryConfig, runQuery } from "@/server/posthog-query.server"; import { getQueryConfig, runQuery } from "@/server/posthog-query.server";
+3 -6
View File
@@ -55,12 +55,9 @@ export default defineConfig({
tasks: { tasks: {
"compile-registry": { "compile-registry": {
cwd: "../..", cwd: "../..",
command: "jiti packages/registry/src/build.ts apps/web/public", command: "jiti scripts/compile-registry.ts apps/web/public/registry",
input: [ input: [{ pattern: "registry/modules/**", base: "workspace" }],
{ pattern: "registry/modules/**", base: "workspace" }, output: [{ pattern: "public/registry/**", base: "package" }],
{ pattern: "packages/registry/src/**", base: "workspace" },
],
output: ["apps/web/public/registry/**", "apps/web/public/schema.json"],
}, },
}, },
}, },
+5 -3
View File
@@ -4,12 +4,12 @@
"private": true, "private": true,
"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 <jake@jarv.is>",
"type": "module", "type": "module",
"scripts": { "scripts": {
"build": "vp run -r build", "build": "vp run -r build",
"build:cli": "vp run stanza-cli#build && vp run create-stanza#build", "build:cli": "vp run @withstanza/schema#build && vp run stanza-cli#build && vp run create-stanza#build",
"build:web": "vp run @stanza/web#build", "build:web": "vp run @withstanza/web#build",
"dev": "vp run -r dev", "dev": "vp run -r dev",
"test": "vp test", "test": "vp test",
"lint": "vp lint", "lint": "vp lint",
@@ -25,7 +25,9 @@
"@changesets/changelog-github": "^0.7.0", "@changesets/changelog-github": "^0.7.0",
"@changesets/cli": "^2.31.0", "@changesets/cli": "^2.31.0",
"@types/node": "^25.9.1", "@types/node": "^25.9.1",
"@withstanza/schema": "workspace:*",
"jiti": "^2.7.0", "jiti": "^2.7.0",
"svgo": "^4.0.1",
"typescript": "^6.0.3", "typescript": "^6.0.3",
"vite-plus": "catalog:" "vite-plus": "catalog:"
}, },
+3 -2
View File
@@ -1,5 +1,5 @@
{ {
"name": "@stanza/codemods", "name": "@withstanza/codemods",
"version": "0.0.0", "version": "0.0.0",
"private": true, "private": true,
"license": "MIT", "license": "MIT",
@@ -11,7 +11,8 @@
"./builtins": "./src/builtins/index.ts" "./builtins": "./src/builtins/index.ts"
}, },
"dependencies": { "dependencies": {
"@stanza/registry": "workspace:*", "@withstanza/schema": "workspace:*",
"@withstanza/utils": "workspace:*",
"jsonc-parser": "^3.3.1", "jsonc-parser": "^3.3.1",
"ts-morph": "^28.0.0" "ts-morph": "^28.0.0"
}, },
@@ -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 { emptyManifest } from "@stanza/registry"; import { emptyManifest } from "@withstanza/schema";
import { afterEach, beforeEach, describe, expect, it } from "vite-plus/test"; import { afterEach, beforeEach, describe, expect, it } from "vite-plus/test";
import { type CodemodContext, openProject } from "../index"; import { type CodemodContext, openProject } from "../index";
@@ -1,6 +1,6 @@
import path from "node:path"; import path from "node:path";
import { assertSafeRelativePath } from "@stanza/registry"; import { assertSafeRelativePath } from "@withstanza/utils";
import type { ArrayLiteralExpression, CallExpression, ObjectLiteralExpression } from "ts-morph"; import type { ArrayLiteralExpression, CallExpression, ObjectLiteralExpression } from "ts-morph";
import { import {
@@ -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 { emptyManifest } from "@stanza/registry"; import { emptyManifest } from "@withstanza/schema";
import { afterEach, beforeEach, describe, expect, it } from "vite-plus/test"; import { afterEach, beforeEach, describe, expect, it } from "vite-plus/test";
import { type CodemodContext, openProject } from "../index"; import { type CodemodContext, openProject } from "../index";
@@ -1,7 +1,7 @@
import fs from "node:fs"; import fs from "node:fs";
import path from "node:path"; import path from "node:path";
import { assertSafeRelativePath } from "@stanza/registry"; import { assertSafeRelativePath } from "@withstanza/utils";
import { import {
addDefaultImport, addDefaultImport,
@@ -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 { emptyManifest } from "@stanza/registry"; import { emptyManifest } from "@withstanza/schema";
import { afterEach, beforeEach, describe, expect, it } from "vite-plus/test"; import { afterEach, beforeEach, describe, expect, it } from "vite-plus/test";
import { openProject, type CodemodContext } from "../index"; import { openProject, type CodemodContext } from "../index";
@@ -1,4 +1,4 @@
import { emptyManifest } from "@stanza/registry"; import { emptyManifest } from "@withstanza/schema";
import { describe, expect, it } from "vite-plus/test"; import { describe, expect, it } from "vite-plus/test";
import { openProject, type CodemodContext, type Project } from "../index"; import { openProject, type CodemodContext, type Project } from "../index";
@@ -1,6 +1,6 @@
import path from "node:path"; import path from "node:path";
import { assertSafeRelativePath } from "@stanza/registry"; import { assertSafeRelativePath } from "@withstanza/utils";
import type { ArrayLiteralExpression } from "ts-morph"; import type { ArrayLiteralExpression } from "ts-morph";
import { import {
@@ -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 { emptyManifest } from "@stanza/registry"; import { emptyManifest } from "@withstanza/schema";
import { afterEach, beforeEach, describe, expect, it } from "vite-plus/test"; 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";
@@ -1,9 +1,9 @@
import fs from "node:fs"; import fs from "node:fs";
import path from "node:path"; import path from "node:path";
import { assertSafeRelativePath } from "@stanza/registry"; import { assertSafeRelativePath } from "@withstanza/utils";
import type { Codemod } from "../index"; import { type Codemod } from "../index";
/** Comment-line shape used to wrap inserted blocks for idempotency + revert. */ /** Comment-line shape used to wrap inserted blocks for idempotency + revert. */
export type CommentStyle = "line" | "hash" | "block"; export type CommentStyle = "line" | "hash" | "block";
+1 -1
View File
@@ -15,7 +15,7 @@
* doesn't fit an existing generic codemod, design a new generic codemod * doesn't fit an existing generic codemod, design a new generic codemod
* with the right argument surface and add it to the catalog. * with the right argument surface and add it to the catalog.
* *
* The catalog lives in `@stanza/codemods/builtins` (this file) so it sits * The catalog lives in `@withstanza/codemods/builtins` (this file) so it sits
* next to the helper primitives it uses; consumers import via the package's * next to the helper primitives it uses; consumers import via the package's
* subpath export. * subpath export.
*/ */
@@ -1,4 +1,4 @@
import { emptyManifest } from "@stanza/registry"; import { emptyManifest } from "@withstanza/schema";
import { describe, expect, it } from "vite-plus/test"; import { describe, expect, it } from "vite-plus/test";
import { openProject, type CodemodContext, type Project } from "../index"; import { openProject, type CodemodContext, type Project } from "../index";
+1 -1
View File
@@ -1,6 +1,6 @@
import path from "node:path"; import path from "node:path";
import { assertSafeRelativePath } from "@stanza/registry"; import { assertSafeRelativePath } from "@withstanza/utils";
import { import {
type Codemod, type Codemod,
@@ -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 { emptyManifest } from "@stanza/registry"; import { emptyManifest } from "@withstanza/schema";
import { afterEach, beforeEach, describe, expect, it } from "vite-plus/test"; import { afterEach, beforeEach, describe, expect, it } from "vite-plus/test";
import { type CodemodContext, openProject } from "../index"; import { type CodemodContext, openProject } from "../index";
@@ -1,6 +1,6 @@
import path from "node:path"; import path from "node:path";
import { assertSafeRelativePath } from "@stanza/registry"; import { assertSafeRelativePath } from "@withstanza/utils";
import { type Codemod, type ImportDeclaration } from "../index"; import { type Codemod, type ImportDeclaration } from "../index";
@@ -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 { emptyManifest } from "@stanza/registry"; import { emptyManifest } from "@withstanza/schema";
import { afterEach, beforeEach, describe, expect, it } from "vite-plus/test"; import { afterEach, beforeEach, describe, expect, it } from "vite-plus/test";
import { type CodemodContext, openProject } from "../index"; import { type CodemodContext, openProject } from "../index";
@@ -1,6 +1,6 @@
import path from "node:path"; import path from "node:path";
import { assertSafeRelativePath } from "@stanza/registry"; import { assertSafeRelativePath } from "@withstanza/utils";
import { import {
type Codemod, type Codemod,
@@ -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 { emptyManifest } from "@stanza/registry"; import { emptyManifest } from "@withstanza/schema";
import { afterEach, beforeEach, describe, expect, it } from "vite-plus/test"; import { afterEach, beforeEach, describe, expect, it } from "vite-plus/test";
import { type CodemodContext, openProject } from "../index"; import { type CodemodContext, openProject } from "../index";
@@ -1,7 +1,7 @@
import fs from "node:fs"; import fs from "node:fs";
import path from "node:path"; import path from "node:path";
import { assertSafeRelativePath } from "@stanza/registry"; import { assertSafeRelativePath } from "@withstanza/utils";
import { import {
type Codemod, type Codemod,
@@ -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 { emptyManifest, type StanzaManifest } from "@stanza/registry"; import { emptyManifest, type StanzaManifest } from "@withstanza/schema";
import { afterEach, beforeEach, describe, expect, it } from "vite-plus/test"; import { afterEach, beforeEach, describe, expect, it } from "vite-plus/test";
import { type CodemodContext, openProject } from "../index"; import { type CodemodContext, openProject } from "../index";
@@ -1,6 +1,6 @@
import path from "node:path"; import path from "node:path";
import { selectedOne } from "@stanza/registry"; import { selectedOne } from "@withstanza/schema";
import { import {
addDefaultImport, addDefaultImport,
+2 -2
View File
@@ -1,12 +1,12 @@
import fs from "node:fs"; import fs from "node:fs";
import { appendEnvVar } from "@stanza/registry"; import { appendEnvVar } from "@withstanza/utils";
/** /**
* Idempotently append an env var to a .env.example-style file. Preserves * Idempotently append an env var to a .env.example-style file. Preserves
* existing entries; updates the example value in-place if the var already * existing entries; updates the example value in-place if the var already
* exists; adds a leading comment if `description` is supplied. Formatting is * exists; adds a leading comment if `description` is supplied. Formatting is
* delegated to `appendEnvVar` (pure, in `@stanza/registry`) so the CLI and the * delegated to `appendEnvVar` (pure, in `@withstanza/utils`) so the CLI and the
* web builder's preview produce identical files. * web builder's preview produce identical files.
*/ */
export function addEnvVar( export function addEnvVar(
+1 -1
View File
@@ -1,4 +1,4 @@
import type { AppSpec, CategoryId, ModuleId, StanzaManifest } from "@stanza/registry"; import type { AppSpec, CategoryId, ModuleId, StanzaManifest } from "@withstanza/schema";
import type { Project } from "ts-morph"; import type { Project } from "ts-morph";
export type CodemodContext = { export type CodemodContext = {
+4 -7
View File
@@ -1,5 +1,5 @@
{ {
"name": "@stanza/registry", "name": "@withstanza/registry",
"version": "0.0.0", "version": "0.0.0",
"private": true, "private": true,
"license": "MIT", "license": "MIT",
@@ -8,19 +8,16 @@
"types": "./src/index.ts", "types": "./src/index.ts",
"exports": { "exports": {
".": "./src/index.ts", ".": "./src/index.ts",
"./build": "./src/build.ts",
"./manifest": "./src/manifest.ts",
"./module": "./src/module.ts",
"./resolver": "./src/resolver.ts" "./resolver": "./src/resolver.ts"
}, },
"dependencies": { "dependencies": {
"@withstanza/schema": "workspace:*",
"@withstanza/utils": "workspace:*",
"handlebars": "^4.7.9", "handlebars": "^4.7.9",
"validate-npm-package-name": "^8.0.0", "validate-npm-package-name": "^8.0.0"
"zod": "^4.4.3"
}, },
"devDependencies": { "devDependencies": {
"@types/validate-npm-package-name": "^4.0.2", "@types/validate-npm-package-name": "^4.0.2",
"svgo": "^4.0.1",
"typescript": "^6.0.3", "typescript": "^6.0.3",
"vite-plus": "catalog:", "vite-plus": "catalog:",
"vitest": "catalog:" "vitest": "catalog:"
-74
View File
@@ -1,78 +1,7 @@
export type {
AppKind,
Category,
CategoryId,
Cardinality,
InstallHome,
ModuleId,
PeerRequirement,
Module,
ModuleAdapter,
ModuleMetadata,
RegistryIndex,
TemplateRef,
EnvVar,
Logo,
CodemodInvocation,
JsonValue,
} from "./module";
export {
APP_KINDS,
defineModule,
CATEGORIES,
KNOWN_CATEGORIES,
isCategoryId,
PEER_CATEGORIES,
PACKAGE_DIRS,
categoryLabel,
categoryDescription,
categoryHome,
categoryCardinality,
isMulti,
ModuleSchema,
ModuleMetadataSchema,
RegistryIndexSchema,
} from "./module";
export type {
AppSpec,
StanzaManifest,
StanzaModuleRecord,
RegionMap,
RegionOwnership,
} from "./manifest";
export {
StanzaManifestSchema,
CURRENT_MANIFEST_VERSION,
SUPPORTED_MANIFEST_VERSIONS,
MANIFEST_SCHEMA_URL,
declaredEnvNames,
defaultWebApp,
emptyManifest,
getApp,
appsForRecord,
selectedOne,
selectedAll,
manifestJsonSchema,
} from "./manifest";
export type { RegistryConfig } from "./registry-config";
export {
DEFAULT_NAMESPACE,
expandEnv,
isLikelyNamespaceTypo,
isNamespace,
isValidModuleId,
parseModuleSpec,
RegistriesSchema,
RegistryConfigSchema,
} from "./registry-config";
export type { ResolveContext, ResolveResult, ResolveError } from "./resolver"; export type { ResolveContext, ResolveResult, ResolveError } from "./resolver";
export { resolveAdapter, isCompatible, categoryOrder, activePeerIds } from "./resolver"; export { resolveAdapter, isCompatible, categoryOrder, activePeerIds } from "./resolver";
export type { export type {
PackageManager,
PackageJson, PackageJson,
MergedInstallFields, MergedInstallFields,
ResolvedEntry, ResolvedEntry,
@@ -91,7 +20,6 @@ export {
export { export {
ENV_EXAMPLE_HEADER, ENV_EXAMPLE_HEADER,
appendEnvVar,
synthesizeEnvExample, synthesizeEnvExample,
synthesizeManifest, synthesizeManifest,
synthesizeReadme, synthesizeReadme,
@@ -103,5 +31,3 @@ export { renderTemplate, buildRenderContext, pmRun, pmRecursive } from "./templa
export type { ProjectNameValidation } from "./project-name"; export type { ProjectNameValidation } from "./project-name";
export { validateProjectName } from "./project-name"; export { validateProjectName } from "./project-name";
export { safeRelativePath, assertSafeRelativePath } from "./safe-path";
+1 -1
View File
@@ -1,6 +1,6 @@
import { defineModule, type Module, ModuleSchema } from "@withstanza/schema";
import { describe, expect, it } from "vite-plus/test"; import { describe, expect, it } from "vite-plus/test";
import { defineModule, type Module, ModuleSchema } from "./module";
import { import {
mergeInstallFields, mergeInstallFields,
type Resolved, type Resolved,
+5 -4
View File
@@ -1,18 +1,19 @@
import { type AppSpec, defaultWebApp } from "./manifest";
import { import {
type AppSpec,
categoryHome, categoryHome,
type CategoryId, type CategoryId,
defaultWebApp,
type EnvVar, type EnvVar,
type InstallHome, type InstallHome,
type Module, type Module,
type ModuleAdapter, type ModuleAdapter,
PACKAGE_DIRS, PACKAGE_DIRS,
} from "./module"; type PackageManager,
} from "@withstanza/schema";
import { categoryOrder } from "./resolver"; import { categoryOrder } from "./resolver";
import { pmRecursive } from "./template"; import { pmRecursive } from "./template";
export type PackageManager = "pnpm" | "bun" | "npm";
/** Minimal package.json shape we author. Field order here is the emit order. */ /** Minimal package.json shape we author. Field order here is the emit order. */
export type PackageJson = { export type PackageJson = {
name?: string; name?: string;
+1 -2
View File
@@ -1,7 +1,6 @@
import { defineModule, emptyManifest, type Module } from "@withstanza/schema";
import { assert, describe, expect, it } from "vite-plus/test"; import { assert, describe, expect, it } from "vite-plus/test";
import { emptyManifest } from "./manifest";
import { defineModule, type Module } from "./module";
import { activePeerIds, resolveAdapter } from "./resolver"; import { activePeerIds, resolveAdapter } from "./resolver";
const drizzle: Module = defineModule({ const drizzle: Module = defineModule({
+3 -2
View File
@@ -1,11 +1,12 @@
import { type StanzaManifest, selectedOne } from "./manifest";
import { import {
type CategoryId, type CategoryId,
KNOWN_CATEGORIES, KNOWN_CATEGORIES,
type ModuleAdapter, type ModuleAdapter,
PEER_CATEGORIES, PEER_CATEGORIES,
type PeerRequirement, type PeerRequirement,
} from "./module"; selectedOne,
type StanzaManifest,
} from "@withstanza/schema";
/** /**
* Topological order categories are processed in (derived from `CATEGORIES`): * Topological order categories are processed in (derived from `CATEGORIES`):
+1 -1
View File
@@ -1,6 +1,6 @@
import { defineModule, type Module } from "@withstanza/schema";
import { describe, expect, it } from "vite-plus/test"; import { describe, expect, it } from "vite-plus/test";
import { defineModule, type Module } from "./module";
import type { Resolved } from "./package-json"; import type { Resolved } from "./package-json";
import { ENV_EXAMPLE_HEADER, synthesizeEnvExample, synthesizeReadme } from "./synthesize"; import { ENV_EXAMPLE_HEADER, synthesizeEnvExample, synthesizeReadme } from "./synthesize";
+8 -41
View File
@@ -1,22 +1,22 @@
import { import {
type AppSpec, type AppSpec,
defaultWebApp,
emptyManifest,
type StanzaManifest,
type StanzaModuleRecord,
} from "./manifest";
import {
categoryHome, categoryHome,
type CategoryId, type CategoryId,
categoryLabel, categoryLabel,
defaultWebApp,
emptyManifest,
type InstallHome, type InstallHome,
isMulti, isMulti,
type ModuleId, type ModuleId,
type PackageManager,
type StanzaManifest,
type StanzaModuleRecord,
type TemplateRef, type TemplateRef,
} from "./module"; } from "@withstanza/schema";
import { appendEnvVar } from "@withstanza/utils";
import { import {
mergeInstallFields, mergeInstallFields,
type PackageManager,
type Resolved, type Resolved,
type ResolvedEntry, type ResolvedEntry,
type SynthesizeEntry, type SynthesizeEntry,
@@ -27,39 +27,6 @@ import { buildRenderContext, pmRun, renderTemplate } from "./template";
/** Header `stanza init` writes at the top of `.env.example`. */ /** Header `stanza init` writes at the top of `.env.example`. */
export const ENV_EXAMPLE_HEADER = "# Stanza-managed environment variables.\n"; export const ENV_EXAMPLE_HEADER = "# Stanza-managed environment variables.\n";
/**
* Idempotently append an env var to `.env.example`-style text, returning the
* new contents. Pure (no fs) so it backs both the CLI's `addEnvVar` and the
* web builder's preview synthesis — the single source of truth for env-file
* formatting. Updates an existing var in place; otherwise appends with a blank
* line separator and an optional leading `# description` comment.
*/
export function appendEnvVar(
contents: string,
name: string,
example: string,
description?: string,
): string {
const lines = contents.split("\n");
const existingIdx = lines.findIndex((line) => line.replace(/^#\s*/, "").startsWith(`${name}=`));
const entry = description ? `# ${description}\n${name}=${example}` : `${name}=${example}`;
if (existingIdx >= 0) {
const prev = lines[existingIdx - 1];
if (description && prev?.startsWith("#")) {
lines.splice(existingIdx - 1, 2, ...entry.split("\n"));
} else {
lines.splice(existingIdx, 1, ...entry.split("\n"));
}
} else {
if (contents.length > 0 && !contents.endsWith("\n")) lines.push("");
if (lines.length > 0 && lines[lines.length - 1] !== "") lines.push("");
lines.push(...entry.split("\n"));
}
return lines.join("\n").replace(/\n+$/, "\n");
}
/** /**
* Compute the `.env.example` stanza would write for a resolved selection: * Compute the `.env.example` stanza would write for a resolved selection:
* the managed header followed by every module's env vars, in `categoryOrder`. * the managed header followed by every module's env vars, in `categoryOrder`.
+1 -3
View File
@@ -1,8 +1,6 @@
import { type AppSpec, defaultWebApp, defineModule, type Module } from "@withstanza/schema";
import { describe, expect, it } from "vite-plus/test"; import { describe, expect, it } from "vite-plus/test";
import { defaultWebApp } from "./manifest";
import type { AppSpec } from "./manifest";
import { defineModule, type Module } from "./module";
import type { Resolved } from "./package-json"; import type { Resolved } from "./package-json";
import { synthesizeTemplates } from "./synthesize"; import { synthesizeTemplates } from "./synthesize";
import { buildRenderContext, renderTemplate } from "./template"; import { buildRenderContext, renderTemplate } from "./template";
+7 -4
View File
@@ -1,9 +1,12 @@
import {
type AppSpec,
type CategoryId,
PACKAGE_DIRS,
type PackageManager,
PEER_CATEGORIES,
} from "@withstanza/schema";
import Handlebars from "handlebars"; import Handlebars from "handlebars";
import type { AppSpec } from "./manifest";
import { type CategoryId, PACKAGE_DIRS, PEER_CATEGORIES } from "./module";
import type { PackageManager } from "./package-json";
/** /**
* Handlebars context consumed by {@link renderTemplate}. The shape is the * Handlebars context consumed by {@link renderTemplate}. The shape is the
* public contract template authors write against: * public contract template authors write against:
+48
View File
@@ -0,0 +1,48 @@
# @withstanza/schema
The contract types and [Zod](https://zod.dev) schemas behind [Stanza](https://stanza.tools) — the shape of a `stanza.json` manifest, a registry module, and the registry index, in one place.
`StanzaManifestSchema` is the single source of truth for the JSON Schema published at **<https://stanza.tools/schema.json>** (the value a manifest's `$schema` points at), so editors, the CLI, and third-party registry authors all validate against the same definitions.
> [!WARNING]
> **Major work in progress.** The schema is pre-1.0 and still evolving — see the [registry roadmap](https://stanza.tools/docs/registry).
## Install
```sh
npm install @withstanza/schema
```
## What's in it
- **`defineModule(...)`** — identity helper for authoring a registry module's `module.ts`; validates illegal field combinations at runtime.
- **`ModuleSchema` / `ModuleMetadataSchema` / `RegistryIndexSchema`** — runtime validation for fetched registry JSON.
- **`StanzaManifestSchema`** + manifest helpers (`emptyManifest`, `selectedOne`, `selectedAll`, `appsForRecord`, `declaredEnvNames`, …) and `CURRENT_MANIFEST_VERSION` / `MANIFEST_SCHEMA_URL`.
- **`CATEGORIES`** and the category helpers (`categoryHome`, `categoryLabel`, `categoryCardinality`, `isMulti`, `PEER_CATEGORIES`, `PACKAGE_DIRS`, …) — the canonical category list.
- **`RegistriesSchema` / `parseModuleSpec` / `expandEnv`** — third-party registry config and `@<ns>/<id>` parsing.
- **`PackageManagerSchema`** and the full set of contract types (`Module`, `ModuleAdapter`, `StanzaManifest`, `Category`, `CategoryId`, `TemplateRef`, `EnvVar`, …).
## Usage
```ts
import { defineModule, StanzaManifestSchema, CATEGORIES } from "@withstanza/schema";
// Author a module (in a registry's module.ts):
export default defineModule({
id: "cosmos",
category: "testing",
label: "Cosmos",
description: "Visual component sandbox.",
version: "1.0.0",
adapters: [{ key: "default", match: {} }],
});
// Validate a stanza.json you loaded yourself:
const manifest = StanzaManifestSchema.parse(JSON.parse(raw));
```
Most consumers reach this through [`stanza-cli`](https://www.npmjs.com/package/stanza-cli) rather than directly. Full guides — including [module authoring](https://stanza.tools/docs/authoring) — live at **[stanza.tools/docs](https://stanza.tools/docs)**.
## License
[MIT](https://github.com/jakejarvis/stanza/blob/main/LICENSE)
+52
View File
@@ -0,0 +1,52 @@
{
"name": "@withstanza/schema",
"version": "0.0.0",
"description": "JSON Schema and contract types for the Stanza manifest (stanza.json).",
"keywords": [
"json-schema",
"monorepo",
"stanza"
],
"homepage": "https://stanza.tools",
"bugs": "https://github.com/jakejarvis/stanza/issues",
"license": "MIT",
"author": "Jake Jarvis <jake@jarv.is>",
"repository": {
"type": "git",
"url": "git+https://github.com/jakejarvis/stanza.git",
"directory": "packages/schema"
},
"files": [
"dist"
],
"type": "module",
"main": "./src/index.ts",
"types": "./src/index.ts",
"exports": {
".": "./src/index.ts"
},
"publishConfig": {
"main": "./dist/index.mjs",
"types": "./dist/index.d.mts",
"exports": {
".": {
"types": "./dist/index.d.mts",
"default": "./dist/index.mjs"
}
},
"access": "public"
},
"scripts": {
"build": "vp pack"
},
"dependencies": {
"zod": "^4.4.3"
},
"devDependencies": {
"@types/node": "^25.9.1",
"@withstanza/utils": "workspace:*",
"typescript": "^6.0.3",
"vite-plus": "catalog:",
"vitest": "catalog:"
}
}
+200
View File
@@ -0,0 +1,200 @@
/**
* Kind of app a framework module targets. The closed enum lets the runtime
* validate "you can't install Next.js into a `kind: \"native\"` app" — add new
* kinds here as they're introduced (and the schema picks them up).
*/
export const APP_KINDS = ["web", "native"] as const;
export type AppKind = (typeof APP_KINDS)[number];
/** Where a category's install fields and `scope`-derived templates land. */
export type InstallHome =
| { kind: "app" } // an app entry in manifest.apps[] (selected per install)
| { kind: "repo" } // monorepo root
| { kind: "package"; dir: string }; // packages/<dir>/, named @<name>/<dir>
/** How many modules a category holds: a single choice, or several coexisting. */
export type Cardinality = "one" | "many";
export type Category = {
id: CategoryId;
label: string;
description: string;
/** `"one"` → single-choice (framework, auth); `"many"` → coexist (testing). */
cardinality: Cardinality;
/** Install destination for this category's modules. */
home: InstallHome;
};
/**
* Single source of truth for categories — `CategoryId` and `KNOWN_CATEGORIES`
* derive from this, so adding one is a one-place edit. Order is topological:
* a category appears after every category it can peer on, and `many` (leaf)
* categories come last. It's also the wizard prompt + processing order.
*
* Constraint-bearing is emergent: a category is a peer candidate iff some
* module declares `peers`/`match` against it. The resolver only treats
* `cardinality: "one"` categories as peers (`PEER_CATEGORIES`).
*/
export const CATEGORIES = [
{
id: "framework",
label: "Framework",
description: "Web and native app frameworks.",
cardinality: "one",
home: { kind: "app" },
},
{
id: "ui",
label: "UI",
description: "Styling systems and component primitives.",
cardinality: "one",
home: { kind: "package", dir: "ui" },
},
{
id: "api",
label: "API",
description: "Typed RPC layer between the framework and your services.",
cardinality: "one",
home: { kind: "package", dir: "api" },
},
{
id: "db",
label: "Database",
description: "Database engines.",
cardinality: "one",
home: { kind: "package", dir: "db" },
},
{
id: "orm",
label: "ORM",
description: "Typed query layers over your database.",
cardinality: "one",
home: { kind: "package", dir: "db" },
},
{
id: "auth",
label: "Auth",
description: "Authentication providers and session handling.",
cardinality: "one",
home: { kind: "package", dir: "auth" },
},
{
id: "payments",
label: "Payments",
description: "Checkout, customer portal, and webhooks.",
cardinality: "one",
home: { kind: "package", dir: "payments" },
},
{
id: "email",
label: "Email",
description: "Transactional email providers and templates.",
cardinality: "one",
home: { kind: "package", dir: "email" },
},
{
id: "ai",
label: "AI",
description: "AI SDK and provider wiring.",
cardinality: "one",
home: { kind: "package", dir: "ai" },
},
{
id: "tooling",
label: "Tooling",
description: "Linter and formatter toolchains.",
cardinality: "one",
home: { kind: "repo" },
},
{
id: "testing",
label: "Testing",
description: "Test runners — unit and end-to-end.",
cardinality: "many",
home: { kind: "app" },
},
{
id: "deploy",
label: "Deploy",
description: "Deploy targets.",
cardinality: "many",
home: { kind: "repo" },
},
{
id: "monorepo",
label: "Monorepo",
description: "Workspace task orchestrators.",
cardinality: "one",
home: { kind: "repo" },
},
// Inline shape (not `Category`) so `CategoryId` derives without a cycle.
] as const satisfies readonly {
id: string;
label: string;
description: string;
cardinality: Cardinality;
home: InstallHome;
}[];
/** Legal category ids, derived from `CATEGORIES`. */
export type CategoryId = (typeof CATEGORIES)[number]["id"];
/** Category ids as a non-empty tuple — the shape `z.enum` needs. Also the processing order. */
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<
CategoryId,
Category
>;
/** Display name — used by the wizard, summary, and CLI/web list output. */
export function categoryLabel(id: CategoryId): string {
return CATEGORY_BY_ID[id].label;
}
/** One-line blurb — used as the category landing-page subtitle. */
export function categoryDescription(id: CategoryId): string {
return CATEGORY_BY_ID[id].description;
}
/** Install destination for a category's modules. */
export function categoryHome(id: CategoryId): InstallHome {
return CATEGORY_BY_ID[id].home;
}
export function categoryCardinality(id: CategoryId): Cardinality {
return CATEGORY_BY_ID[id].cardinality;
}
/** True for multi-choice categories (testing, deploy, …). */
export function isMulti(id: CategoryId): boolean {
return CATEGORY_BY_ID[id].cardinality === "many";
}
/**
* Categories that can be peers — only `cardinality: "one"` ones, since you
* dispatch on "the framework", not on a set. The resolver iterates these.
*/
export const PEER_CATEGORIES = CATEGORIES.filter((c) => c.cardinality === "one").map(
(c) => c.id,
) as CategoryId[];
/** Unique package dirs across all categories (for cross-package wiring + sweep). */
export const PACKAGE_DIRS: Set<string> = new Set(
CATEGORIES.flatMap((c) => (c.home.kind === "package" ? [c.home.dir] : [])),
);
/** A module id — interned into manifests and registry URLs. */
export type ModuleId = string;
+64
View File
@@ -0,0 +1,64 @@
export type { AppKind, Cardinality, Category, CategoryId, InstallHome, ModuleId } from "./category";
export {
APP_KINDS,
CATEGORIES,
categoryCardinality,
categoryDescription,
categoryHome,
categoryLabel,
isCategoryId,
isMulti,
KNOWN_CATEGORIES,
PACKAGE_DIRS,
PEER_CATEGORIES,
} from "./category";
export type {
AppSpec,
RegionMap,
RegionOwnership,
StanzaManifest,
StanzaModuleRecord,
} from "./manifest";
export {
appsForRecord,
CURRENT_MANIFEST_VERSION,
declaredEnvNames,
defaultWebApp,
emptyManifest,
getApp,
MANIFEST_SCHEMA_URL,
selectedAll,
selectedOne,
StanzaManifestSchema,
SUPPORTED_MANIFEST_VERSIONS,
} from "./manifest";
export type { PackageManager } from "./package-manager";
export { DEFAULT_PACKAGE_MANAGER, isPackageManager, PackageManagerSchema } from "./package-manager";
export type { RegistryConfig } from "./registry-config";
export {
DEFAULT_NAMESPACE,
expandEnv,
isLikelyNamespaceTypo,
isNamespace,
isValidModuleId,
parseModuleSpec,
RegistriesSchema,
RegistryConfigSchema,
} from "./registry-config";
export type {
CodemodInvocation,
EnvVar,
JsonValue,
Logo,
Module,
ModuleAdapter,
ModuleMetadata,
PeerRequirement,
RegistryIndex,
TemplateRef,
} from "./module";
export { defineModule, ModuleMetadataSchema, ModuleSchema, RegistryIndexSchema } from "./module";
@@ -8,7 +8,8 @@ import {
type CategoryId, type CategoryId,
KNOWN_CATEGORIES, KNOWN_CATEGORIES,
type ModuleId, type ModuleId,
} from "./module"; } from "./category";
import { type PackageManager, PackageManagerSchema } from "./package-manager";
import { type RegistryConfig, RegistriesSchema } from "./registry-config"; import { type RegistryConfig, RegistriesSchema } from "./registry-config";
export const CURRENT_MANIFEST_VERSION = "0.4" as const; export const CURRENT_MANIFEST_VERSION = "0.4" as const;
@@ -97,7 +98,7 @@ export type StanzaManifest = {
*/ */
version: (typeof SUPPORTED_MANIFEST_VERSIONS)[number]; version: (typeof SUPPORTED_MANIFEST_VERSIONS)[number];
projectShape: "monorepo"; projectShape: "monorepo";
packageManager: "pnpm" | "bun" | "npm"; packageManager: PackageManager;
/** Display name; usually the repo root name. */ /** Display name; usually the repo root name. */
name: string; name: string;
/** /**
@@ -144,7 +145,7 @@ export const StanzaManifestSchema = z
// CLI re-stamps to CURRENT_MANIFEST_VERSION on the next write. // CLI re-stamps to CURRENT_MANIFEST_VERSION on the next write.
version: z.enum(SUPPORTED_MANIFEST_VERSIONS), version: z.enum(SUPPORTED_MANIFEST_VERSIONS),
projectShape: z.literal("monorepo"), projectShape: z.literal("monorepo"),
packageManager: z.enum(["pnpm", "bun", "npm"]), packageManager: PackageManagerSchema,
name: z.string(), name: z.string(),
apps: z.array(appSpecSchema).min(1), apps: z.array(appSpecSchema).min(1),
// Zod 4: partialRecord because not every category is filled. Every category // Zod 4: partialRecord because not every category is filled. Every category
@@ -219,7 +220,7 @@ export function defaultWebApp(): AppSpec {
export function emptyManifest(input: { export function emptyManifest(input: {
name: string; name: string;
apps?: AppSpec[]; apps?: AppSpec[];
packageManager?: StanzaManifest["packageManager"]; packageManager?: PackageManager;
}): StanzaManifest { }): StanzaManifest {
return { return {
$schema: MANIFEST_SCHEMA_URL, $schema: MANIFEST_SCHEMA_URL,
@@ -297,17 +298,3 @@ export function declaredEnvNames(manifest: StanzaManifest): string[] {
if (!envRegions) return []; if (!envRegions) return [];
return Object.keys(envRegions).toSorted(); return Object.keys(envRegions).toSorted();
} }
/**
* JSON Schema for `stanza.json`, derived from the single Zod source of truth.
* Published at {@link MANIFEST_SCHEMA_URL} so editors can validate and
* autocomplete the manifest.
*/
export function manifestJsonSchema(): Record<string, unknown> {
return {
$id: MANIFEST_SCHEMA_URL,
title: "Stanza manifest",
description: "Schema for stanza.json — a Stanza monorepo manifest.",
...z.toJSONSchema(StanzaManifestSchema),
};
}
@@ -1,206 +1,15 @@
import { safeRelativePath } from "@withstanza/utils";
import { z } from "zod"; import { z } from "zod";
import { safeRelativePath } from "./safe-path"; import {
APP_KINDS,
/** type AppKind,
* Kind of app a framework module targets. The closed enum lets the runtime type Category,
* validate "you can't install Next.js into a `kind: \"native\"` app" add new type CategoryId,
* kinds here as they're introduced (and the schema picks them up). categoryHome,
*/ KNOWN_CATEGORIES,
export const APP_KINDS = ["web", "native"] as const; type ModuleId,
export type AppKind = (typeof APP_KINDS)[number]; } from "./category";
/** Where a category's install fields and `scope`-derived templates land. */
export type InstallHome =
| { kind: "app" } // an app entry in manifest.apps[] (selected per install)
| { kind: "repo" } // monorepo root
| { kind: "package"; dir: string }; // packages/<dir>/, named @<name>/<dir>
/** How many modules a category holds: a single choice, or several coexisting. */
export type Cardinality = "one" | "many";
export type Category = {
id: CategoryId;
label: string;
description: string;
/** `"one"` → single-choice (framework, auth); `"many"` → coexist (testing). */
cardinality: Cardinality;
/** Install destination for this category's modules. */
home: InstallHome;
};
/**
* Single source of truth for categories `CategoryId` and `KNOWN_CATEGORIES`
* derive from this, so adding one is a one-place edit. Order is topological:
* a category appears after every category it can peer on, and `many` (leaf)
* categories come last. It's also the wizard prompt + processing order.
*
* Constraint-bearing is emergent: a category is a peer candidate iff some
* module declares `peers`/`match` against it. The resolver only treats
* `cardinality: "one"` categories as peers (`PEER_CATEGORIES`).
*/
export const CATEGORIES = [
{
id: "framework",
label: "Framework",
description: "Web and native app frameworks.",
cardinality: "one",
home: { kind: "app" },
},
{
id: "ui",
label: "UI",
description: "Styling systems and component primitives.",
cardinality: "one",
home: { kind: "package", dir: "ui" },
},
{
id: "api",
label: "API",
description: "Typed RPC layer between the framework and your services.",
cardinality: "one",
home: { kind: "package", dir: "api" },
},
{
id: "db",
label: "Database",
description: "Database engines.",
cardinality: "one",
home: { kind: "package", dir: "db" },
},
{
id: "orm",
label: "ORM",
description: "Typed query layers over your database.",
cardinality: "one",
home: { kind: "package", dir: "db" },
},
{
id: "auth",
label: "Auth",
description: "Authentication providers and session handling.",
cardinality: "one",
home: { kind: "package", dir: "auth" },
},
{
id: "payments",
label: "Payments",
description: "Checkout, customer portal, and webhooks.",
cardinality: "one",
home: { kind: "package", dir: "payments" },
},
{
id: "email",
label: "Email",
description: "Transactional email providers and templates.",
cardinality: "one",
home: { kind: "package", dir: "email" },
},
{
id: "ai",
label: "AI",
description: "AI SDK and provider wiring.",
cardinality: "one",
home: { kind: "package", dir: "ai" },
},
{
id: "tooling",
label: "Tooling",
description: "Linter and formatter toolchains.",
cardinality: "one",
home: { kind: "repo" },
},
{
id: "testing",
label: "Testing",
description: "Test runners — unit and end-to-end.",
cardinality: "many",
home: { kind: "app" },
},
{
id: "deploy",
label: "Deploy",
description: "Deploy targets.",
cardinality: "many",
home: { kind: "repo" },
},
{
id: "monorepo",
label: "Monorepo",
description: "Workspace task orchestrators.",
cardinality: "one",
home: { kind: "repo" },
},
// Inline shape (not `Category`) so `CategoryId` derives without a cycle.
] as const satisfies readonly {
id: string;
label: string;
description: string;
cardinality: Cardinality;
home: InstallHome;
}[];
/** Legal category ids, derived from `CATEGORIES`. */
export type CategoryId = (typeof CATEGORIES)[number]["id"];
/** Category ids as a non-empty tuple — the shape `z.enum` needs. Also the processing order. */
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<
CategoryId,
Category
>;
/** Display name — used by the wizard, summary, and CLI/web list output. */
export function categoryLabel(id: CategoryId): string {
return CATEGORY_BY_ID[id].label;
}
/** One-line blurb — used as the category landing-page subtitle. */
export function categoryDescription(id: CategoryId): string {
return CATEGORY_BY_ID[id].description;
}
/** Install destination for a category's modules. */
export function categoryHome(id: CategoryId): InstallHome {
return CATEGORY_BY_ID[id].home;
}
export function categoryCardinality(id: CategoryId): Cardinality {
return CATEGORY_BY_ID[id].cardinality;
}
/** True for multi-choice categories (testing, deploy, …). */
export function isMulti(id: CategoryId): boolean {
return CATEGORY_BY_ID[id].cardinality === "many";
}
/**
* Categories that can be peers only `cardinality: "one"` ones, since you
* dispatch on "the framework", not on a set. The resolver iterates these.
*/
export const PEER_CATEGORIES = CATEGORIES.filter((c) => c.cardinality === "one").map(
(c) => c.id,
) as CategoryId[];
/** Unique package dirs across all categories (for cross-package wiring + sweep). */
export const PACKAGE_DIRS: Set<string> = new Set(
CATEGORIES.flatMap((c) => (c.home.kind === "package" ? [c.home.dir] : [])),
);
export type ModuleId = string;
export type PeerRequirement = { export type PeerRequirement = {
[K in CategoryId]?: ModuleId[] | "any"; [K in CategoryId]?: ModuleId[] | "any";
+17
View File
@@ -0,0 +1,17 @@
import { z } from "zod";
/**
* Package managers Stanza scaffolds for. Persisted in `stanza.json`
* (`packageManager`) and surfaced to templates as `{{pm}}`; pnpm is the
* default. Closed enum so a manifest naming an unknown pm fails validation.
*/
export const PackageManagerSchema = z.enum(["pnpm", "bun", "npm"]);
export type PackageManager = z.infer<typeof PackageManagerSchema>;
/** Default when the caller doesn't pick one. */
export const DEFAULT_PACKAGE_MANAGER: PackageManager = "pnpm";
/** Narrowing guard for untrusted input (URL search params, env vars). */
export function isPackageManager(value: unknown): value is PackageManager {
return PackageManagerSchema.safeParse(value).success;
}
+8
View File
@@ -0,0 +1,8 @@
{
"extends": "../../tsconfig.json",
"compilerOptions": {
"types": ["node"]
},
"include": ["**/*.ts"],
"exclude": ["node_modules", "dist"]
}

Some files were not shown because too many files have changed in this diff Show More