mirror of
https://github.com/jakejarvis/stanza.git
synced 2026-07-16 16:55:58 -04:00
refactor: extract @withstanza/schema and @withstanza/utils packages (#16)
This commit is contained in:
@@ -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
@@ -6,7 +6,6 @@ dist/
|
||||
*.tsbuildinfo
|
||||
.tanstack/
|
||||
.source/
|
||||
.vercel
|
||||
|
||||
# IDE
|
||||
.vscode/*
|
||||
@@ -31,6 +30,5 @@ Thumbs.db
|
||||
coverage/
|
||||
__snapshots__/.tmp/
|
||||
|
||||
# misc
|
||||
apps/web/public/registry/
|
||||
apps/web/public/schema.json
|
||||
# Vercel
|
||||
.vercel
|
||||
|
||||
@@ -1,15 +1,18 @@
|
||||
# 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
|
||||
|
||||
- `apps/cli/` — `stanza-cli`, entry `src/bin.ts` (publishable, ESM via tsdown)
|
||||
- `apps/web/` — `@stanza/web`, TanStack Start visual builder (private, deployed to Vercel)
|
||||
- `packages/registry/` — schema, resolver, manifest validator (private; inlined into CLI bundle)
|
||||
- `apps/web/` — `@withstanza/web`, TanStack Start visual builder (private, deployed to Vercel)
|
||||
- `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/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)
|
||||
- `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
|
||||
|
||||
## 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 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 @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`
|
||||
- `src/routeTree.gen.ts` is generated by `vp run @stanza/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
|
||||
- `pnpm changeset` — drop a markdown file after a substantive PR; the release workflow handles versioning + publish. Only `stanza-cli` + `create-stanza` ship to npm
|
||||
- `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 @withstanza/web#build` — run it before the first `vp check` if missing
|
||||
- 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. `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.
|
||||
|
||||
@@ -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
|
||||
- **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
|
||||
- **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)
|
||||
- **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
|
||||
- **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
|
||||
- **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 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 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. 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)
|
||||
|
||||
## Module authoring
|
||||
|
||||
@@ -45,11 +45,15 @@ apps/
|
||||
cli/ # stanza-cli — the CLI binary
|
||||
web/ # https://stanza.tools (TanStack Start)
|
||||
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
|
||||
utils/ # shared path-safety + env-file helpers
|
||||
create-stanza/ # `npm init stanza` template shim
|
||||
registry/
|
||||
modules/ # first-party modules (framework, orm, db, auth, ui, tooling, testing)
|
||||
scripts/
|
||||
compile-registry.ts # builds the static registry JSON the CLI consumes
|
||||
```
|
||||
|
||||
## License
|
||||
|
||||
@@ -57,10 +57,12 @@
|
||||
"zod": "^4.4.3"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@stanza/codemods": "workspace:*",
|
||||
"@stanza/registry": "workspace:*",
|
||||
"@types/node": "^25.9.1",
|
||||
"@types/semver": "^7.7.1",
|
||||
"@withstanza/codemods": "workspace:*",
|
||||
"@withstanza/registry": "workspace:*",
|
||||
"@withstanza/schema": "workspace:*",
|
||||
"@withstanza/utils": "workspace:*",
|
||||
"typescript": "^6.0.3",
|
||||
"vite-plus": "catalog:",
|
||||
"vitest": "catalog:"
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
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 {
|
||||
categoryHome,
|
||||
DEFAULT_NAMESPACE,
|
||||
@@ -9,9 +10,8 @@ import {
|
||||
isValidModuleId,
|
||||
KNOWN_CATEGORIES,
|
||||
parseModuleSpec,
|
||||
resolveAdapter,
|
||||
selectedAll,
|
||||
} from "@stanza/registry";
|
||||
} from "@withstanza/schema";
|
||||
import { defineCommand } from "citty";
|
||||
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
|
||||
// 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)) {
|
||||
p.log.error(
|
||||
`Invalid module id "${moduleId}". Ids must be alphanumeric segments ` +
|
||||
|
||||
@@ -1,12 +1,15 @@
|
||||
import { execFileSync } from "node:child_process";
|
||||
import fs from "node:fs";
|
||||
import { createServer, type Server } from "node:http";
|
||||
import os from "node:os";
|
||||
import path from "node:path";
|
||||
import { fileURLToPath } from "node:url";
|
||||
|
||||
import { CATEGORIES } from "@stanza/registry";
|
||||
import { buildRegistry } from "@stanza/registry/build";
|
||||
import { CATEGORIES } from "@withstanza/schema";
|
||||
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 { cmdAdd } from "./add";
|
||||
import { cmdDoctor } from "./doctor";
|
||||
@@ -23,10 +26,17 @@ let prevExitCode: typeof process.exitCode;
|
||||
let fixtureRoot: string;
|
||||
let fixtureMain: string;
|
||||
|
||||
beforeAll(async () => {
|
||||
beforeAll(() => {
|
||||
fixtureRoot = fs.mkdtempSync(path.join(os.tmpdir(), "stanza-reg-"));
|
||||
await buildRegistry({ outBase: fixtureRoot });
|
||||
fixtureMain = path.join(fixtureRoot, "registry", "index.json");
|
||||
// Build the real first-party registry via the standalone build script — the
|
||||
// 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(() => {
|
||||
|
||||
@@ -2,7 +2,7 @@ import fs from "node:fs";
|
||||
import path from "node:path";
|
||||
|
||||
import * as p from "@clack/prompts";
|
||||
import { PACKAGE_DIRS } from "@stanza/registry";
|
||||
import { PACKAGE_DIRS } from "@withstanza/schema";
|
||||
import { defineCommand } from "citty";
|
||||
import pc from "picocolors";
|
||||
|
||||
|
||||
@@ -2,21 +2,24 @@ import fs from "node:fs";
|
||||
import path from "node:path";
|
||||
|
||||
import * as p from "@clack/prompts";
|
||||
import type { AppSpec, CategoryId, Module, Resolved, ResolvedEntry } from "@stanza/registry";
|
||||
import type { Resolved, ResolvedEntry } from "@withstanza/registry";
|
||||
import {
|
||||
appPackageJsonBase,
|
||||
categoryHome,
|
||||
categoryOrder,
|
||||
DEFAULT_NAMESPACE,
|
||||
ENV_EXAMPLE_HEADER,
|
||||
KNOWN_CATEGORIES,
|
||||
PEER_CATEGORIES,
|
||||
pmRun,
|
||||
PM_FLOOR_VERSION,
|
||||
resolveAdapter,
|
||||
rootPackageJson,
|
||||
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 pc from "picocolors";
|
||||
|
||||
@@ -228,7 +231,7 @@ export async function cmdInit(args: CliArgs): Promise<void> {
|
||||
|
||||
async function bootstrapShell(
|
||||
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
|
||||
// the floor (Corepack requires an exact version, so the modifier is stripped).
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
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 pc from "picocolors";
|
||||
|
||||
|
||||
@@ -2,8 +2,8 @@ import fs from "node:fs";
|
||||
import path from "node:path";
|
||||
|
||||
import * as p from "@clack/prompts";
|
||||
import { removePackageDependency, removeEnvVar } from "@stanza/codemods";
|
||||
import type { AppSpec, StanzaManifest, StanzaModuleRecord } from "@stanza/registry";
|
||||
import { removePackageDependency, removeEnvVar } from "@withstanza/codemods";
|
||||
import type { AppSpec, StanzaManifest, StanzaModuleRecord } from "@withstanza/schema";
|
||||
import {
|
||||
appsForRecord,
|
||||
categoryHome,
|
||||
@@ -15,7 +15,7 @@ import {
|
||||
PACKAGE_DIRS,
|
||||
parseModuleSpec,
|
||||
selectedAll,
|
||||
} from "@stanza/registry";
|
||||
} from "@withstanza/schema";
|
||||
import { defineCommand } from "citty";
|
||||
import pc from "picocolors";
|
||||
|
||||
|
||||
@@ -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 pc from "picocolors";
|
||||
|
||||
|
||||
@@ -2,7 +2,7 @@ import fs from "node:fs";
|
||||
import os from "node:os";
|
||||
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 { planSlotPackageBootstrap, recordFor, writeDepKeepingHigher } from "./codemod-runner";
|
||||
|
||||
@@ -1,10 +1,19 @@
|
||||
import fs from "node:fs";
|
||||
import path from "node:path";
|
||||
|
||||
import { openProject } from "@stanza/codemods";
|
||||
import { addPackageDependency, addPackageScript, addEnvVar } from "@stanza/codemods";
|
||||
import type { CodemodContext, Project } from "@stanza/codemods";
|
||||
import { CODEMOD_CATALOG } from "@stanza/codemods/builtins";
|
||||
import { openProject } from "@withstanza/codemods";
|
||||
import { addPackageDependency, addPackageScript, addEnvVar } from "@withstanza/codemods";
|
||||
import type { CodemodContext, Project } from "@withstanza/codemods";
|
||||
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 {
|
||||
AppSpec,
|
||||
CategoryId,
|
||||
@@ -12,21 +21,10 @@ import type {
|
||||
Module,
|
||||
ModuleAdapter,
|
||||
StanzaManifest,
|
||||
TemplateContext,
|
||||
TemplateRef,
|
||||
} from "@stanza/registry";
|
||||
import {
|
||||
activePeerIds,
|
||||
assertSafeRelativePath,
|
||||
buildRenderContext,
|
||||
categoryHome,
|
||||
declaredEnvNames,
|
||||
installPackageJsonTargets,
|
||||
mergeInstallFields,
|
||||
PACKAGE_DIRS,
|
||||
renderTemplate,
|
||||
slotPackageJsonBase,
|
||||
} from "@stanza/registry";
|
||||
} from "@withstanza/schema";
|
||||
import { categoryHome, declaredEnvNames, PACKAGE_DIRS } from "@withstanza/schema";
|
||||
import { assertSafeRelativePath } from "@withstanza/utils";
|
||||
import semver from "semver";
|
||||
|
||||
import { FileTx } from "./file-tx";
|
||||
@@ -117,7 +115,7 @@ export async function applyModule(args: {
|
||||
// on conflicts; env merges by `name`.
|
||||
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
|
||||
// root, or each targeted app.
|
||||
const packageDir = home.kind === "package" ? home.dir : null;
|
||||
|
||||
@@ -8,7 +8,7 @@ import {
|
||||
MANIFEST_SCHEMA_URL,
|
||||
StanzaManifestSchema,
|
||||
type StanzaManifest,
|
||||
} from "@stanza/registry";
|
||||
} from "@withstanza/schema";
|
||||
|
||||
const MANIFEST_FILENAME = "stanza.json";
|
||||
|
||||
|
||||
@@ -2,8 +2,9 @@ import { createHash } from "node:crypto";
|
||||
import fs from "node:fs";
|
||||
import path from "node:path";
|
||||
|
||||
import type { Resolved, ResolvedEntry, StanzaManifest } from "@stanza/registry";
|
||||
import { activePeerIds, categoryOrder, synthesizeReadme } from "@stanza/registry";
|
||||
import type { Resolved, ResolvedEntry } from "@withstanza/registry";
|
||||
import { activePeerIds, categoryOrder, synthesizeReadme } from "@withstanza/registry";
|
||||
import type { StanzaManifest } from "@withstanza/schema";
|
||||
|
||||
import type { Registries } from "./registry-loader";
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -2,8 +2,13 @@ import fs from "node:fs";
|
||||
import path from "node:path";
|
||||
import { fileURLToPath } from "node:url";
|
||||
|
||||
import type { Module, RegistryConfig, RegistryIndex, StanzaManifest } from "@stanza/registry";
|
||||
import { DEFAULT_NAMESPACE, expandEnv, ModuleSchema, RegistryIndexSchema } from "@stanza/registry";
|
||||
import type { Module, RegistryConfig, RegistryIndex, StanzaManifest } from "@withstanza/schema";
|
||||
import {
|
||||
DEFAULT_NAMESPACE,
|
||||
expandEnv,
|
||||
ModuleSchema,
|
||||
RegistryIndexSchema,
|
||||
} from "@withstanza/schema";
|
||||
|
||||
/**
|
||||
* The published Stanza website hosts the canonical first-party registry. A
|
||||
|
||||
@@ -2,17 +2,21 @@ import fs from "node:fs";
|
||||
import path from "node:path";
|
||||
|
||||
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 {
|
||||
categoryLabel,
|
||||
categoryOrder,
|
||||
defaultWebApp,
|
||||
emptyManifest,
|
||||
isMulti,
|
||||
KNOWN_CATEGORIES,
|
||||
resolveAdapter,
|
||||
validateProjectName,
|
||||
} from "@stanza/registry";
|
||||
} from "@withstanza/schema";
|
||||
import pc from "picocolors";
|
||||
|
||||
import type { Registries } from "./registry-loader";
|
||||
@@ -25,7 +29,7 @@ export type WizardResult = {
|
||||
* multi-app-shaped.
|
||||
*/
|
||||
apps: AppSpec[];
|
||||
packageManager: "pnpm" | "bun" | "npm";
|
||||
packageManager: PackageManager;
|
||||
/** Chosen modules, keyed by category. Single-choice categories hold one. */
|
||||
selections: Partial<Record<CategoryId, Module[]>>;
|
||||
};
|
||||
@@ -37,7 +41,7 @@ export type WizardResult = {
|
||||
*/
|
||||
export type WizardOverrides = {
|
||||
name?: string;
|
||||
packageManager?: "pnpm" | "bun" | "npm";
|
||||
packageManager?: PackageManager;
|
||||
/** Category → module ids (comma-separated on the CLI). Missing = skipped. */
|
||||
selections: Partial<Record<CategoryId, string[]>>;
|
||||
};
|
||||
|
||||
@@ -0,0 +1,2 @@
|
||||
public/registry/
|
||||
public/schema.json
|
||||
@@ -18,7 +18,7 @@ A module lives in a directory named `<category>-<id>/` and exports a default `de
|
||||
|
||||
```ts
|
||||
// registry/modules/testing-vitest/module.ts
|
||||
import { defineModule } from "@stanza/registry";
|
||||
import { defineModule } from "@withstanza/schema";
|
||||
|
||||
export default defineModule({
|
||||
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.
|
||||
|
||||
**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
|
||||
|
||||
@@ -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`. |
|
||||
| `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
|
||||
|
||||
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.
|
||||
- `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.
|
||||
|
||||
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
|
||||
|
||||
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>/
|
||||
├── registry/
|
||||
│ ├── index.json # main file: categories + per-module summaries (each with a `path`)
|
||||
│ └── modules/
|
||||
│ ├── <category>-<id>.json # one file per module, templates inlined
|
||||
│ └── …
|
||||
└── schema.json # JSON Schema for stanza.json
|
||||
├── index.json # main file: categories + per-module summaries (each with a `path`)
|
||||
└── modules/
|
||||
├── <category>-<id>.json # one file per module, templates inlined
|
||||
└── …
|
||||
```
|
||||
|
||||
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:
|
||||
|
||||
```sh
|
||||
# Default output: <repoRoot>/dist
|
||||
jiti packages/registry/src/build.ts
|
||||
# Default output: <repoRoot>/dist (writes dist/index.json + dist/modules/*.json)
|
||||
jiti scripts/compile-registry.ts
|
||||
|
||||
# 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
|
||||
|
||||
@@ -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:
|
||||
|
||||
```ts
|
||||
import { defineModule } from "@stanza/registry";
|
||||
import { defineModule } from "@withstanza/schema";
|
||||
|
||||
export default defineModule({
|
||||
id: "cosmos",
|
||||
@@ -423,13 +421,13 @@ export default defineModule({
|
||||
**2. Build the registry** with the script above. Output:
|
||||
|
||||
```
|
||||
out/registry/
|
||||
out/
|
||||
├── index.json
|
||||
└── modules/
|
||||
└── 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`:
|
||||
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
{
|
||||
"name": "@stanza/web",
|
||||
"name": "@withstanza/web",
|
||||
"version": "0.0.0",
|
||||
"private": true,
|
||||
"license": "MIT",
|
||||
@@ -16,7 +16,6 @@
|
||||
"@number-flow/react": "^0.6.0",
|
||||
"@orama/orama": "^3.1.18",
|
||||
"@pierre/trees": "1.0.0-beta.4",
|
||||
"@stanza/registry": "workspace:*",
|
||||
"@tabler/icons-react": "^3.44.0",
|
||||
"@tailwindcss/vite": "^4.3.0",
|
||||
"@takumi-rs/image-response": "^1.6.0",
|
||||
@@ -28,6 +27,8 @@
|
||||
"@tanstack/react-start": "^1.168.18",
|
||||
"@vercel/analytics": "^2.0.1",
|
||||
"@vercel/functions": "^3.6.1",
|
||||
"@withstanza/registry": "workspace:*",
|
||||
"@withstanza/schema": "workspace:*",
|
||||
"class-variance-authority": "^0.7.1",
|
||||
"clsx": "^2.1.1",
|
||||
"fumadocs-core": "^16.9.3",
|
||||
|
||||
@@ -1,14 +1,13 @@
|
||||
import type { CategoryId, ModuleMetadata } from "@stanza/registry";
|
||||
import { isMulti } from "@stanza/registry";
|
||||
import { useNavigate, useRouterState } from "@tanstack/react-router";
|
||||
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 { FilePreview } from "@/components/builder/file-preview";
|
||||
import { ModuleCards } from "@/components/builder/module-cards";
|
||||
import { ProjectSetup } from "@/components/builder/project-setup";
|
||||
import { CommandPreview } from "@/components/command-preview";
|
||||
import type { PackageManager } from "@/lib/package-manager";
|
||||
import {
|
||||
type BuilderSearch,
|
||||
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 {
|
||||
categoryLabel,
|
||||
emptyManifest,
|
||||
isMulti,
|
||||
KNOWN_CATEGORIES,
|
||||
PEER_CATEGORIES,
|
||||
resolveAdapter,
|
||||
} from "@stanza/registry";
|
||||
import { IconCheck, IconInfoCircle } from "@tabler/icons-react";
|
||||
import { Link } from "@tanstack/react-router";
|
||||
} from "@withstanza/schema";
|
||||
import { memo, useCallback, useMemo } from "react";
|
||||
|
||||
import { ModuleLogo } from "@/components/module-logo";
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { validateProjectName } from "@stanza/registry";
|
||||
import { IconAlertCircle } from "@tabler/icons-react";
|
||||
import { useDebouncedCallback } from "@tanstack/react-pacer";
|
||||
import { validateProjectName } from "@withstanza/registry";
|
||||
import type { ChangeEvent } from "react";
|
||||
import { useId, useState } from "react";
|
||||
|
||||
|
||||
@@ -1,9 +1,9 @@
|
||||
import { track } from "@vercel/analytics";
|
||||
import type { PackageManager } from "@withstanza/schema";
|
||||
|
||||
import { PackageManagerSelect } from "@/components/package-manager-select";
|
||||
import { CopyableField } from "@/components/ui/copyable-field";
|
||||
import { selectionProperties } from "@/lib/analytics";
|
||||
import type { PackageManager } from "@/lib/package-manager";
|
||||
import { buildCommand, DEFAULT_NAME, type Selections } from "@/lib/selection";
|
||||
|
||||
/**
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import type { CategoryId, ModuleMetadata, RegistryIndex } from "@stanza/registry";
|
||||
import { categoryLabel, KNOWN_CATEGORIES } from "@stanza/registry";
|
||||
import type { CategoryId, ModuleMetadata, RegistryIndex } from "@withstanza/schema";
|
||||
import { categoryLabel, KNOWN_CATEGORIES } from "@withstanza/schema";
|
||||
import { useMemo } from "react";
|
||||
|
||||
import { ToggleGroup, ToggleGroupItem } from "@/components/ui/toggle-group";
|
||||
|
||||
@@ -1,8 +1,8 @@
|
||||
import { DEFAULT_PACKAGE_MANAGER, type PackageManager } from "@withstanza/schema";
|
||||
import { useState } from "react";
|
||||
|
||||
import { CommandPreview } from "@/components/command-preview";
|
||||
import { Section } from "@/components/detail/section";
|
||||
import { DEFAULT_PACKAGE_MANAGER, type PackageManager } from "@/lib/package-manager";
|
||||
import type { Selections } from "@/lib/selection";
|
||||
|
||||
export function Install({ name, selections }: { name: string; selections: Selections }) {
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import type { EnvVar } from "@stanza/registry";
|
||||
import { IconExternalLink } from "@tabler/icons-react";
|
||||
import type { EnvVar } from "@withstanza/schema";
|
||||
|
||||
import { Section, SectionList } from "@/components/detail/section";
|
||||
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 { Section, SectionList } from "@/components/detail/section";
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import type { Logo } from "@stanza/registry";
|
||||
import type { Logo } from "@withstanza/schema";
|
||||
import { useMemo } from "react";
|
||||
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import { IconChevronDown } from "@tabler/icons-react";
|
||||
import { isPackageManager, type PackageManager } from "@withstanza/schema";
|
||||
import type { ComponentProps, ReactNode } from "react";
|
||||
|
||||
import { Button } from "@/components/ui/button";
|
||||
@@ -9,7 +10,7 @@ import {
|
||||
DropdownMenuRadioItem,
|
||||
DropdownMenuTrigger,
|
||||
} 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">) {
|
||||
return (
|
||||
|
||||
@@ -1,11 +1,10 @@
|
||||
"use client";
|
||||
|
||||
import type { ModuleMetadata, RegistryIndex } from "@stanza/registry";
|
||||
import { categoryLabel } from "@stanza/registry";
|
||||
import { IconBookmark, IconSearch } from "@tabler/icons-react";
|
||||
import { formatForDisplay, useHotkey } from "@tanstack/react-hotkeys";
|
||||
import { useDebouncedCallback } from "@tanstack/react-pacer";
|
||||
import { useNavigate } from "@tanstack/react-router";
|
||||
import { categoryLabel, type ModuleMetadata, type RegistryIndex } from "@withstanza/schema";
|
||||
import type { SortedResult } from "fumadocs-core/search";
|
||||
import { useDocsSearch } from "fumadocs-core/search/client";
|
||||
import { useCallback, useEffect, useMemo, useRef, useState, useSyncExternalStore } from "react";
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { KNOWN_CATEGORIES } from "@stanza/registry";
|
||||
import { KNOWN_CATEGORIES } from "@withstanza/schema";
|
||||
|
||||
import type { Selections } from "@/lib/selection";
|
||||
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import type { CategoryId, ModuleMetadata } from "@stanza/registry";
|
||||
import type { CategoryId, ModuleMetadata } from "@withstanza/schema";
|
||||
|
||||
export function groupByCategory(
|
||||
modules: ModuleMetadata[],
|
||||
|
||||
@@ -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 }[] = [
|
||||
{ id: "pnpm", label: "pnpm" },
|
||||
{ id: "npm", label: "npm" },
|
||||
{ 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);
|
||||
}
|
||||
|
||||
@@ -42,7 +42,7 @@ function listRegistryPaths(): string[] {
|
||||
} catch (error) {
|
||||
throw new Error(
|
||||
`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 },
|
||||
);
|
||||
}
|
||||
@@ -67,6 +67,7 @@ export function listPrerenderPages() {
|
||||
"/docs/llms-full.txt",
|
||||
...registry,
|
||||
"/stats",
|
||||
"/schema.json",
|
||||
];
|
||||
return paths.map((path) => ({ path, prerender: { enabled: true } }));
|
||||
}
|
||||
|
||||
@@ -1,25 +1,20 @@
|
||||
import type { Resolved, ResolvedEntry } from "@withstanza/registry";
|
||||
import { categoryOrder, resolveAdapter } from "@withstanza/registry";
|
||||
import type {
|
||||
AppSpec,
|
||||
CategoryId,
|
||||
Module,
|
||||
ModuleMetadata,
|
||||
Resolved,
|
||||
ResolvedEntry,
|
||||
} from "@stanza/registry";
|
||||
import {
|
||||
categoryOrder,
|
||||
defaultWebApp,
|
||||
emptyManifest,
|
||||
KNOWN_CATEGORIES,
|
||||
PEER_CATEGORIES,
|
||||
resolveAdapter,
|
||||
} from "@stanza/registry";
|
||||
|
||||
PackageManager,
|
||||
} from "@withstanza/schema";
|
||||
import {
|
||||
DEFAULT_PACKAGE_MANAGER,
|
||||
defaultWebApp,
|
||||
emptyManifest,
|
||||
isPackageManager,
|
||||
type PackageManager,
|
||||
} from "@/lib/package-manager";
|
||||
KNOWN_CATEGORIES,
|
||||
PEER_CATEGORIES,
|
||||
} from "@withstanza/schema";
|
||||
|
||||
/** Selected module ids per category. Single-choice categories hold ≤ 1. */
|
||||
export type Selections = Partial<Record<CategoryId, string[]>>;
|
||||
|
||||
@@ -10,6 +10,7 @@
|
||||
|
||||
import { Route as rootRouteImport } from './routes/__root'
|
||||
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 DocsDotmdRouteImport } from './routes/docs[.]md'
|
||||
import { Route as IndexRouteImport } from './routes/index'
|
||||
@@ -31,6 +32,11 @@ const StatsRoute = StatsRouteImport.update({
|
||||
path: '/stats',
|
||||
getParentRoute: () => rootRouteImport,
|
||||
} as any)
|
||||
const SchemaDotjsonRoute = SchemaDotjsonRouteImport.update({
|
||||
id: '/schema.json',
|
||||
path: '/schema.json',
|
||||
getParentRoute: () => rootRouteImport,
|
||||
} as any)
|
||||
const OgDotwebpRoute = OgDotwebpRouteImport.update({
|
||||
id: '/og.webp',
|
||||
path: '/og.webp',
|
||||
@@ -113,6 +119,7 @@ export interface FileRoutesByFullPath {
|
||||
'/': typeof IndexRoute
|
||||
'/docs.md': typeof DocsDotmdRoute
|
||||
'/og.webp': typeof OgDotwebpRoute
|
||||
'/schema.json': typeof SchemaDotjsonRoute
|
||||
'/stats': typeof StatsRoute
|
||||
'/api/events': typeof ApiEventsRoute
|
||||
'/docs/$': typeof DocsSplatRoute
|
||||
@@ -131,6 +138,7 @@ export interface FileRoutesByTo {
|
||||
'/': typeof IndexRoute
|
||||
'/docs.md': typeof DocsDotmdRoute
|
||||
'/og.webp': typeof OgDotwebpRoute
|
||||
'/schema.json': typeof SchemaDotjsonRoute
|
||||
'/stats': typeof StatsRoute
|
||||
'/api/events': typeof ApiEventsRoute
|
||||
'/docs/$': typeof DocsSplatRoute
|
||||
@@ -150,6 +158,7 @@ export interface FileRoutesById {
|
||||
'/': typeof IndexRoute
|
||||
'/docs.md': typeof DocsDotmdRoute
|
||||
'/og.webp': typeof OgDotwebpRoute
|
||||
'/schema.json': typeof SchemaDotjsonRoute
|
||||
'/stats': typeof StatsRoute
|
||||
'/api/events': typeof ApiEventsRoute
|
||||
'/docs/$': typeof DocsSplatRoute
|
||||
@@ -170,6 +179,7 @@ export interface FileRouteTypes {
|
||||
| '/'
|
||||
| '/docs.md'
|
||||
| '/og.webp'
|
||||
| '/schema.json'
|
||||
| '/stats'
|
||||
| '/api/events'
|
||||
| '/docs/$'
|
||||
@@ -188,6 +198,7 @@ export interface FileRouteTypes {
|
||||
| '/'
|
||||
| '/docs.md'
|
||||
| '/og.webp'
|
||||
| '/schema.json'
|
||||
| '/stats'
|
||||
| '/api/events'
|
||||
| '/docs/$'
|
||||
@@ -206,6 +217,7 @@ export interface FileRouteTypes {
|
||||
| '/'
|
||||
| '/docs.md'
|
||||
| '/og.webp'
|
||||
| '/schema.json'
|
||||
| '/stats'
|
||||
| '/api/events'
|
||||
| '/docs/$'
|
||||
@@ -225,6 +237,7 @@ export interface RootRouteChildren {
|
||||
IndexRoute: typeof IndexRoute
|
||||
DocsDotmdRoute: typeof DocsDotmdRoute
|
||||
OgDotwebpRoute: typeof OgDotwebpRoute
|
||||
SchemaDotjsonRoute: typeof SchemaDotjsonRoute
|
||||
StatsRoute: typeof StatsRoute
|
||||
ApiEventsRoute: typeof ApiEventsRoute
|
||||
DocsSplatRoute: typeof DocsSplatRoute
|
||||
@@ -249,6 +262,13 @@ declare module '@tanstack/react-router' {
|
||||
preLoaderRoute: typeof StatsRouteImport
|
||||
parentRoute: typeof rootRouteImport
|
||||
}
|
||||
'/schema.json': {
|
||||
id: '/schema.json'
|
||||
path: '/schema.json'
|
||||
fullPath: '/schema.json'
|
||||
preLoaderRoute: typeof SchemaDotjsonRouteImport
|
||||
parentRoute: typeof rootRouteImport
|
||||
}
|
||||
'/og.webp': {
|
||||
id: '/og.webp'
|
||||
path: '/og.webp'
|
||||
@@ -361,6 +381,7 @@ const rootRouteChildren: RootRouteChildren = {
|
||||
IndexRoute: IndexRoute,
|
||||
DocsDotmdRoute: DocsDotmdRoute,
|
||||
OgDotwebpRoute: OgDotwebpRoute,
|
||||
SchemaDotjsonRoute: SchemaDotjsonRoute,
|
||||
StatsRoute: StatsRoute,
|
||||
ApiEventsRoute: ApiEventsRoute,
|
||||
DocsSplatRoute: DocsSplatRoute,
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { create, insert, search } from "@orama/orama";
|
||||
import type { Module, ModuleMetadata, RegistryIndex } from "@stanza/registry";
|
||||
import { createFileRoute } from "@tanstack/react-router";
|
||||
import type { Module, ModuleMetadata, RegistryIndex } from "@withstanza/schema";
|
||||
import { cache } from "react";
|
||||
|
||||
import { loadRegistryFile } from "@/server/registry-base.server";
|
||||
|
||||
@@ -86,7 +86,7 @@ export const Route = createFileRoute("/docs/$")({
|
||||
title,
|
||||
description,
|
||||
path,
|
||||
ogImage: loaderData ? `/og${loaderData.url}.webp` : undefined,
|
||||
ogImage: loaderData ? `/og${path}.webp` : undefined,
|
||||
markdownPath: loaderData ? `${path}.md` : undefined,
|
||||
jsonLd:
|
||||
loaderData && title
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import type { RegistryIndex } from "@stanza/registry";
|
||||
import { ImageResponse } from "@takumi-rs/image-response";
|
||||
import { createFileRoute } from "@tanstack/react-router";
|
||||
import type { RegistryIndex } from "@withstanza/schema";
|
||||
|
||||
import { OgCard } from "@/server/og-card.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 { 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 { AdapterSwitcher } from "@/components/detail/adapter-switcher";
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { categoryDescription, categoryLabel, isCategoryId } from "@stanza/registry";
|
||||
import { IconArrowLeft } from "@tabler/icons-react";
|
||||
import { Link, createFileRoute, notFound, useLoaderData } from "@tanstack/react-router";
|
||||
import { categoryDescription, categoryLabel, isCategoryId } from "@withstanza/schema";
|
||||
import { useMemo } from "react";
|
||||
|
||||
import { Section, SectionList } from "@/components/detail/section";
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import type { Category, ModuleMetadata } from "@stanza/registry";
|
||||
import { IconArrowLeft } from "@tabler/icons-react";
|
||||
import { Link, createFileRoute, useLoaderData } from "@tanstack/react-router";
|
||||
import type { Category, ModuleMetadata } from "@withstanza/schema";
|
||||
import { useMemo } from "react";
|
||||
|
||||
import { SectionList } from "@/components/detail/section";
|
||||
|
||||
@@ -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",
|
||||
},
|
||||
});
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
@@ -1,7 +1,7 @@
|
||||
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 type { CategoryId, ModuleMetadata } from "@withstanza/schema";
|
||||
import { categoryLabel, KNOWN_CATEGORIES } from "@withstanza/schema";
|
||||
import { lazy, Suspense, useEffect, useState } from "react";
|
||||
|
||||
import { ModuleLogo } from "@/components/module-logo";
|
||||
|
||||
@@ -1,11 +1,11 @@
|
||||
import { createServerFn } from "@tanstack/react-start";
|
||||
import {
|
||||
synthesizeEnvExample,
|
||||
synthesizeManifest,
|
||||
synthesizePackageJsons,
|
||||
synthesizeReadme,
|
||||
synthesizeTemplates,
|
||||
} from "@stanza/registry";
|
||||
import { createServerFn } from "@tanstack/react-start";
|
||||
} from "@withstanza/registry";
|
||||
|
||||
import {
|
||||
DEFAULT_BUILDER_APPS,
|
||||
|
||||
@@ -1,3 +1,5 @@
|
||||
import { createServerFn } from "@tanstack/react-start";
|
||||
import { mergeInstallFields, resolveAdapter } from "@withstanza/registry";
|
||||
import type {
|
||||
CategoryId,
|
||||
EnvVar,
|
||||
@@ -6,17 +8,14 @@ import type {
|
||||
ModuleMetadata,
|
||||
PeerRequirement,
|
||||
RegistryIndex,
|
||||
} from "@stanza/registry";
|
||||
} from "@withstanza/schema";
|
||||
import {
|
||||
emptyManifest,
|
||||
isCategoryId,
|
||||
isValidModuleId,
|
||||
KNOWN_CATEGORIES,
|
||||
mergeInstallFields,
|
||||
PEER_CATEGORIES,
|
||||
resolveAdapter,
|
||||
} from "@stanza/registry";
|
||||
import { createServerFn } from "@tanstack/react-start";
|
||||
} from "@withstanza/schema";
|
||||
|
||||
import type { Preview } from "@/server/highlighter";
|
||||
import { renderPreview } from "@/server/highlighter.server";
|
||||
|
||||
@@ -1,5 +1,4 @@
|
||||
import type { ModuleMetadata } from "@stanza/registry";
|
||||
import { categoryLabel } from "@stanza/registry";
|
||||
import { categoryLabel, type ModuleMetadata } from "@withstanza/schema";
|
||||
import type { CSSProperties, ReactElement } from "react";
|
||||
|
||||
/**
|
||||
|
||||
@@ -29,7 +29,7 @@ export async function loadRegistryFile<T>(relativePath: string): Promise<T> {
|
||||
return parsed;
|
||||
} catch {
|
||||
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 type { RegistryIndex } from "@withstanza/schema";
|
||||
|
||||
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";
|
||||
|
||||
@@ -7,7 +7,7 @@ let modulesPromise: Promise<Record<string, Module>> | undefined;
|
||||
/**
|
||||
* Per-process cache for the full module catalog. Registry data is immutable
|
||||
* 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.
|
||||
*/
|
||||
export function getAllModules(): Promise<Record<string, Module>> {
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import type { CategoryId } from "@stanza/registry";
|
||||
import { KNOWN_CATEGORIES } from "@stanza/registry";
|
||||
import { createServerFn } from "@tanstack/react-start";
|
||||
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";
|
||||
|
||||
|
||||
@@ -55,12 +55,9 @@ export default defineConfig({
|
||||
tasks: {
|
||||
"compile-registry": {
|
||||
cwd: "../..",
|
||||
command: "jiti packages/registry/src/build.ts apps/web/public",
|
||||
input: [
|
||||
{ pattern: "registry/modules/**", base: "workspace" },
|
||||
{ pattern: "packages/registry/src/**", base: "workspace" },
|
||||
],
|
||||
output: ["apps/web/public/registry/**", "apps/web/public/schema.json"],
|
||||
command: "jiti scripts/compile-registry.ts apps/web/public/registry",
|
||||
input: [{ pattern: "registry/modules/**", base: "workspace" }],
|
||||
output: [{ pattern: "public/registry/**", base: "package" }],
|
||||
},
|
||||
},
|
||||
},
|
||||
|
||||
+5
-3
@@ -4,12 +4,12 @@
|
||||
"private": true,
|
||||
"description": "Modular monorepo template CLI — shadcn for stacks.",
|
||||
"license": "MIT",
|
||||
"author": "Jake Jarvis <jakejarvis@gmail.com>",
|
||||
"author": "Jake Jarvis <jake@jarv.is>",
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"build": "vp run -r build",
|
||||
"build:cli": "vp run stanza-cli#build && vp run create-stanza#build",
|
||||
"build:web": "vp run @stanza/web#build",
|
||||
"build:cli": "vp run @withstanza/schema#build && vp run stanza-cli#build && vp run create-stanza#build",
|
||||
"build:web": "vp run @withstanza/web#build",
|
||||
"dev": "vp run -r dev",
|
||||
"test": "vp test",
|
||||
"lint": "vp lint",
|
||||
@@ -25,7 +25,9 @@
|
||||
"@changesets/changelog-github": "^0.7.0",
|
||||
"@changesets/cli": "^2.31.0",
|
||||
"@types/node": "^25.9.1",
|
||||
"@withstanza/schema": "workspace:*",
|
||||
"jiti": "^2.7.0",
|
||||
"svgo": "^4.0.1",
|
||||
"typescript": "^6.0.3",
|
||||
"vite-plus": "catalog:"
|
||||
},
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
{
|
||||
"name": "@stanza/codemods",
|
||||
"name": "@withstanza/codemods",
|
||||
"version": "0.0.0",
|
||||
"private": true,
|
||||
"license": "MIT",
|
||||
@@ -11,7 +11,8 @@
|
||||
"./builtins": "./src/builtins/index.ts"
|
||||
},
|
||||
"dependencies": {
|
||||
"@stanza/registry": "workspace:*",
|
||||
"@withstanza/schema": "workspace:*",
|
||||
"@withstanza/utils": "workspace:*",
|
||||
"jsonc-parser": "^3.3.1",
|
||||
"ts-morph": "^28.0.0"
|
||||
},
|
||||
|
||||
@@ -2,7 +2,7 @@ import fs from "node:fs";
|
||||
import os from "node:os";
|
||||
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 { type CodemodContext, openProject } from "../index";
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import path from "node:path";
|
||||
|
||||
import { assertSafeRelativePath } from "@stanza/registry";
|
||||
import { assertSafeRelativePath } from "@withstanza/utils";
|
||||
import type { ArrayLiteralExpression, CallExpression, ObjectLiteralExpression } from "ts-morph";
|
||||
|
||||
import {
|
||||
|
||||
@@ -2,7 +2,7 @@ import fs from "node:fs";
|
||||
import os from "node:os";
|
||||
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 { type CodemodContext, openProject } from "../index";
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import fs from "node:fs";
|
||||
import path from "node:path";
|
||||
|
||||
import { assertSafeRelativePath } from "@stanza/registry";
|
||||
import { assertSafeRelativePath } from "@withstanza/utils";
|
||||
|
||||
import {
|
||||
addDefaultImport,
|
||||
|
||||
@@ -2,7 +2,7 @@ import fs from "node:fs";
|
||||
import os from "node:os";
|
||||
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 { 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 { openProject, type CodemodContext, type Project } from "../index";
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import path from "node:path";
|
||||
|
||||
import { assertSafeRelativePath } from "@stanza/registry";
|
||||
import { assertSafeRelativePath } from "@withstanza/utils";
|
||||
import type { ArrayLiteralExpression } from "ts-morph";
|
||||
|
||||
import {
|
||||
|
||||
@@ -2,7 +2,7 @@ import fs from "node:fs";
|
||||
import os from "node:os";
|
||||
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 { openProject, type CodemodContext, type Project } from "../index";
|
||||
|
||||
@@ -1,9 +1,9 @@
|
||||
import fs from "node:fs";
|
||||
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. */
|
||||
export type CommentStyle = "line" | "hash" | "block";
|
||||
|
||||
@@ -15,7 +15,7 @@
|
||||
* doesn't fit an existing generic codemod, design a new generic codemod
|
||||
* 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
|
||||
* 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 { openProject, type CodemodContext, type Project } from "../index";
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import path from "node:path";
|
||||
|
||||
import { assertSafeRelativePath } from "@stanza/registry";
|
||||
import { assertSafeRelativePath } from "@withstanza/utils";
|
||||
|
||||
import {
|
||||
type Codemod,
|
||||
|
||||
@@ -2,7 +2,7 @@ import fs from "node:fs";
|
||||
import os from "node:os";
|
||||
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 { type CodemodContext, openProject } from "../index";
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import path from "node:path";
|
||||
|
||||
import { assertSafeRelativePath } from "@stanza/registry";
|
||||
import { assertSafeRelativePath } from "@withstanza/utils";
|
||||
|
||||
import { type Codemod, type ImportDeclaration } from "../index";
|
||||
|
||||
|
||||
@@ -2,7 +2,7 @@ import fs from "node:fs";
|
||||
import os from "node:os";
|
||||
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 { type CodemodContext, openProject } from "../index";
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import path from "node:path";
|
||||
|
||||
import { assertSafeRelativePath } from "@stanza/registry";
|
||||
import { assertSafeRelativePath } from "@withstanza/utils";
|
||||
|
||||
import {
|
||||
type Codemod,
|
||||
|
||||
@@ -2,7 +2,7 @@ import fs from "node:fs";
|
||||
import os from "node:os";
|
||||
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 { type CodemodContext, openProject } from "../index";
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import fs from "node:fs";
|
||||
import path from "node:path";
|
||||
|
||||
import { assertSafeRelativePath } from "@stanza/registry";
|
||||
import { assertSafeRelativePath } from "@withstanza/utils";
|
||||
|
||||
import {
|
||||
type Codemod,
|
||||
|
||||
@@ -2,7 +2,7 @@ import fs from "node:fs";
|
||||
import os from "node:os";
|
||||
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 { type CodemodContext, openProject } from "../index";
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import path from "node:path";
|
||||
|
||||
import { selectedOne } from "@stanza/registry";
|
||||
import { selectedOne } from "@withstanza/schema";
|
||||
|
||||
import {
|
||||
addDefaultImport,
|
||||
|
||||
@@ -1,12 +1,12 @@
|
||||
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
|
||||
* existing entries; updates the example value in-place if the var already
|
||||
* 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.
|
||||
*/
|
||||
export function addEnvVar(
|
||||
|
||||
@@ -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";
|
||||
|
||||
export type CodemodContext = {
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
{
|
||||
"name": "@stanza/registry",
|
||||
"name": "@withstanza/registry",
|
||||
"version": "0.0.0",
|
||||
"private": true,
|
||||
"license": "MIT",
|
||||
@@ -8,19 +8,16 @@
|
||||
"types": "./src/index.ts",
|
||||
"exports": {
|
||||
".": "./src/index.ts",
|
||||
"./build": "./src/build.ts",
|
||||
"./manifest": "./src/manifest.ts",
|
||||
"./module": "./src/module.ts",
|
||||
"./resolver": "./src/resolver.ts"
|
||||
},
|
||||
"dependencies": {
|
||||
"@withstanza/schema": "workspace:*",
|
||||
"@withstanza/utils": "workspace:*",
|
||||
"handlebars": "^4.7.9",
|
||||
"validate-npm-package-name": "^8.0.0",
|
||||
"zod": "^4.4.3"
|
||||
"validate-npm-package-name": "^8.0.0"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@types/validate-npm-package-name": "^4.0.2",
|
||||
"svgo": "^4.0.1",
|
||||
"typescript": "^6.0.3",
|
||||
"vite-plus": "catalog:",
|
||||
"vitest": "catalog:"
|
||||
|
||||
@@ -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 { resolveAdapter, isCompatible, categoryOrder, activePeerIds } from "./resolver";
|
||||
|
||||
export type {
|
||||
PackageManager,
|
||||
PackageJson,
|
||||
MergedInstallFields,
|
||||
ResolvedEntry,
|
||||
@@ -91,7 +20,6 @@ export {
|
||||
|
||||
export {
|
||||
ENV_EXAMPLE_HEADER,
|
||||
appendEnvVar,
|
||||
synthesizeEnvExample,
|
||||
synthesizeManifest,
|
||||
synthesizeReadme,
|
||||
@@ -103,5 +31,3 @@ export { renderTemplate, buildRenderContext, pmRun, pmRecursive } from "./templa
|
||||
|
||||
export type { ProjectNameValidation } from "./project-name";
|
||||
export { validateProjectName } from "./project-name";
|
||||
|
||||
export { safeRelativePath, assertSafeRelativePath } from "./safe-path";
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { defineModule, type Module, ModuleSchema } from "@withstanza/schema";
|
||||
import { describe, expect, it } from "vite-plus/test";
|
||||
|
||||
import { defineModule, type Module, ModuleSchema } from "./module";
|
||||
import {
|
||||
mergeInstallFields,
|
||||
type Resolved,
|
||||
|
||||
@@ -1,18 +1,19 @@
|
||||
import { type AppSpec, defaultWebApp } from "./manifest";
|
||||
import {
|
||||
type AppSpec,
|
||||
categoryHome,
|
||||
type CategoryId,
|
||||
defaultWebApp,
|
||||
type EnvVar,
|
||||
type InstallHome,
|
||||
type Module,
|
||||
type ModuleAdapter,
|
||||
PACKAGE_DIRS,
|
||||
} from "./module";
|
||||
type PackageManager,
|
||||
} from "@withstanza/schema";
|
||||
|
||||
import { categoryOrder } from "./resolver";
|
||||
import { pmRecursive } from "./template";
|
||||
|
||||
export type PackageManager = "pnpm" | "bun" | "npm";
|
||||
|
||||
/** Minimal package.json shape we author. Field order here is the emit order. */
|
||||
export type PackageJson = {
|
||||
name?: string;
|
||||
|
||||
@@ -1,7 +1,6 @@
|
||||
import { defineModule, emptyManifest, type Module } from "@withstanza/schema";
|
||||
import { assert, describe, expect, it } from "vite-plus/test";
|
||||
|
||||
import { emptyManifest } from "./manifest";
|
||||
import { defineModule, type Module } from "./module";
|
||||
import { activePeerIds, resolveAdapter } from "./resolver";
|
||||
|
||||
const drizzle: Module = defineModule({
|
||||
|
||||
@@ -1,11 +1,12 @@
|
||||
import { type StanzaManifest, selectedOne } from "./manifest";
|
||||
import {
|
||||
type CategoryId,
|
||||
KNOWN_CATEGORIES,
|
||||
type ModuleAdapter,
|
||||
PEER_CATEGORIES,
|
||||
type PeerRequirement,
|
||||
} from "./module";
|
||||
selectedOne,
|
||||
type StanzaManifest,
|
||||
} from "@withstanza/schema";
|
||||
|
||||
/**
|
||||
* Topological order categories are processed in (derived from `CATEGORIES`):
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { defineModule, type Module } from "@withstanza/schema";
|
||||
import { describe, expect, it } from "vite-plus/test";
|
||||
|
||||
import { defineModule, type Module } from "./module";
|
||||
import type { Resolved } from "./package-json";
|
||||
import { ENV_EXAMPLE_HEADER, synthesizeEnvExample, synthesizeReadme } from "./synthesize";
|
||||
|
||||
|
||||
@@ -1,22 +1,22 @@
|
||||
import {
|
||||
type AppSpec,
|
||||
defaultWebApp,
|
||||
emptyManifest,
|
||||
type StanzaManifest,
|
||||
type StanzaModuleRecord,
|
||||
} from "./manifest";
|
||||
import {
|
||||
categoryHome,
|
||||
type CategoryId,
|
||||
categoryLabel,
|
||||
defaultWebApp,
|
||||
emptyManifest,
|
||||
type InstallHome,
|
||||
isMulti,
|
||||
type ModuleId,
|
||||
type PackageManager,
|
||||
type StanzaManifest,
|
||||
type StanzaModuleRecord,
|
||||
type TemplateRef,
|
||||
} from "./module";
|
||||
} from "@withstanza/schema";
|
||||
import { appendEnvVar } from "@withstanza/utils";
|
||||
|
||||
import {
|
||||
mergeInstallFields,
|
||||
type PackageManager,
|
||||
type Resolved,
|
||||
type ResolvedEntry,
|
||||
type SynthesizeEntry,
|
||||
@@ -27,39 +27,6 @@ import { buildRenderContext, pmRun, renderTemplate } from "./template";
|
||||
/** Header `stanza init` writes at the top of `.env.example`. */
|
||||
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:
|
||||
* the managed header followed by every module's env vars, in `categoryOrder`.
|
||||
|
||||
@@ -1,8 +1,6 @@
|
||||
import { type AppSpec, defaultWebApp, defineModule, type Module } from "@withstanza/schema";
|
||||
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 { synthesizeTemplates } from "./synthesize";
|
||||
import { buildRenderContext, renderTemplate } from "./template";
|
||||
|
||||
@@ -1,9 +1,12 @@
|
||||
import {
|
||||
type AppSpec,
|
||||
type CategoryId,
|
||||
PACKAGE_DIRS,
|
||||
type PackageManager,
|
||||
PEER_CATEGORIES,
|
||||
} from "@withstanza/schema";
|
||||
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
|
||||
* public contract template authors write against:
|
||||
|
||||
@@ -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)
|
||||
@@ -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:"
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
@@ -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,
|
||||
KNOWN_CATEGORIES,
|
||||
type ModuleId,
|
||||
} from "./module";
|
||||
} from "./category";
|
||||
import { type PackageManager, PackageManagerSchema } from "./package-manager";
|
||||
import { type RegistryConfig, RegistriesSchema } from "./registry-config";
|
||||
|
||||
export const CURRENT_MANIFEST_VERSION = "0.4" as const;
|
||||
@@ -97,7 +98,7 @@ export type StanzaManifest = {
|
||||
*/
|
||||
version: (typeof SUPPORTED_MANIFEST_VERSIONS)[number];
|
||||
projectShape: "monorepo";
|
||||
packageManager: "pnpm" | "bun" | "npm";
|
||||
packageManager: PackageManager;
|
||||
/** Display name; usually the repo root name. */
|
||||
name: string;
|
||||
/**
|
||||
@@ -144,7 +145,7 @@ export const StanzaManifestSchema = z
|
||||
// CLI re-stamps to CURRENT_MANIFEST_VERSION on the next write.
|
||||
version: z.enum(SUPPORTED_MANIFEST_VERSIONS),
|
||||
projectShape: z.literal("monorepo"),
|
||||
packageManager: z.enum(["pnpm", "bun", "npm"]),
|
||||
packageManager: PackageManagerSchema,
|
||||
name: z.string(),
|
||||
apps: z.array(appSpecSchema).min(1),
|
||||
// Zod 4: partialRecord because not every category is filled. Every category
|
||||
@@ -219,7 +220,7 @@ export function defaultWebApp(): AppSpec {
|
||||
export function emptyManifest(input: {
|
||||
name: string;
|
||||
apps?: AppSpec[];
|
||||
packageManager?: StanzaManifest["packageManager"];
|
||||
packageManager?: PackageManager;
|
||||
}): StanzaManifest {
|
||||
return {
|
||||
$schema: MANIFEST_SCHEMA_URL,
|
||||
@@ -297,17 +298,3 @@ export function declaredEnvNames(manifest: StanzaManifest): string[] {
|
||||
if (!envRegions) return [];
|
||||
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 { safeRelativePath } from "./safe-path";
|
||||
|
||||
/**
|
||||
* 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] : [])),
|
||||
);
|
||||
|
||||
export type ModuleId = string;
|
||||
import {
|
||||
APP_KINDS,
|
||||
type AppKind,
|
||||
type Category,
|
||||
type CategoryId,
|
||||
categoryHome,
|
||||
KNOWN_CATEGORIES,
|
||||
type ModuleId,
|
||||
} from "./category";
|
||||
|
||||
export type PeerRequirement = {
|
||||
[K in CategoryId]?: ModuleId[] | "any";
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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
Reference in New Issue
Block a user