diff --git a/.oxfmtrc.json b/.oxfmtrc.json index 2c51fca..69149df 100644 --- a/.oxfmtrc.json +++ b/.oxfmtrc.json @@ -8,5 +8,16 @@ "preserveWhitespace": false }, "sortPackageJson": true, - "ignorePatterns": [] + "ignorePatterns": [ + "**/node_modules/**", + "**/dist/**", + "**/.output/**", + "**/.turbo/**", + "**/.vercel/**", + "**/routeTree.gen.ts", + "**/coverage/**", + "apps/web/public/registry/**", + "registry/modules/*/logo*.svg", + "registry/modules/*/templates/**" + ] } diff --git a/.oxlintrc.json b/.oxlintrc.json index 327f774..556b1dd 100644 --- a/.oxlintrc.json +++ b/.oxlintrc.json @@ -33,7 +33,7 @@ }, { "files": ["apps/web/**/*.tsx"], - "plugins": ["typescript", "react", "react-hooks", "jsx-a11y", "unicorn", "import"], + "plugins": ["typescript", "react", "react-perf", "promise", "jsx-a11y", "unicorn", "import"], "rules": { "react/react-in-jsx-scope": "off" } diff --git a/CLAUDE.md b/CLAUDE.md index 1550822..9a54da1 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -29,6 +29,7 @@ Three things differentiate stanza from other scaffolders: - `bun apps/cli/src/bin.ts ` — run CLI directly without build - `pnpm registry:build` (or `bun packages/internal/src/registry-build.ts`) — regenerate `dist/registry/{index,modules/*}.json` - `pnpm module:new [slot] [id]` — scaffold a new module under `registry/modules/` +- `pnpm --filter @stanza/web dev` — TanStack Start dev server; `predev` hook auto-copies `dist/registry/` → `apps/web/public/registry/` so the same-domain registry path resolves - `pnpm lint` / `pnpm lint:fix` — Oxlint across the whole repo (config: `.oxlintrc.json`) - `pnpm fmt` / `pnpm fmt:check` — oxfmt across the whole repo (config: `.oxfmtrc.json`) - `cd packages/ && node_modules/.bin/vitest run` — unit tests (per workspace; no root `vitest` binary) @@ -49,6 +50,9 @@ Three things differentiate stanza from other scaffolders: - **Modules are vendored**: their templates land in the user's repo verbatim; no `@stanza/runtime` dep - **Template distribution**: `packages/internal`'s `registry-build.ts` inlines each template file's contents into the per-module JSON's `templates[].content` field so HTTP-loaded manifests are self-contained. The runner prefers `tpl.content` and only reads from `registry/modules//templates/` when it's absent (local dev). New templates need no build wiring — they're picked up automatically +- **Module logos**: drop `logo.svg` (theme-agnostic) or `logo-light.svg` + `logo-dark.svg` (theme pair) in a module's directory. The registry build auto-detects and inlines as `mod.logo` (string or `{ light, dark }`) — module authors don't declare anything in `module.ts`. First-party logos come from [svgl.app](https://svgl.app). The web builder renders inline via `dangerouslySetInnerHTML` +- **Codemod distribution**: each module's `codemods/index.ts` is bundled by `registry-build.ts` via `Bun.build` (`format: "esm"`, externals: `@stanza/codemods`, `@stanza/registry`, `ts-morph`, `node:*`) and embedded as `mod.codemodBundle` (string) in the per-module JSON. The CLI runner writes the bundle to `node_modules/.cache/stanza-codemods/` (located by walking up from `import.meta.url` looking for `@stanza/codemods` in a `node_modules`) and dynamic-imports it — that placement is what makes the externals resolve. Local dev still imports the `.ts` source off disk when no bundle is present +- **apps/web previews are server-rendered**: Shiki runs in `apps/web/src/server/highlighter.ts` (module-singleton, kept warm). The builder loader (`createServerFn` in `apps/web/src/server/builder-state.ts`) computes selected files from URL search params, pre-renders Shiki HTML for each, and ships `Record` to the client. `shiki` must NEVER be imported from a client component — verified by `vite build` followed by `grep shiki .output/public/assets/*.js` (should return nothing) - **Slot taxonomy** is currently `framework | styling | db | orm | auth` (see `KNOWN_SLOTS`); adding a slot is a manifest schema bump — update `KNOWN_SLOTS`, `slotOrder`, and the Zod manifest schema together - **Adapter keys** encode peer choices (e.g., `next+drizzle`); the resolver picks the most specific match - **Region ownership** in `stanza.json` is the source of truth for `remove`/future-`swap`; two modules claiming the same region is a hard error (`RegionConflictError`) diff --git a/apps/cli/src/codemod-runner.ts b/apps/cli/src/codemod-runner.ts index ff3f85b..a656312 100644 --- a/apps/cli/src/codemod-runner.ts +++ b/apps/cli/src/codemod-runner.ts @@ -1,5 +1,6 @@ import fs from "node:fs"; import path from "node:path"; +import { fileURLToPath } from "node:url"; import { openProject } from "@stanza/codemods"; import { @@ -100,7 +101,7 @@ export async function applyModule(args: { // 4. Imperative codemods — resolved against the module's local codemod registry. if (adapter.codemods?.length) { - const codemods = await loadCodemods(moduleDir); + const codemods = await loadCodemods(module, moduleDir); const project = lazyProject(appRoot); const ctx = buildContext({ projectRoot, @@ -157,13 +158,60 @@ function readTemplateSource(tpl: TemplateRef, moduleDir: string): string { type CodemodMap = Record; -async function loadCodemods(moduleDir: string): Promise { +/** + * Resolve a module's imperative codemods. HTTP-loaded modules carry a + * pre-bundled JS string in `module.codemodBundle` (set by the registry build + * step, with `@stanza/codemods` + `ts-morph` externalized so the CLI provides + * them); we materialize it inside the CLI's own `node_modules/.cache/` so the + * import can resolve those externals up the standard tree. Local dev imports + * the source straight off disk. + */ +async function loadCodemods(module: Module, moduleDir: string): Promise { + if (module.codemodBundle) { + const cacheDir = getBundleCacheDir(); + const file = path.join(cacheDir, `${module.slot}-${module.id}-${Date.now()}.mjs`); + fs.writeFileSync(file, module.codemodBundle, "utf8"); + try { + const mod = (await import(file)) as { default?: CodemodMap; codemods?: CodemodMap }; + return mod.default ?? mod.codemods ?? {}; + } finally { + // Best-effort cleanup — the import already cached the module so deleting + // the source file is safe. Leaving it on a failed import for debugging. + try { + fs.unlinkSync(file); + } catch {} + } + } const entry = path.join(moduleDir, "codemods", "index.ts"); if (!fs.existsSync(entry)) return {}; const mod = (await import(entry)) as { default?: CodemodMap; codemods?: CodemodMap }; return mod.default ?? mod.codemods ?? {}; } +/** + * Find a `node_modules/` tree upstream of the CLI source that has + * `@stanza/codemods` installed, and return its `.cache/stanza-codemods/` + * subdir. Temp bundle files written here can resolve their `@stanza/*` and + * `ts-morph` imports the normal way. + */ +function getBundleCacheDir(): string { + let dir = path.dirname(fileURLToPath(import.meta.url)); + for (let i = 0; i < 10; i++) { + const candidate = path.join(dir, "node_modules"); + if (fs.existsSync(path.join(candidate, "@stanza", "codemods"))) { + const out = path.join(candidate, ".cache", "stanza-codemods"); + fs.mkdirSync(out, { recursive: true }); + return out; + } + const parent = path.dirname(dir); + if (parent === dir) break; + dir = parent; + } + throw new Error( + "Could not locate @stanza/codemods relative to the CLI — bundled codemods need it in the resolution graph.", + ); +} + function lazyProject(appRoot: string): { (): Project; save?: () => Promise } { let project: Project | undefined; const fn = (() => { diff --git a/apps/cli/src/registry-loader.ts b/apps/cli/src/registry-loader.ts index c31fba8..d160d2c 100644 --- a/apps/cli/src/registry-loader.ts +++ b/apps/cli/src/registry-loader.ts @@ -99,11 +99,10 @@ async function loadHttpRegistry(baseUrl: string): Promise { const res = await fetch(url); if (!res.ok) throw new Error(`Module fetch failed: ${url} (${res.status})`); return (await res.json()) as Module; - // NOTE: HTTP-loaded modules can't ship codemod *functions* — they - // contain only the manifest + codemod ids. The CLI ships a bundled - // registry of codemod implementations keyed by id, populated at build - // time from the first-party modules. Custom codemod bundles for - // third-party modules are a future addition (signed JS payloads). + // HTTP-loaded modules carry their imperative codemods as a pre-bundled + // ESM string in `module.codemodBundle` (populated by registry-build, + // with `@stanza/codemods` + `ts-morph` externalized). The runner + // materializes it to a temp file and dynamic-imports. }, }; } diff --git a/apps/web/package.json b/apps/web/package.json index 8b3fe9b..f837c50 100644 --- a/apps/web/package.json +++ b/apps/web/package.json @@ -6,13 +6,17 @@ "type": "module", "scripts": { "dev": "vite dev", + "predev": "bun run scripts/copy-registry.ts", "build": "vite build", + "prebuild": "bun run scripts/copy-registry.ts", "start": "node .output/server/index.mjs", "typecheck": "tsc --noEmit" }, "dependencies": { "@base-ui/react": "^1.5.0", "@fontsource-variable/geist": "^5.2.9", + "@fontsource-variable/geist-mono": "^5.2.8", + "@pierre/trees": "1.0.0-beta.4", "@stanza/registry": "workspace:*", "@tabler/icons-react": "^3.44.0", "@tailwindcss/vite": "^4.3.0", @@ -28,6 +32,8 @@ "react": "^19.2.6", "react-dom": "^19.2.6", "shadcn": "^4.7.0", + "shiki": "^4.1.0", + "sonner": "^2.0.7", "tailwind-merge": "^3.6.0", "tailwindcss": "^4.3.0", "tw-animate-css": "^1.4.0" diff --git a/apps/web/scripts/copy-registry.ts b/apps/web/scripts/copy-registry.ts new file mode 100644 index 0000000..79730e1 --- /dev/null +++ b/apps/web/scripts/copy-registry.ts @@ -0,0 +1,27 @@ +#!/usr/bin/env bun +/** + * Copies the registry CDN output (`dist/registry/`) into `apps/web/public/registry/` + * so the web builder can serve it from the same domain. Runs as a predev/prebuild + * hook; safe to re-run. + */ +import fs from "node:fs"; +import path from "node:path"; +import { fileURLToPath } from "node:url"; + +const here = path.dirname(fileURLToPath(import.meta.url)); +const appDir = path.resolve(here, ".."); +const repoRoot = path.resolve(appDir, "..", ".."); + +const src = path.join(repoRoot, "dist", "registry"); +const dest = path.join(appDir, "public", "registry"); + +if (!fs.existsSync(src)) { + console.warn( + `[copy-registry] ${path.relative(repoRoot, src)} doesn't exist yet — run \`pnpm registry:build\` first. Skipping.`, + ); + process.exit(0); +} + +fs.rmSync(dest, { recursive: true, force: true }); +fs.cpSync(src, dest, { recursive: true }); +console.log(`[copy-registry] ${path.relative(repoRoot, src)} → ${path.relative(repoRoot, dest)}`); diff --git a/apps/web/src/components/builder.tsx b/apps/web/src/components/builder.tsx deleted file mode 100644 index e707752..0000000 --- a/apps/web/src/components/builder.tsx +++ /dev/null @@ -1,110 +0,0 @@ -import type { ModuleSummary, RegistryIndex, SlotId } from "@stanza/registry"; -import { resolveAdapter, slotOrder, emptyManifest } from "@stanza/registry"; -import { useMemo, useState } from "react"; - -type Selections = Partial>; - -export function Builder({ index }: { index: RegistryIndex }) { - const [selections, setSelections] = useState({}); - const [name, setName] = useState("my-app"); - - const moduleById = useMemo(() => { - const m = new Map(); - for (const mod of index.modules) m.set(`${mod.slot}:${mod.id}`, mod); - return m; - }, [index]); - - const compatibleBySlot = useMemo(() => { - const out: Record = { - framework: [], - styling: [], - db: [], - orm: [], - auth: [], - }; - const pending = pendingFromSelections(selections, moduleById); - for (const slot of slotOrder) { - out[slot] = index.modules - .filter((m) => m.slot === slot) - .filter((m) => { - const synthetic = { ...m, adapters: m.adapters.map((a) => ({ ...a })) }; - return resolveAdapter(synthetic, { - manifest: emptyManifest({ name: "t" }), - pending, - }).ok; - }); - } - return out; - }, [index, selections, moduleById]); - - const command = useMemo(() => buildCommand(name, selections), [name, selections]); - - return ( -
- - - {slotOrder.map((slot) => ( -
- {slotLabel(slot)} - {compatibleBySlot[slot].length === 0 ? ( -
No compatible modules with current picks.
- ) : ( - compatibleBySlot[slot].map((m) => ( - - )) - )} -
- ))} - -
-        {command}
-      
-
- ); -} - -function pendingFromSelections(selections: Selections, moduleById: Map) { - const pending: Record = {}; - for (const [slot, id] of Object.entries(selections)) { - if (!id) continue; - const mod = moduleById.get(`${slot}:${id}`); - if (mod) pending[slot] = mod; - } - // The resolver expects full Modules; the empty `adapters` slot bodies don't - // matter for peer-validation, so cast is safe. - return pending as Parameters[1]["pending"]; -} - -function buildCommand(name: string, selections: Selections): string { - const flags = slotOrder - .map((s) => (selections[s] ? `--${s}=${selections[s]}` : null)) - .filter(Boolean) - .join(" "); - return `pnpm create stanza ${name} ${flags}`.trim(); -} - -function slotLabel(slot: SlotId): string { - return { framework: "Framework", styling: "Styling", db: "Database", orm: "ORM", auth: "Auth" }[ - slot - ]; -} diff --git a/apps/web/src/components/builder/command-bar.tsx b/apps/web/src/components/builder/command-bar.tsx new file mode 100644 index 0000000..d6ba56d --- /dev/null +++ b/apps/web/src/components/builder/command-bar.tsx @@ -0,0 +1,63 @@ +import { IconCopy } from "@tabler/icons-react"; +import { toast } from "sonner"; + +import { Button } from "@/components/ui/button"; +import { Card } from "@/components/ui/card"; +import { Input } from "@/components/ui/input"; +import { Tooltip, TooltipContent, TooltipTrigger } from "@/components/ui/tooltip"; + +export function CommandBar({ + name, + defaultName, + command, + onNameChange, +}: { + name: string; + defaultName: string; + command: string; + onNameChange: (name: string) => void; +}) { + const onCopy = async () => { + try { + await navigator.clipboard.writeText(command); + toast.success("Copied to clipboard"); + } catch { + toast.error("Couldn't access the clipboard"); + } + }; + + return ( + +
+ + onNameChange(e.target.value)} + /> +
+ +
+ Run this +
+
+            {command}
+          
+ + + + + } + /> + Copy + +
+
+
+ ); +} diff --git a/apps/web/src/components/builder/file-preview.tsx b/apps/web/src/components/builder/file-preview.tsx new file mode 100644 index 0000000..d12a32d --- /dev/null +++ b/apps/web/src/components/builder/file-preview.tsx @@ -0,0 +1,109 @@ +import { FileTree, useFileTree, useFileTreeSelection } from "@pierre/trees/react"; +import type { Module, ModuleAdapter, SlotId } from "@stanza/registry"; +import { useMemo } from "react"; + +import { useTheme } from "@/components/theme-provider"; +import { Card } from "@/components/ui/card"; +import type { Preview } from "@/server/highlighter"; + +export function FilePreview({ + filePaths, + previews, + resolved, +}: { + filePaths: string[]; + previews: Record; + resolved: Partial>; +}) { + // @pierre/trees is path-driven. We pass our derived file list straight in. + // When the parent's loader reruns with new selections, this component + // re-mounts and the tree model rebuilds with the fresh paths. + const { model } = useFileTree({ + paths: filePaths, + search: false, + }); + + // The model owns selection internally; `useFileTreeSelection` exposes the + // currently-selected paths as a stable readonly array we subscribe to. + const selectedPaths = useFileTreeSelection(model); + const activePath = selectedPaths[0] ?? filePaths[0]; + const preview = activePath ? previews[activePath] : undefined; + + const slotCount = Object.keys(resolved).length; + + return ( + +
+ + {filePaths.length > 0 + ? `${filePaths.length} file${filePaths.length === 1 ? "" : "s"} from ${slotCount} module${slotCount === 1 ? "" : "s"}` + : "Pick a module to preview generated files"} + +
+ + {filePaths.length === 0 ? ( + + ) : ( +
+
+ +
+ +
+ )} +
+ ); +} + +function EmptyState() { + return ( +
+ The file tree will populate as you pick modules. +
+ ); +} + +function PreviewPane({ + preview, + path, +}: { + preview: Preview | undefined; + path: string | undefined; +}) { + const { theme } = useTheme(); + const resolved = useResolvedTheme(theme); + const html = useMemo(() => { + if (!preview) return ""; + return resolved === "dark" ? preview.dark : preview.light; + }, [preview, resolved]); + + if (!preview || !path) { + return ( +
+ Select a file +
+ ); + } + + return ( +
+
+ {path} +
+
+
+ ); +} + +function useResolvedTheme(theme: string): "light" | "dark" { + // Match the same `light` / `dark` / `system` semantics the ThemeProvider + // applies to , without re-reading the DOM (this runs server-side too). + if (theme === "dark") return "dark"; + if (theme === "light") return "light"; + if (typeof window === "undefined") return "light"; + return window.matchMedia("(prefers-color-scheme: dark)").matches ? "dark" : "light"; +} diff --git a/apps/web/src/components/builder/index.tsx b/apps/web/src/components/builder/index.tsx new file mode 100644 index 0000000..bc843b8 --- /dev/null +++ b/apps/web/src/components/builder/index.tsx @@ -0,0 +1,62 @@ +import type { SlotId } from "@stanza/registry"; +import { useNavigate } from "@tanstack/react-router"; + +import { CommandBar } from "@/components/builder/command-bar"; +import { FilePreview } from "@/components/builder/file-preview"; +import { SlotCards } from "@/components/builder/slot-cards"; +import { + type BuilderSearch, + type Selections, + buildCommand, + DEFAULT_NAME, + parseSelections, + resolveSelectedAdapters, + toSearchParams, +} from "@/lib/selection"; +import type { BuilderState } from "@/server/builder-state"; + +export function Builder({ state, search }: { state: BuilderState; search: BuilderSearch }) { + const navigate = useNavigate({ from: "/" }); + const { name, selections } = parseSelections(search); + const resolved = resolveSelectedAdapters(state.modules, selections); + const command = buildCommand({ name, selections }); + + const setName = (next: string) => { + void navigate({ + search: toSearchParams({ name: next, selections }), + replace: true, + }); + }; + + const setSelection = (slot: SlotId, id: string | undefined) => { + const next: Selections = { ...selections }; + if (id) next[slot] = id; + else delete next[slot]; + void navigate({ + search: toSearchParams({ name, selections: next }), + replace: true, + }); + }; + + return ( +
+
+ +
+
+ + +
+
+ ); +} diff --git a/apps/web/src/components/builder/slot-cards.tsx b/apps/web/src/components/builder/slot-cards.tsx new file mode 100644 index 0000000..e4b1146 --- /dev/null +++ b/apps/web/src/components/builder/slot-cards.tsx @@ -0,0 +1,212 @@ +import type { Logo, Module, ModuleSummary, SlotId } from "@stanza/registry"; +import { KNOWN_SLOTS, emptyManifest, resolveAdapter } from "@stanza/registry"; +import { IconCheck } from "@tabler/icons-react"; + +import { Tooltip, TooltipContent, TooltipTrigger } from "@/components/ui/tooltip"; +import type { Selections } from "@/lib/selection"; +import { cn } from "@/lib/utils"; + +const SLOT_LABELS: Record = { + framework: "Framework", + styling: "Styling", + db: "Database", + orm: "ORM", + auth: "Auth", +}; + +export function SlotCards({ + modules, + summaries, + selections, + onSelect, +}: { + modules: Record; + summaries: ModuleSummary[]; + selections: Selections; + onSelect: (slot: SlotId, id: string | undefined) => void; +}) { + return ( +
+ {KNOWN_SLOTS.map((slot, index) => { + const slotModules = summaries.filter((m) => m.slot === slot); + if (slotModules.length === 0) return null; + return ( + + ); + })} +
+ ); +} + +function SlotSection({ + slot, + label, + modules, + modulesById, + selections, + index, + onSelect, +}: { + slot: SlotId; + label: string; + modules: ModuleSummary[]; + modulesById: Record; + selections: Selections; + index: number; + onSelect: (slot: SlotId, id: string | undefined) => void; +}) { + const pending: Partial> = {}; + for (const s of KNOWN_SLOTS) { + const id = selections[s]; + if (id && modulesById[`${s}:${id}`]) pending[s] = modulesById[`${s}:${id}`]; + } + const manifest = emptyManifest({ name: "t" }); + return ( +
+
+ + {String(index).padStart(2, "0")} + +

{label}

+
+
+ {modules.map((m) => { + const full = modulesById[`${m.slot}:${m.id}`]; + const result = full + ? resolveAdapter(full, { manifest, pending }) + : { ok: false, error: { kind: "no-adapter" as const } }; + const disabled = !result.ok; + const selected = selections[slot] === m.id; + const reason = !result.ok ? describeError(result.error.kind) : undefined; + return ( + { + if (disabled) return; + onSelect(slot, selected ? undefined : m.id); + }} + /> + ); + })} +
+
+ ); +} + +function ModuleCard({ + module: m, + selected, + disabled, + reason, + onClick, +}: { + module: ModuleSummary; + selected: boolean; + disabled: boolean; + reason?: string; + onClick: () => void; +}) { + const card = ( + + ); + + if (!disabled || !reason) return card; + return ( + + + {reason} + + ); +} + +function ModuleLogo({ logo, label }: { logo: Logo | undefined; label: string }) { + if (!logo) { + // Fallback: a subtle initial-letter tile. Keeps card geometry consistent. + return ( +
+ {label.slice(0, 1)} +
+ ); + } + const wrapClass = + "flex size-9 shrink-0 items-center justify-center [&_svg]:size-7 [&_svg]:max-h-7 [&_svg]:max-w-7"; + if (typeof logo === "string") { + return ( +
+ ); + } + return ( + <> +
+
+ + ); +} + +function describeError(kind: string): string { + switch (kind) { + case "missing-peer": + return "Needs another slot filled in first."; + case "incompatible-peer": + return "Doesn't pair with one of your current picks."; + case "no-adapter": + return "No adapter matches your current stack."; + default: + return "Not compatible with the current stack."; + } +} diff --git a/apps/web/src/components/footer.tsx b/apps/web/src/components/footer.tsx new file mode 100644 index 0000000..07d2ecf --- /dev/null +++ b/apps/web/src/components/footer.tsx @@ -0,0 +1,19 @@ +export function Footer() { + return ( +
+
+

stanza — modular monorepo template CLI

+ +
+
+ ); +} diff --git a/apps/web/src/components/header.tsx b/apps/web/src/components/header.tsx new file mode 100644 index 0000000..d7c514f --- /dev/null +++ b/apps/web/src/components/header.tsx @@ -0,0 +1,35 @@ +import { IconBrandGithub } from "@tabler/icons-react"; +import { Link } from "@tanstack/react-router"; + +import { ModeToggle } from "@/components/mode-toggle"; +import { Button } from "@/components/ui/button"; + +export function Header() { + return ( +
+
+ + stanza + + +
+
+ ); +} diff --git a/apps/web/src/components/mode-toggle.tsx b/apps/web/src/components/mode-toggle.tsx new file mode 100644 index 0000000..47e13b6 --- /dev/null +++ b/apps/web/src/components/mode-toggle.tsx @@ -0,0 +1,33 @@ +import { IconMoon, IconSun } from "@tabler/icons-react"; + +import { useTheme } from "@/components/theme-provider"; +import { Button } from "@/components/ui/button"; +import { + DropdownMenu, + DropdownMenuContent, + DropdownMenuItem, + DropdownMenuTrigger, +} from "@/components/ui/dropdown-menu"; + +export function ModeToggle() { + const { setTheme } = useTheme(); + + return ( + + + + + Toggle theme + + } + /> + + setTheme("light")}>Light + setTheme("dark")}>Dark + setTheme("system")}>System + + + ); +} diff --git a/apps/web/src/components/theme-provider.tsx b/apps/web/src/components/theme-provider.tsx new file mode 100644 index 0000000..b129ff9 --- /dev/null +++ b/apps/web/src/components/theme-provider.tsx @@ -0,0 +1,94 @@ +import { ScriptOnce } from "@tanstack/react-router"; +import { createContext, use, useCallback, useEffect, useMemo, useState } from "react"; + +type Theme = "dark" | "light" | "system"; + +type ThemeProviderProps = { + children: React.ReactNode; + defaultTheme?: Theme; + storageKey?: string; +}; + +type ThemeProviderState = { + theme: Theme; + setTheme: (theme: Theme) => void; +}; + +const ThemeProviderContext = createContext({ + theme: "system", + setTheme: () => {}, +}); + +function getThemeScript(storageKey: string, defaultTheme: Theme) { + const key = JSON.stringify(storageKey); + const fallback = JSON.stringify(defaultTheme); + // This script runs before React hydrates so the first paint already has the + // right `.light` / `.dark` class on . Keeps the FOUC away on reload. + return `(function(){try{var t=localStorage.getItem(${key});if(t!=='light'&&t!=='dark'&&t!=='system'){t=${fallback}}var d=matchMedia('(prefers-color-scheme: dark)').matches;var r=t==='system'?(d?'dark':'light'):t;var e=document.documentElement;e.classList.add(r);e.style.colorScheme=r}catch(e){}})();`; +} + +function applyTheme(theme: Theme) { + const root = document.documentElement; + root.classList.remove("light", "dark"); + const resolved = + theme === "system" + ? window.matchMedia("(prefers-color-scheme: dark)").matches + ? "dark" + : "light" + : theme; + root.classList.add(resolved); + root.style.colorScheme = resolved; +} + +export function ThemeProvider({ + children, + defaultTheme = "system", + storageKey = "theme", +}: ThemeProviderProps) { + const [theme, setThemeState] = useState(defaultTheme); + const [mounted, setMounted] = useState(false); + + useEffect(() => { + const stored = localStorage.getItem(storageKey); + setThemeState( + stored === "light" || stored === "dark" || stored === "system" ? stored : defaultTheme, + ); + setMounted(true); + }, [defaultTheme, storageKey]); + + useEffect(() => { + if (!mounted) return; + applyTheme(theme); + }, [theme, mounted]); + + useEffect(() => { + if (!mounted || theme !== "system") return; + const media = window.matchMedia("(prefers-color-scheme: dark)"); + const onChange = () => applyTheme("system"); + media.addEventListener("change", onChange); + return () => media.removeEventListener("change", onChange); + }, [theme, mounted]); + + const setTheme = useCallback( + (next: Theme) => { + localStorage.setItem(storageKey, next); + setThemeState(next); + }, + [storageKey], + ); + + const value = useMemo(() => ({ theme, setTheme }), [theme, setTheme]); + + return ( + + {getThemeScript(storageKey, defaultTheme)} + {children} + + ); +} + +export function useTheme() { + const context = use(ThemeProviderContext); + if (context === undefined) throw new Error("useTheme must be used within a ThemeProvider"); + return context; +} diff --git a/apps/web/src/components/ui/badge.tsx b/apps/web/src/components/ui/badge.tsx new file mode 100644 index 0000000..0adb8a1 --- /dev/null +++ b/apps/web/src/components/ui/badge.tsx @@ -0,0 +1,49 @@ +import { mergeProps } from "@base-ui/react/merge-props"; +import { useRender } from "@base-ui/react/use-render"; +import { cva, type VariantProps } from "class-variance-authority"; + +import { cn } from "@/lib/utils"; + +const badgeVariants = cva( + "group/badge inline-flex h-5 w-fit shrink-0 items-center justify-center gap-1 overflow-hidden rounded-none border border-transparent px-2 py-0.5 text-xs font-medium whitespace-nowrap transition-all focus-visible:border-ring focus-visible:ring-[3px] focus-visible:ring-ring/50 has-data-[icon=inline-end]:pr-1.5 has-data-[icon=inline-start]:pl-1.5 aria-invalid:border-destructive aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 [&>svg]:pointer-events-none [&>svg]:size-3!", + { + variants: { + variant: { + default: "bg-primary text-primary-foreground [a]:hover:bg-primary/80", + secondary: "bg-secondary text-secondary-foreground [a]:hover:bg-secondary/80", + destructive: + "bg-destructive/10 text-destructive focus-visible:ring-destructive/20 dark:bg-destructive/20 dark:focus-visible:ring-destructive/40 [a]:hover:bg-destructive/20", + outline: "border-border text-foreground [a]:hover:bg-muted [a]:hover:text-muted-foreground", + ghost: "hover:bg-muted hover:text-muted-foreground dark:hover:bg-muted/50", + link: "text-primary underline-offset-4 hover:underline", + }, + }, + defaultVariants: { + variant: "default", + }, + }, +); + +function Badge({ + className, + variant = "default", + render, + ...props +}: useRender.ComponentProps<"span"> & VariantProps) { + return useRender({ + defaultTagName: "span", + props: mergeProps<"span">( + { + className: cn(badgeVariants({ variant }), className), + }, + props, + ), + render, + state: { + slot: "badge", + variant, + }, + }); +} + +export { Badge, badgeVariants }; diff --git a/apps/web/src/components/ui/card.tsx b/apps/web/src/components/ui/card.tsx new file mode 100644 index 0000000..f840e36 --- /dev/null +++ b/apps/web/src/components/ui/card.tsx @@ -0,0 +1,92 @@ +import * as React from "react"; + +import { cn } from "@/lib/utils"; + +function Card({ + className, + size = "default", + ...props +}: React.ComponentProps<"div"> & { size?: "default" | "sm" }) { + return ( +
img:first-child]:pt-0 data-[size=sm]:gap-2 data-[size=sm]:py-3 data-[size=sm]:has-data-[slot=card-footer]:pb-0 *:[img:first-child]:rounded-none *:[img:last-child]:rounded-none", + className, + )} + {...props} + /> + ); +} + +function CardHeader({ className, ...props }: React.ComponentProps<"div">) { + return ( +
+ ); +} + +function CardTitle({ className, ...props }: React.ComponentProps<"div">) { + return ( +
+ ); +} + +function CardDescription({ className, ...props }: React.ComponentProps<"div">) { + return ( +
+ ); +} + +function CardAction({ className, ...props }: React.ComponentProps<"div">) { + return ( +
+ ); +} + +function CardContent({ className, ...props }: React.ComponentProps<"div">) { + return ( +
+ ); +} + +function CardFooter({ className, ...props }: React.ComponentProps<"div">) { + return ( +
+ ); +} + +export { Card, CardHeader, CardFooter, CardTitle, CardAction, CardDescription, CardContent }; diff --git a/apps/web/src/components/ui/dropdown-menu.tsx b/apps/web/src/components/ui/dropdown-menu.tsx new file mode 100644 index 0000000..df06408 --- /dev/null +++ b/apps/web/src/components/ui/dropdown-menu.tsx @@ -0,0 +1,253 @@ +import { Menu as MenuPrimitive } from "@base-ui/react/menu"; +import { IconChevronRight, IconCheck } from "@tabler/icons-react"; +import * as React from "react"; + +import { cn } from "@/lib/utils"; + +function DropdownMenu({ ...props }: MenuPrimitive.Root.Props) { + return ; +} + +function DropdownMenuPortal({ ...props }: MenuPrimitive.Portal.Props) { + return ; +} + +function DropdownMenuTrigger({ ...props }: MenuPrimitive.Trigger.Props) { + return ; +} + +function DropdownMenuContent({ + align = "start", + alignOffset = 0, + side = "bottom", + sideOffset = 4, + className, + ...props +}: MenuPrimitive.Popup.Props & + Pick) { + return ( + + + + + + ); +} + +function DropdownMenuGroup({ ...props }: MenuPrimitive.Group.Props) { + return ; +} + +function DropdownMenuLabel({ + className, + inset, + ...props +}: MenuPrimitive.GroupLabel.Props & { + inset?: boolean; +}) { + return ( + + ); +} + +function DropdownMenuItem({ + className, + inset, + variant = "default", + ...props +}: MenuPrimitive.Item.Props & { + inset?: boolean; + variant?: "default" | "destructive"; +}) { + return ( + + ); +} + +function DropdownMenuSub({ ...props }: MenuPrimitive.SubmenuRoot.Props) { + return ; +} + +function DropdownMenuSubTrigger({ + className, + inset, + children, + ...props +}: MenuPrimitive.SubmenuTrigger.Props & { + inset?: boolean; +}) { + return ( + + {children} + + + ); +} + +function DropdownMenuSubContent({ + align = "start", + alignOffset = -3, + side = "right", + sideOffset = 0, + className, + ...props +}: React.ComponentProps) { + return ( + + ); +} + +function DropdownMenuCheckboxItem({ + className, + children, + checked, + inset, + ...props +}: MenuPrimitive.CheckboxItem.Props & { + inset?: boolean; +}) { + return ( + + + + + + + {children} + + ); +} + +function DropdownMenuRadioGroup({ ...props }: MenuPrimitive.RadioGroup.Props) { + return ; +} + +function DropdownMenuRadioItem({ + className, + children, + inset, + ...props +}: MenuPrimitive.RadioItem.Props & { + inset?: boolean; +}) { + return ( + + + + + + + {children} + + ); +} + +function DropdownMenuSeparator({ className, ...props }: MenuPrimitive.Separator.Props) { + return ( + + ); +} + +function DropdownMenuShortcut({ className, ...props }: React.ComponentProps<"span">) { + return ( + + ); +} + +export { + DropdownMenu, + DropdownMenuPortal, + DropdownMenuTrigger, + DropdownMenuContent, + DropdownMenuGroup, + DropdownMenuLabel, + DropdownMenuItem, + DropdownMenuCheckboxItem, + DropdownMenuRadioGroup, + DropdownMenuRadioItem, + DropdownMenuSeparator, + DropdownMenuShortcut, + DropdownMenuSub, + DropdownMenuSubTrigger, + DropdownMenuSubContent, +}; diff --git a/apps/web/src/components/ui/input.tsx b/apps/web/src/components/ui/input.tsx new file mode 100644 index 0000000..0c68eef --- /dev/null +++ b/apps/web/src/components/ui/input.tsx @@ -0,0 +1,20 @@ +import { Input as InputPrimitive } from "@base-ui/react/input"; +import * as React from "react"; + +import { cn } from "@/lib/utils"; + +function Input({ className, type, ...props }: React.ComponentProps<"input">) { + return ( + + ); +} + +export { Input }; diff --git a/apps/web/src/components/ui/separator.tsx b/apps/web/src/components/ui/separator.tsx new file mode 100644 index 0000000..e99046a --- /dev/null +++ b/apps/web/src/components/ui/separator.tsx @@ -0,0 +1,19 @@ +import { Separator as SeparatorPrimitive } from "@base-ui/react/separator"; + +import { cn } from "@/lib/utils"; + +function Separator({ className, orientation = "horizontal", ...props }: SeparatorPrimitive.Props) { + return ( + + ); +} + +export { Separator }; diff --git a/apps/web/src/components/ui/sonner.tsx b/apps/web/src/components/ui/sonner.tsx new file mode 100644 index 0000000..45612ef --- /dev/null +++ b/apps/web/src/components/ui/sonner.tsx @@ -0,0 +1,44 @@ +import { + IconCircleCheck, + IconInfoCircle, + IconAlertTriangle, + IconAlertOctagon, + IconLoader, +} from "@tabler/icons-react"; +import { Toaster as Sonner, type ToasterProps } from "sonner"; + +import { useTheme } from "@/components/theme-provider"; + +const Toaster = ({ ...props }: ToasterProps) => { + const { theme = "system" } = useTheme(); + + return ( + , + info: , + warning: , + error: , + loading: , + }} + style={ + { + "--normal-bg": "var(--popover)", + "--normal-text": "var(--popover-foreground)", + "--normal-border": "var(--border)", + "--border-radius": "var(--radius)", + } as React.CSSProperties + } + toastOptions={{ + classNames: { + toast: "cn-toast", + }, + }} + {...props} + /> + ); +}; + +export { Toaster }; diff --git a/apps/web/src/components/ui/tabs.tsx b/apps/web/src/components/ui/tabs.tsx new file mode 100644 index 0000000..f83b9e0 --- /dev/null +++ b/apps/web/src/components/ui/tabs.tsx @@ -0,0 +1,73 @@ +import { Tabs as TabsPrimitive } from "@base-ui/react/tabs"; +import { cva, type VariantProps } from "class-variance-authority"; + +import { cn } from "@/lib/utils"; + +function Tabs({ className, orientation = "horizontal", ...props }: TabsPrimitive.Root.Props) { + return ( + + ); +} + +const tabsListVariants = cva( + "group/tabs-list inline-flex w-fit items-center justify-center rounded-none p-[3px] text-muted-foreground group-data-horizontal/tabs:h-8 group-data-vertical/tabs:h-fit group-data-vertical/tabs:flex-col data-[variant=line]:rounded-none", + { + variants: { + variant: { + default: "bg-muted", + line: "gap-1 bg-transparent", + }, + }, + defaultVariants: { + variant: "default", + }, + }, +); + +function TabsList({ + className, + variant = "default", + ...props +}: TabsPrimitive.List.Props & VariantProps) { + return ( + + ); +} + +function TabsTrigger({ className, ...props }: TabsPrimitive.Tab.Props) { + return ( + + ); +} + +function TabsContent({ className, ...props }: TabsPrimitive.Panel.Props) { + return ( + + ); +} + +export { Tabs, TabsList, TabsTrigger, TabsContent, tabsListVariants }; diff --git a/apps/web/src/components/ui/tooltip.tsx b/apps/web/src/components/ui/tooltip.tsx new file mode 100644 index 0000000..e46c0ab --- /dev/null +++ b/apps/web/src/components/ui/tooltip.tsx @@ -0,0 +1,54 @@ +"use client"; + +import { Tooltip as TooltipPrimitive } from "@base-ui/react/tooltip"; + +import { cn } from "@/lib/utils"; + +function TooltipProvider({ delay = 0, ...props }: TooltipPrimitive.Provider.Props) { + return ; +} + +function Tooltip({ ...props }: TooltipPrimitive.Root.Props) { + return ; +} + +function TooltipTrigger({ ...props }: TooltipPrimitive.Trigger.Props) { + return ; +} + +function TooltipContent({ + className, + side = "top", + sideOffset = 4, + align = "center", + alignOffset = 0, + children, + ...props +}: TooltipPrimitive.Popup.Props & + Pick) { + return ( + + + + {children} + + + + + ); +} + +export { Tooltip, TooltipTrigger, TooltipContent, TooltipProvider }; diff --git a/apps/web/src/lib/selection.test.ts b/apps/web/src/lib/selection.test.ts new file mode 100644 index 0000000..705fa8a --- /dev/null +++ b/apps/web/src/lib/selection.test.ts @@ -0,0 +1,161 @@ +import { defineModule, type Module } from "@stanza/registry"; +import { describe, expect, it } from "vitest"; + +import { + buildCommand, + DEFAULT_NAME, + parseSelections, + resolveSelectedAdapters, + selectedFiles, + toSearchParams, +} from "./selection"; + +describe("parseSelections", () => { + it("returns defaults when search is empty", () => { + const { name, selections } = parseSelections({}); + expect(name).toBe(DEFAULT_NAME); + expect(selections).toEqual({}); + }); + + it("preserves recognized slot keys and ignores unknown keys", () => { + const { name, selections } = parseSelections({ + name: "my-thing", + framework: "next", + orm: "drizzle", + // @ts-expect-error unknown slot — should be silently dropped + flooble: "ignored", + }); + expect(name).toBe("my-thing"); + expect(selections).toEqual({ framework: "next", orm: "drizzle" }); + }); + + it("drops empty string slot values", () => { + const { selections } = parseSelections({ framework: "" }); + expect(selections.framework).toBeUndefined(); + }); +}); + +describe("toSearchParams", () => { + it("round-trips a populated state", () => { + const input = { + name: "my-app", + selections: { framework: "next" as const, orm: "drizzle" as const }, + }; + const search = toSearchParams(input); + expect(parseSelections(search)).toEqual(input); + }); + + it("omits the default name to keep the URL terse", () => { + expect(toSearchParams({ name: DEFAULT_NAME, selections: {} })).toEqual({}); + }); +}); + +describe("buildCommand", () => { + it("emits only the selected flags", () => { + expect( + buildCommand({ + name: "my-app", + selections: { framework: "next", db: "sqlite" }, + }), + ).toBe("pnpm create stanza my-app --framework=next --db=sqlite"); + }); + + it("keeps the bare command when nothing is selected", () => { + expect(buildCommand({ name: "my-app", selections: {} })).toBe("pnpm create stanza my-app"); + }); +}); + +describe("selectedFiles", () => { + const drizzle: Module = defineModule({ + id: "drizzle", + slot: "orm", + label: "Drizzle", + description: "", + version: "0.1.0", + adapters: [ + { + key: "default", + match: {}, + templates: [ + { src: "schema.ts", dest: "src/db/schema.ts", scope: "app" }, + { src: "drizzle.config.ts", dest: "drizzle.config.ts", scope: "app" }, + ], + }, + ], + }); + + it("prefixes scope:app with the active app dir and leaves scope:repo bare", () => { + const adapter = drizzle.adapters[0]!; + // Add a repo-scoped template to the same fixture + const adapterWithRepo = { + ...adapter, + templates: [ + ...(adapter.templates ?? []), + { src: "turbo.json", dest: "turbo.json", scope: "repo" as const }, + ], + }; + const files = selectedFiles({ + orm: { module: drizzle, adapter: adapterWithRepo }, + }); + expect(files.map((f) => f.path)).toEqual([ + "apps/web/src/db/schema.ts", + "apps/web/drizzle.config.ts", + "turbo.json", + ]); + }); + + it("groups files by slot order", () => { + const framework: Module = defineModule({ + id: "next", + slot: "framework", + label: "Next.js", + description: "", + version: "0.1.0", + adapters: [ + { + key: "default", + match: {}, + templates: [{ src: "layout.tsx", dest: "app/layout.tsx", scope: "app" }], + }, + ], + }); + const files = selectedFiles({ + orm: { module: drizzle, adapter: drizzle.adapters[0]! }, + framework: { module: framework, adapter: framework.adapters[0]! }, + }); + // framework comes before orm in slotOrder, so its files should appear first + expect(files[0]!.path).toBe("apps/web/app/layout.tsx"); + }); +}); + +describe("resolveSelectedAdapters", () => { + it("picks the adapter whose match aligns with active peers", () => { + const drizzleMod: Module = defineModule({ + id: "drizzle", + slot: "orm", + label: "Drizzle", + description: "", + version: "0.1.0", + peers: { db: ["postgres", "sqlite"] }, + adapters: [ + { key: "postgres", match: { db: "postgres" } }, + { key: "sqlite", match: { db: "sqlite" } }, + ], + }); + const postgresMod: Module = defineModule({ + id: "postgres", + slot: "db", + label: "Postgres", + description: "", + version: "0.1.0", + adapters: [{ key: "default", match: {} }], + }); + const modules = { + "orm:drizzle": drizzleMod, + "db:postgres": postgresMod, + }; + const out = resolveSelectedAdapters(modules, { orm: "drizzle", db: "postgres" }); + expect(out.orm?.adapter.key).toBe("postgres"); + expect(out.db?.adapter.key).toBe("default"); + }); +}); diff --git a/apps/web/src/lib/selection.ts b/apps/web/src/lib/selection.ts new file mode 100644 index 0000000..af7e8da --- /dev/null +++ b/apps/web/src/lib/selection.ts @@ -0,0 +1,115 @@ +import type { Module, ModuleAdapter, SlotId, TemplateRef } from "@stanza/registry"; +import { KNOWN_SLOTS, emptyManifest, resolveAdapter, slotOrder } from "@stanza/registry"; + +export type Selections = Partial>; + +export type BuilderSearch = { + name?: string; + framework?: string; + styling?: string; + db?: string; + orm?: string; + auth?: string; +}; + +export const DEFAULT_NAME = "my-app"; + +/** + * Parse the URL search params into a name + selections record. Any unrecognized + * slot keys are dropped. Missing values default to `undefined`, which the rest + * of the builder treats as "not picked yet". + */ +export function parseSelections(search: BuilderSearch): { name: string; selections: Selections } { + const selections: Selections = {}; + for (const slot of KNOWN_SLOTS) { + const value = search[slot]; + if (typeof value === "string" && value.length > 0) { + selections[slot] = value; + } + } + const name = + typeof search.name === "string" && search.name.length > 0 ? search.name : DEFAULT_NAME; + return { name, selections }; +} + +/** + * Inverse of `parseSelections`. We omit empty fields so the URL stays terse — + * a brand-new visit shows no query string, not `?name=my-app&framework=`. + */ +export function toSearchParams(input: { name: string; selections: Selections }): BuilderSearch { + const out: BuilderSearch = {}; + if (input.name && input.name !== DEFAULT_NAME) out.name = input.name; + for (const slot of KNOWN_SLOTS) { + const v = input.selections[slot]; + if (v) out[slot] = v; + } + return out; +} + +/** + * Resolve the adapter for each selected slot against the current selections — + * same logic the CLI uses on `stanza add`. Returns `null` for a slot whose + * peers aren't satisfied yet (rare while typing). + */ +export function resolveSelectedAdapters( + modules: Record, + selections: Selections, +): Partial> { + const pending: Partial> = {}; + for (const slot of KNOWN_SLOTS) { + const id = selections[slot]; + if (!id) continue; + const mod = modules[`${slot}:${id}`]; + if (mod) pending[slot] = mod; + } + const out: Partial> = {}; + for (const slot of slotOrder) { + const mod = pending[slot]; + if (!mod) continue; + const r = resolveAdapter(mod, { manifest: emptyManifest({ name: "t" }), pending }); + if (r.ok) out[slot] = { module: mod, adapter: r.adapter }; + } + return out; +} + +export type SelectedFile = { + path: string; + template: TemplateRef; + owner: { slot: SlotId; module: string }; +}; + +/** + * Derive the full file list stanza will write for the current selection. + * Mirrors codemod-runner's resolution: `scope: "app"` files are prefixed by + * the active app dir (we default to `apps/web/`); `scope: "repo"` files land + * at the repo root. Returns a deterministic order grouped by slot order. + */ +export function selectedFiles( + resolved: Partial>, + appDir = "apps/web", +): SelectedFile[] { + const out: SelectedFile[] = []; + for (const slot of slotOrder) { + const entry = resolved[slot]; + if (!entry) continue; + for (const tpl of entry.adapter.templates ?? []) { + const path = tpl.scope === "repo" ? tpl.dest : `${appDir.replace(/\/$/, "")}/${tpl.dest}`; + out.push({ path, template: tpl, owner: { slot, module: entry.module.id } }); + } + } + return out; +} + +/** + * Build the `pnpm create stanza` command from the current state. Used both by + * the command-bar display and by the copy-to-clipboard action. + */ +export function buildCommand(input: { name: string; selections: Selections }): string { + const flags = slotOrder + .map((slot) => { + const v = input.selections[slot]; + return v ? `--${slot}=${v}` : null; + }) + .filter((s): s is string => Boolean(s)); + return `pnpm create stanza ${input.name}${flags.length ? " " + flags.join(" ") : ""}`; +} diff --git a/apps/web/src/routeTree.gen.ts b/apps/web/src/routeTree.gen.ts index 7bf2dc2..dceedff 100644 --- a/apps/web/src/routeTree.gen.ts +++ b/apps/web/src/routeTree.gen.ts @@ -8,62 +8,61 @@ // You should NOT make any changes in this file as it will be overwritten. // Additionally, you should also exclude this file from your linter and/or formatter to prevent it from being checked or modified. -import { Route as rootRouteImport } from "./routes/__root"; -import { Route as IndexRouteImport } from "./routes/index"; +import { Route as rootRouteImport } from './routes/__root' +import { Route as IndexRouteImport } from './routes/index' const IndexRoute = IndexRouteImport.update({ - id: "/", - path: "/", + id: '/', + path: '/', getParentRoute: () => rootRouteImport, -} as any); +} as any) export interface FileRoutesByFullPath { - "/": typeof IndexRoute; + '/': typeof IndexRoute } export interface FileRoutesByTo { - "/": typeof IndexRoute; + '/': typeof IndexRoute } export interface FileRoutesById { - __root__: typeof rootRouteImport; - "/": typeof IndexRoute; + __root__: typeof rootRouteImport + '/': typeof IndexRoute } export interface FileRouteTypes { - fileRoutesByFullPath: FileRoutesByFullPath; - fullPaths: "/"; - fileRoutesByTo: FileRoutesByTo; - to: "/"; - id: "__root__" | "/"; - fileRoutesById: FileRoutesById; + fileRoutesByFullPath: FileRoutesByFullPath + fullPaths: '/' + fileRoutesByTo: FileRoutesByTo + to: '/' + id: '__root__' | '/' + fileRoutesById: FileRoutesById } export interface RootRouteChildren { - IndexRoute: typeof IndexRoute; + IndexRoute: typeof IndexRoute } -declare module "@tanstack/react-router" { +declare module '@tanstack/react-router' { interface FileRoutesByPath { - "/": { - id: "/"; - path: "/"; - fullPath: "/"; - preLoaderRoute: typeof IndexRouteImport; - parentRoute: typeof rootRouteImport; - }; + '/': { + id: '/' + path: '/' + fullPath: '/' + preLoaderRoute: typeof IndexRouteImport + parentRoute: typeof rootRouteImport + } } } const rootRouteChildren: RootRouteChildren = { IndexRoute: IndexRoute, -}; +} export const routeTree = rootRouteImport ._addFileChildren(rootRouteChildren) - ._addFileTypes(); + ._addFileTypes() -import type { createStart } from "@tanstack/react-start"; - -import type { getRouter } from "./router.tsx"; -declare module "@tanstack/react-start" { +import type { getRouter } from './router.tsx' +import type { createStart } from '@tanstack/react-start' +declare module '@tanstack/react-start' { interface Register { - ssr: true; - router: Awaited>; + ssr: true + router: Awaited> } } diff --git a/apps/web/src/routes/__root.tsx b/apps/web/src/routes/__root.tsx index 11c7c89..f1aca08 100644 --- a/apps/web/src/routes/__root.tsx +++ b/apps/web/src/routes/__root.tsx @@ -2,6 +2,12 @@ import { TanStackDevtools } from "@tanstack/react-devtools"; import { HeadContent, Outlet, Scripts, createRootRoute } from "@tanstack/react-router"; import { TanStackRouterDevtoolsPanel } from "@tanstack/react-router-devtools"; +import { Footer } from "@/components/footer"; +import { Header } from "@/components/header"; +import { ThemeProvider } from "@/components/theme-provider"; +import { Toaster } from "@/components/ui/sonner"; +import { TooltipProvider } from "@/components/ui/tooltip"; + import appCss from "../styles.css?url"; export const Route = createRootRoute({ @@ -12,34 +18,33 @@ export const Route = createRootRoute({ { title: "stanza" }, { name: "description", content: "Modular monorepo template builder." }, ], - links: [ - { - rel: "stylesheet", - href: appCss, - }, - ], + links: [{ rel: "stylesheet", href: appCss }], }), component: RootComponent, }); function RootComponent() { return ( - + - - + + + +
+
+
+ +
+
+
+ +
+
, - }, - ]} + config={{ position: "bottom-right" }} + plugins={[{ name: "Tanstack Router", render: }]} /> diff --git a/apps/web/src/routes/index.tsx b/apps/web/src/routes/index.tsx index 2a4bbda..334d399 100644 --- a/apps/web/src/routes/index.tsx +++ b/apps/web/src/routes/index.tsx @@ -1,30 +1,38 @@ -import type { RegistryIndex } from "@stanza/registry"; import { createFileRoute } from "@tanstack/react-router"; -import { createServerFn } from "@tanstack/react-start"; import { Builder } from "@/components/builder"; +import type { BuilderSearch } from "@/lib/selection"; +import { getBuilderState } from "@/server/builder-state"; -const getRegistryIndex = createServerFn({ method: "GET" }).handler(async () => { - const url = process.env.STANZA_REGISTRY ?? "https://stanza.dev/registry"; - const res = await fetch(`${url}/index.json`); - if (!res.ok) { - throw new Error(`Failed to load stanza registry: ${res.status}`); +function validateSearch(input: Record): BuilderSearch { + const out: BuilderSearch = {}; + for (const key of ["name", "framework", "styling", "db", "orm", "auth"] as const) { + const v = input[key]; + if (typeof v === "string" && v.length > 0) out[key] = v; } - return (await res.json()) as RegistryIndex; -}); + return out; +} export const Route = createFileRoute("/")({ - loader: () => getRegistryIndex(), + validateSearch, + loaderDeps: ({ search }) => search, + loader: ({ deps }) => getBuilderState({ data: deps }), component: Page, }); function Page() { - const index = Route.useLoaderData(); + const state = Route.useLoaderData(); + const search = Route.useSearch(); return ( -
-

stanza

-

Pick your stack. Get an idiomatic monorepo.

- -
+
+
+

Build your stack

+

+ Pick a framework, ORM, database, auth provider, and styling. Get a clean monorepo with + idiomatic, vendored code. Add modules later with stanza add. +

+
+ +
); } diff --git a/apps/web/src/server/builder-state.ts b/apps/web/src/server/builder-state.ts new file mode 100644 index 0000000..3bcc8ed --- /dev/null +++ b/apps/web/src/server/builder-state.ts @@ -0,0 +1,89 @@ +import type { Module, RegistryIndex } from "@stanza/registry"; +import { createServerFn } from "@tanstack/react-start"; + +import { parseSelections, resolveSelectedAdapters, selectedFiles } from "@/lib/selection"; +import type { BuilderSearch } from "@/lib/selection"; +import { renderPreview, type Preview } from "@/server/highlighter"; + +const REGISTRY_BASE = process.env.STANZA_REGISTRY ?? "/registry"; + +export type BuilderState = { + index: RegistryIndex; + /** Keyed by `${slot}:${id}` for direct lookup. */ + modules: Record; + /** Pre-rendered Shiki HTML, keyed by file path (relative to repo root). */ + previews: Record; + /** Ordered list of file paths derived from current selections. */ + filePaths: string[]; +}; + +/** + * Server function that powers the builder. Runs on every search-param change + * via the route's `loaderDeps`. Pulls the registry, derives the selected file + * list, and pre-renders Shiki HTML for each — keeping shiki off the client. + */ +export const getBuilderState = createServerFn({ method: "GET" }) + .inputValidator((data: BuilderSearch) => data) + .handler(async ({ data }): Promise => { + const base = await resolveBase(); + + const indexRes = await fetch(`${base}/index.json`); + if (!indexRes.ok) { + throw new Error(`Failed to load stanza registry index: ${indexRes.status}`); + } + const index = (await indexRes.json()) as RegistryIndex; + + // Fetch every module in parallel. The dataset is small (10s of KB at most) + // and having everything client-side lets the user toggle slots without + // extra roundtrips. The Shiki preview map is the only piece that changes + // per-selection — recomputed each loader run. + const fullModules = await Promise.all( + index.modules.map(async (summary) => { + const url = `${base}/modules/${summary.slot}-${summary.id}.json`; + const res = await fetch(url); + if (!res.ok) throw new Error(`Module fetch failed: ${url} (${res.status})`); + const mod = (await res.json()) as Module; + return [`${mod.slot}:${mod.id}`, mod] as const; + }), + ); + const modules: Record = Object.fromEntries(fullModules); + + const { selections } = parseSelections(data); + const resolved = resolveSelectedAdapters(modules, selections); + const files = selectedFiles(resolved); + + const previewEntries = await Promise.all( + files.map(async (file) => { + const content = file.template.content ?? ""; + const preview = await renderPreview(content, file.path); + return [file.path, preview] as const; + }), + ); + + return { + index, + modules, + previews: Object.fromEntries(previewEntries), + filePaths: files.map((f) => f.path), + }; + }); + +/** + * `STANZA_REGISTRY` overrides the base. Without an override we hit the + * same-domain `/registry/...` path served from `apps/web/public/registry/`. + * In Node fetch we need an absolute URL — derive one from the request when + * possible, fall back to localhost for dev. + */ +async function resolveBase(): Promise { + if (REGISTRY_BASE.startsWith("http")) return REGISTRY_BASE; + // Server-rendering: convert the relative path to an absolute URL using the + // incoming request. `getRequest()` is the TanStack Start server-side handle. + try { + const { getRequest } = await import("@tanstack/react-start/server"); + const url = new URL(getRequest().url); + return `${url.origin}${REGISTRY_BASE}`; + } catch { + // Fallback for non-request contexts (e.g. unit tests). Dev server default. + return `http://localhost:3000${REGISTRY_BASE}`; + } +} diff --git a/apps/web/src/server/highlighter.ts b/apps/web/src/server/highlighter.ts new file mode 100644 index 0000000..e8fb160 --- /dev/null +++ b/apps/web/src/server/highlighter.ts @@ -0,0 +1,89 @@ +import { + createHighlighter, + type Highlighter, + type BundledLanguage, + type BundledTheme, +} from "shiki"; + +/** + * Languages we expect from the registry's templates. New module templates that + * use a language outside this set will fall through to plaintext — add the + * language here and rebuild. + */ +const LANGS = [ + "tsx", + "ts", + "json", + "jsonc", + "css", + "html", + "md", + "mdx", + "yaml", + "bash", + "shell", + "prisma", + "sql", +] as const satisfies readonly BundledLanguage[]; + +const THEMES = ["github-light", "github-dark"] as const satisfies readonly BundledTheme[]; + +let highlighterPromise: Promise | undefined; + +/** + * Module-singleton Shiki highlighter. Keeps the grammar/theme load warm across + * server function invocations on the same server instance. This whole module + * runs server-side only — never imported from a client component. + */ +export function getHighlighter(): Promise { + if (!highlighterPromise) { + highlighterPromise = createHighlighter({ + themes: [...THEMES], + langs: [...LANGS], + }); + } + return highlighterPromise; +} + +const EXT_TO_LANG: Record = { + ts: "ts", + tsx: "tsx", + js: "ts", + jsx: "tsx", + mjs: "ts", + cjs: "ts", + json: "json", + jsonc: "jsonc", + css: "css", + html: "html", + md: "md", + mdx: "mdx", + yaml: "yaml", + yml: "yaml", + sh: "bash", + bash: "bash", + prisma: "prisma", + sql: "sql", + // anything else returns undefined → highlighter renders as plaintext +}; + +export function langForPath(path: string): BundledLanguage | undefined { + const ext = path.split(".").pop()?.toLowerCase() ?? ""; + return EXT_TO_LANG[ext]; +} + +export type Preview = { light: string; dark: string }; + +/** + * Render a single source string to a `{ light, dark }` pair of pre-themed HTML + * blocks. The client renders one via dangerouslySetInnerHTML based on the + * active theme. + */ +export async function renderPreview(source: string, filePath: string): Promise { + const highlighter = await getHighlighter(); + const lang = langForPath(filePath) ?? "plaintext"; + return { + light: highlighter.codeToHtml(source, { lang, theme: "github-light" }), + dark: highlighter.codeToHtml(source, { lang, theme: "github-dark" }), + }; +} diff --git a/apps/web/src/styles.css b/apps/web/src/styles.css index d931800..38952a9 100644 --- a/apps/web/src/styles.css +++ b/apps/web/src/styles.css @@ -2,10 +2,12 @@ @import "tw-animate-css"; @import "shadcn/tailwind.css"; @import "@fontsource-variable/geist"; +@import "@fontsource-variable/geist-mono"; @custom-variant dark (&:is(.dark *)); @theme inline { + --font-mono: "Geist Mono Variable", monospace; --font-heading: var(--font-sans); --font-sans: "Geist Variable", sans-serif; --color-sidebar-ring: var(--sidebar-ring); @@ -131,7 +133,4 @@ [role="button"]:not(:disabled) { cursor: pointer; } - html { - @apply font-sans; - } } diff --git a/apps/web/tsconfig.json b/apps/web/tsconfig.json index f1e9d69..1d4898b 100644 --- a/apps/web/tsconfig.json +++ b/apps/web/tsconfig.json @@ -11,5 +11,5 @@ "@/*": ["./src/*"] } }, - "include": ["src/**/*.ts", "src/**/*.tsx", "vite.config.ts"] + "include": ["src/**/*.ts", "src/**/*.tsx", "vite.config.ts", "scripts/**/*.ts"] } diff --git a/packages/internal/src/module-new.ts b/packages/internal/src/module-new.ts index 125058d..4450a62 100644 --- a/packages/internal/src/module-new.ts +++ b/packages/internal/src/module-new.ts @@ -67,8 +67,9 @@ async function main() { "Next steps:", ` ${kleur.dim("1.")} Edit ${kleur.cyan(`registry/modules/${args.slot}-${args.id}/module.ts`)}`, ` ${kleur.dim("2.")} Add templates under ${kleur.cyan("templates/")}`, - ` ${kleur.dim("3.")} ${kleur.cyan("pnpm install --force")} to link the workspace`, - ` ${kleur.dim("4.")} ${kleur.cyan("pnpm registry:build")} to verify the manifest`, + ` ${kleur.dim("3.")} Drop a ${kleur.cyan("logo.svg")} (or ${kleur.cyan("logo-light.svg")} + ${kleur.cyan("logo-dark.svg")}) — try ${kleur.dim("https://svgl.app")}`, + ` ${kleur.dim("4.")} ${kleur.cyan("pnpm install --force")} to link the workspace`, + ` ${kleur.dim("5.")} ${kleur.cyan("pnpm registry:build")} to verify the manifest`, ].join("\n"), ); } diff --git a/packages/internal/src/registry-build.ts b/packages/internal/src/registry-build.ts index a647d22..c88af01 100644 --- a/packages/internal/src/registry-build.ts +++ b/packages/internal/src/registry-build.ts @@ -12,7 +12,7 @@ import fs from "node:fs"; import path from "node:path"; import { fileURLToPath } from "node:url"; -import type { Module, RegistryIndex } from "@stanza/registry"; +import type { Logo, Module, RegistryIndex } from "@stanza/registry"; const here = path.dirname(fileURLToPath(import.meta.url)); const repoRoot = findRepoRoot(here); @@ -40,8 +40,12 @@ async function main() { // no follow-up requests to retrieve template files. Local dev still works // because the runner falls back to disk when `content` is absent. const templatesDir = path.join(modulesDir, dir, "templates"); + const logo = readLogo(path.join(modulesDir, dir)); + const codemodBundle = await bundleCodemods(path.join(modulesDir, dir)); const inlined: Module = { ...mod, + ...(logo ? { logo } : {}), + ...(codemodBundle ? { codemodBundle } : {}), adapters: mod.adapters.map((adapter) => ({ ...adapter, templates: adapter.templates?.map((tpl) => ({ @@ -57,11 +61,12 @@ async function main() { ); // The index keeps a lightweight summary — no `content`, no per-adapter - // payloads. The wizard/web builder uses this for filtering; the full - // module JSON is only fetched when a user actually picks something. + // payloads — but it DOES carry top-level metadata like `logo` so the + // wizard / web builder can render module cards without fetching the + // full per-module JSON. summaries.push({ - ...mod, - adapters: mod.adapters.map((a) => ({ key: a.key, match: a.match })), + ...inlined, + adapters: inlined.adapters.map((a) => ({ key: a.key, match: a.match })), }); } @@ -83,6 +88,64 @@ async function main() { console.log(`Wrote ${summaries.length} modules to ${outDir}`); } +/** + * Convention: a module ships a logo by dropping either `logo.svg` (a single + * theme-agnostic SVG) or `logo-light.svg` + `logo-dark.svg` (a theme pair) + * in its directory. We read whichever is present and return the inlined + * markup; if neither exists the module just renders without one. + */ +function readLogo(moduleDir: string): Logo | undefined { + const light = path.join(moduleDir, "logo-light.svg"); + const dark = path.join(moduleDir, "logo-dark.svg"); + if (fs.existsSync(light) && fs.existsSync(dark)) { + return { + light: fs.readFileSync(light, "utf8"), + dark: fs.readFileSync(dark, "utf8"), + }; + } + const single = path.join(moduleDir, "logo.svg"); + if (fs.existsSync(single)) return fs.readFileSync(single, "utf8"); + return undefined; +} + +/** + * If a module ships imperative codemods (`codemods/index.ts`), bundle them + * into a self-contained ESM string and embed in the registry JSON. We mark + * the runtime helpers (`@stanza/codemods`, `ts-morph`, Node built-ins) as + * external — the CLI provides them at install time. Local dev (FS-loaded + * modules) can still ignore this and import the source directly off disk. + */ +async function bundleCodemods(moduleDir: string): Promise { + const entry = path.join(moduleDir, "codemods", "index.ts"); + if (!fs.existsSync(entry)) return undefined; + + const result = await Bun.build({ + entrypoints: [entry], + target: "bun", + format: "esm", + external: [ + "@stanza/codemods", + "@stanza/registry", + "ts-morph", + "node:*", + "fs", + "path", + "os", + "url", + ], + minify: false, + }); + + if (!result.success) { + const log = result.logs.map((l: { message?: string }) => l.message ?? String(l)).join("\n"); + throw new Error(`Failed to bundle codemods at ${entry}:\n${log}`); + } + + const out = result.outputs[0]; + if (!out) throw new Error(`Bundler produced no output for ${entry}`); + return await out.text(); +} + function findRepoRoot(start: string): string { let dir = start; for (let i = 0; i < 8; i++) { diff --git a/packages/internal/tsconfig.json b/packages/internal/tsconfig.json index 307afcd..34df389 100644 --- a/packages/internal/tsconfig.json +++ b/packages/internal/tsconfig.json @@ -2,7 +2,7 @@ "extends": "../../tsconfig.base.json", "compilerOptions": { "rootDir": "./src", - "types": ["node"] + "types": ["node", "bun"] }, "include": ["src"] } diff --git a/packages/registry/src/index.ts b/packages/registry/src/index.ts index ffa5860..f944c1e 100644 --- a/packages/registry/src/index.ts +++ b/packages/registry/src/index.ts @@ -10,6 +10,7 @@ export type { RegistryIndex, TemplateRef, EnvVar, + Logo, } from "./module.ts"; export { defineModule, KNOWN_SLOTS } from "./module.ts"; diff --git a/packages/registry/src/module.ts b/packages/registry/src/module.ts index 118547d..a22d364 100644 --- a/packages/registry/src/module.ts +++ b/packages/registry/src/module.ts @@ -69,6 +69,15 @@ export type EnvVar = { description?: string; }; +/** + * Inlined SVG markup for a module's logo. A bare string is theme-agnostic; + * `{ light, dark }` lets the UI swap based on the active theme. The registry + * build step auto-detects `logo.svg` (single) or `logo-light.svg` + + * `logo-dark.svg` (pair) in the module directory and populates this field — + * module authors don't write it manually. + */ +export type Logo = string | { light: string; dark: string }; + export type Module = { id: ModuleId; slot: SlotId; @@ -92,6 +101,17 @@ export type Module = { homepage?: string; /** Optional maintainer attribution. */ author?: string; + /** Inlined SVG logo, populated by the registry build step. */ + logo?: Logo; + /** + * Bundled JS source of this module's `codemods/index.ts`, populated by the + * registry build step. Self-contained ESM with `@stanza/codemods`, + * `@stanza/registry`, `ts-morph`, and Node built-ins externalized — the CLI + * provides them at runtime. The runner writes this to a temp file and + * dynamic-imports it; for local dev (no bundle) it falls back to reading + * `codemods/index.ts` off disk. + */ + codemodBundle?: string; }; /** @@ -166,4 +186,6 @@ export const ModuleSchema = z.object({ ), homepage: z.string().optional(), author: z.string().optional(), + logo: z.union([z.string(), z.object({ light: z.string(), dark: z.string() })]).optional(), + codemodBundle: z.string().optional(), }) satisfies z.ZodType; diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index f1eec11..b8d77f7 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -69,6 +69,12 @@ importers: '@fontsource-variable/geist': specifier: ^5.2.9 version: 5.2.9 + '@fontsource-variable/geist-mono': + specifier: ^5.2.8 + version: 5.2.8 + '@pierre/trees': + specifier: 1.0.0-beta.4 + version: 1.0.0-beta.4(react-dom@19.2.6(react@19.2.6))(react@19.2.6) '@stanza/registry': specifier: workspace:* version: link:../../packages/registry @@ -114,6 +120,12 @@ importers: shadcn: specifier: ^4.7.0 version: 4.7.0(@types/node@25.9.1)(typescript@6.0.3) + shiki: + specifier: ^4.1.0 + version: 4.1.0 + sonner: + specifier: ^2.0.7 + version: 2.0.7(react-dom@19.2.6(react@19.2.6))(react@19.2.6) tailwind-merge: specifier: ^3.6.0 version: 3.6.0 @@ -591,6 +603,9 @@ packages: '@floating-ui/utils@0.2.11': resolution: {integrity: sha512-RiB/yIh78pcIxl6lLMG0CgBXAZ2Y0eVHqMPYugu+9U0AeT6YBeiJpf7lbdJNIugFP5SIjwNRgo4DhR1Qxi26Gg==} + '@fontsource-variable/geist-mono@5.2.8': + resolution: {integrity: sha512-KI5bj+hkkRiHttYHmccotUZ80ZuZyai+RwI1d7UId0clkx/jXxlo8qYK8j54WzmpBjtMoEMPyllV7faDcj+6RA==} + '@fontsource-variable/geist@5.2.9': resolution: {integrity: sha512-TP+QSBG3wxKGPE33CbMy/L0Nu3qvJ6Fy81Yc4LnQ95xH+i+cfEp8fyU8/kfV14YwszxIFPhnoMTbjL71waVpyQ==} @@ -1076,6 +1091,12 @@ packages: cpu: [x64] os: [win32] + '@pierre/trees@1.0.0-beta.4': + resolution: {integrity: sha512-OfT1yk9ne8Te5+GB5zUY8yqE6B8BqjBHQJleH4lu8ltwNpoocZl4vXt1AzlEExpxI/pp+AFX5QG+lR3JjtTEag==} + peerDependencies: + react: ^18.3.1 || ^19.0.0 + react-dom: ^18.3.1 || ^19.0.0 + '@posthog/core@1.29.6': resolution: {integrity: sha512-qLA/4xTzxG1FabliYjsOy5pTC9XZWA225MHnpCmqGBoDVGL4sKKgLixMK2dpsHZbSo6AHpBLUfqkvTsh2YxZcQ==} @@ -1177,6 +1198,37 @@ packages: '@sec-ant/readable-stream@0.4.1': resolution: {integrity: sha512-831qok9r2t8AlxLko40y2ebgSDhenenCatLVeW/uBtnHPyhHOvG0C7TvfgecV+wHzIm5KUICgzmVpWS+IMEAeg==} + '@shikijs/core@4.1.0': + resolution: {integrity: sha512-jLJtSJeuFffqX6/inRE1zqU5aFv2hrszvYgq3OjbAgFRZiWv7abKMDdQzYxuSDfmUPQozZvI/kuy6VMTvnvqTQ==} + engines: {node: '>=20'} + + '@shikijs/engine-javascript@4.1.0': + resolution: {integrity: sha512-YquhawCUgaBfhsS72e2Y/dI59gCBNPHu3fEO/tvLaXrTssxZrY5ddjtNLTwndrMgPo8b3IscE+xoICDzpTmlFQ==} + engines: {node: '>=20'} + + '@shikijs/engine-oniguruma@4.1.0': + resolution: {integrity: sha512-axLpjVs45YBvvINa+dJF+NPW+KtFkNXsFr4SDw2BMj9GdeMnGxVB9PQb2xXlJYovslt/nz6giedAyOANkfc7hg==} + engines: {node: '>=20'} + + '@shikijs/langs@4.1.0': + resolution: {integrity: sha512-nwOMruEkbgdZfQ/b8CgpNBVOpvG1k0N5tbmgiFeqsan401+x3ILqlzZJowSla4Agmq4hG2Uf2wh5jLTEhR8VSg==} + engines: {node: '>=20'} + + '@shikijs/primitive@4.1.0': + resolution: {integrity: sha512-zx2/2Uwj2q9X3KSyYREEhXO23xBw5WUhP4orK2lE4r+t9JGITmEe0JH+wPmJhqHpOT2bRRs6lAL945+LDvOAGw==} + engines: {node: '>=20'} + + '@shikijs/themes@4.1.0': + resolution: {integrity: sha512-emCcTnUM7yO2wltYbaxm+yLvcCI4+h8XBKc4KmJ7EZUXoSGjcCHifkI//R4OFit9ewpg7H2/9tjOuXrT2v/Knw==} + engines: {node: '>=20'} + + '@shikijs/types@4.1.0': + resolution: {integrity: sha512-3EQWX54fMpniOrDblzAhiwiJwpiTMW6+B9DWyUd9ska483tbayFYuw47UxwuPknI31bKnySfVQ/QW+jFL4rFdA==} + engines: {node: '>=20'} + + '@shikijs/vscode-textmate@10.0.2': + resolution: {integrity: sha512-83yeghZ2xxin3Nj8z1NMd/NCuca+gsYXswywDy5bHvwlWL8tpTQmzGeUuHd9FC3E/SBEMvzJRwWEOz5gGes9Qg==} + '@sindresorhus/merge-streams@4.0.0': resolution: {integrity: sha512-tlqY9xq5ukxTUZBmoOp+m61cqwQD5pHJtFY3Mn8CA8ps6yghLH/Hw8UPdqg4OLmFW3IFlcXnQNmo/dh8HzXYIQ==} engines: {node: '>=18'} @@ -1607,6 +1659,12 @@ packages: '@types/estree@1.0.9': resolution: {integrity: sha512-GhdPgy1el4/ImP05X05Uw4cw2/M93BCUmnEvWZNStlCzEKME4Fkk+YpoA5OiHNQmoS7Cafb8Xa3Pya8m1Qrzeg==} + '@types/hast@3.0.4': + resolution: {integrity: sha512-WPs+bbQw5aCj+x6laNGWLH3wviHtoCv/P3+otBhbOhJgG8qtpdAMlTCxLtsTWA7LH1Oh/bFCHsBn0TPS5m30EQ==} + + '@types/mdast@4.0.4': + resolution: {integrity: sha512-kGaNbPh1k7AFzgpud/gMdvIm5xuECykRR+JnWKQno9TAXVa6WIVCGTPvYGekIDL4uwCZQSYbUxNBSb1aUo79oA==} + '@types/node@25.9.1': resolution: {integrity: sha512-xfrlY7UD5rMJk3ZVJP8BNzS28J36YJg+xp+LPXV1TdWxr8uMH5A860QNxYDGQe/ylDSgjxE52Q9VnO7p75tJxg==} @@ -1627,9 +1685,15 @@ packages: '@types/statuses@2.0.6': resolution: {integrity: sha512-xMAgYwceFhRA2zY+XbEA7mxYbA093wdiW8Vu6gZPGWy9cmOyU9XesH1tNcEWsKFd5Vzrqx5T3D38PWx1FIIXkA==} + '@types/unist@3.0.3': + resolution: {integrity: sha512-ko/gIFJRv177XgZsZcBwnqJN5x/Gien8qNOn0D5bQU/zAzVf9Zt3BlcUiLqhV9y4ARk0GbT3tnUiPNgnTXzc/Q==} + '@types/validate-npm-package-name@4.0.2': resolution: {integrity: sha512-lrpDziQipxCEeK5kWxvljWYhUvOiB2A9izZd9B2AFarYAkqZshb4lPbRs7zKEic6eGtH8V/2qJW+dPp9OtF6bw==} + '@ungap/structured-clone@1.3.1': + resolution: {integrity: sha512-mUFwbeTqrVgDQxFveS+df2yfap6iuP20NAKAsBt5jDEoOTDew+zwLAOilHCeQJOVSvmgCX4ogqIrA0mnyr08yQ==} + '@vitejs/plugin-react@6.0.2': resolution: {integrity: sha512-DlSMqo4WhThw4vB8Mpn0Woe9J+Jfq1geJ61AKW0QEgLzGMNwtIMdxbDUzLxcun8W7NbJO0e2Jg/Nxm3cCSVzzg==} engines: {node: ^20.19.0 || >=22.12.0} @@ -1786,6 +1850,9 @@ packages: caniuse-lite@1.0.30001793: resolution: {integrity: sha512-iwSsYWaCOoh26cV8NwNRViHlrfUvYsHDfRVcbtmw0Kg6PJIZZXwMkj1442FYLBGkeUf1juAsU3DTfxW579mrPA==} + ccount@2.0.1: + resolution: {integrity: sha512-eyrF0jiFpY+3drT6383f1qhkbGsLSifNAjA61IUjZjmLCWjItY6LB9ft9YhoDgwfmclB2zhu51Lc7+95b8NRAg==} + chai@6.2.2: resolution: {integrity: sha512-NUPRluOfOiTKBKvWPtSD4PhFvWCqOi0BGStNWs57X9js7XGTprSmFoz5F0tWhR4WPjNeR9jXqdC7/UpSJTnlRg==} engines: {node: '>=18'} @@ -1794,6 +1861,12 @@ packages: resolution: {integrity: sha512-7NzBL0rN6fMUW+f7A6Io4h40qQlG+xGmtMxfbnH/K7TAtt8JQWVQK+6g0UXKMeVJoyV5EkkNsErQ8pVD3bLHbA==} engines: {node: ^12.17.0 || ^14.13 || >=16.0.0} + character-entities-html4@2.1.0: + resolution: {integrity: sha512-1v7fgQRj6hnSwFpq1Eu0ynr/CDEw0rXo2B61qXrLNdHZmPKgb7fqS1a2JwF0rISo9q77jDI8VMEHoApn8qDoZA==} + + character-entities-legacy@3.0.0: + resolution: {integrity: sha512-RpPp0asT/6ufRm//AJVwpViZbGM/MkjQFxJccQRHmISF/22NBtsHqAWmL+/pmkPWoIUJdWyeVleTl1wydHATVQ==} + cheerio-select@2.1.0: resolution: {integrity: sha512-9v9kG0LvzrlcungtnJtpGNxY+fzECQKhK4EGJX2vByejiMX84MFNQw4UxPJl3bFbTMw+Dfs37XaIkCwTZfLh4g==} @@ -1838,6 +1911,9 @@ packages: color-name@1.1.4: resolution: {integrity: sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA==} + comma-separated-tokens@2.0.3: + resolution: {integrity: sha512-Fu4hJdvzeylCfQPp9SGWidpzrMs7tTrlu6Vb8XGaRGck8QSNZJJp538Wrb60Lax4fPwR64ViY468OIUTbRlGZg==} + commander@11.1.0: resolution: {integrity: sha512-yPVavfyCcRhmorC7rWlkHn15b4wDVgVmBA7kV4QVBsF7kv/9TKJAbAXVTxvTnwP8HHKjRCJDClKbciiYS7p0DQ==} engines: {node: '>=16'} @@ -2006,6 +2082,9 @@ packages: resolution: {integrity: sha512-Btj2BOOO83o3WyH59e8MgXsxEQVcarkUOpEYrubB0urwnN10yQ364rsiByU11nZlqWYZm05i/of7io4mzihBtQ==} engines: {node: '>=8'} + devlop@1.1.0: + resolution: {integrity: sha512-RWmIqhcFf1lRYBvNmr7qTNuyCt/7/ns2jbpp1+PalgE/rDQcBT0fioSMUpJ93irlUhC5hrg4cYqe6U+0ImW0rA==} + diff@8.0.4: resolution: {integrity: sha512-DPi0FmjiSU5EvQV0++GFDOJ9ASQUVFh5kD+OzOnYdi7n3Wpm9hWWGfB/O2blfHcMVTL5WkQXSnRiK9makhrcnw==} engines: {node: '>=0.3.1'} @@ -2310,6 +2389,12 @@ packages: resolution: {integrity: sha512-ej4AhfhfL2Q2zpMmLo7U1Uv9+PyhIZpgQLGT1F9miIGmiCJIoCgSmczFdrc97mWT4kVY72KA+WnnhJ5pghSvSg==} engines: {node: '>= 0.4'} + hast-util-to-html@9.0.5: + resolution: {integrity: sha512-OguPdidb+fbHQSU4Q4ZiLKnzWo8Wwsf5bZfbvu7//a9oTYoqD/fWpe96NuHkoS9h0ccGOTe0C4NGXdtS0iObOw==} + + hast-util-whitespace@3.0.0: + resolution: {integrity: sha512-88JUN06ipLwsnv+dVn+OIYOvAuvBMy/Qoi6O7mQHxdPXpjy+Cd6xRkWwux7DKO+4sYILtLBRIKgsdpS2gQc7qw==} + headers-polyfill@5.0.1: resolution: {integrity: sha512-1TJ6Fih/b8h5TIcv+1+Hw0PDQWJTKDKzFZzcKOiW1wJza3XoAQlkCuXLbymPYB8+ZQyw8mHvdw560e8zVFIWyA==} @@ -2324,6 +2409,9 @@ packages: resolution: {integrity: sha512-CV9TW3Y3f8/wT0BRFc1/KAVQ3TUHiXmaAb6VW9vtiMFf7SLoMd1PdAc4W3KFOFETBJUb90KatHqlsZMWV+R9Gg==} engines: {node: ^20.19.0 || ^22.12.0 || >=24.0.0} + html-void-elements@3.0.0: + resolution: {integrity: sha512-bEqo66MRXsUGxWHV5IP0PUiAWwoEjba4VCzg0LjFJBpchPaTfyfCKTG6bc5F8ucKec3q5y6qOdGyYTSBEvhCrg==} + htmlparser2@10.1.0: resolution: {integrity: sha512-VTZkM9GWRAtEpveh7MSF6SjjrpNVNNVJfFup7xTY3UpFtm67foy9HDVXneLtFVt4pMz5kZtgNcvCniNFb1hlEQ==} @@ -2613,6 +2701,9 @@ packages: resolution: {integrity: sha512-/IXtbwEk5HTPyEwyKX6hGkYXxM9nbj64B+ilVJnC/R6B0pH5G4V3b0pVbL7DBj4tkhBAppbQUlf6F6Xl9LHu1g==} engines: {node: '>= 0.4'} + mdast-util-to-hast@13.2.1: + resolution: {integrity: sha512-cctsq2wp5vTsLIcaymblUriiTcZd0CwWtCbLvrOzYCDZoWyMNV8sZ7krj09FSnsiJi3WVsHLM4k6Dq/yaPyCXA==} + mdn-data@2.27.1: resolution: {integrity: sha512-9Yubnt3e8A0OKwxYSXyhLymGW4sCufcLG6VdiDdUGVkPhpqLxlvP5vl1983gQjJl3tqbrM731mjaZaP68AgosQ==} @@ -2631,6 +2722,21 @@ packages: resolution: {integrity: sha512-8q7VEgMJW4J8tcfVPy8g09NcQwZdbwFEqhe/WZkoIzjn/3TGDwtOCYtXGxA3O8tPzpczCCDgv+P2P5y00ZJOOg==} engines: {node: '>= 8'} + micromark-util-character@2.1.1: + resolution: {integrity: sha512-wv8tdUTJ3thSFFFJKtpYKOYiGP2+v96Hvk4Tu8KpCAsTMs6yi+nVmGh1syvSCsaxz45J6Jbw+9DD6g97+NV67Q==} + + micromark-util-encode@2.0.1: + resolution: {integrity: sha512-c3cVx2y4KqUnwopcO9b/SCdo2O67LwJJ/UyqGfbigahfegL9myoEFoDYZgkT7f36T0bLrM9hZTAaAyH+PCAXjw==} + + micromark-util-sanitize-uri@2.0.1: + resolution: {integrity: sha512-9N9IomZ/YuGGZZmQec1MbgxtlgougxTodVwDzzEouPKo3qFWvymFHWcnDi2vzV1ff6kas9ucW+o3yzJK9YB1AQ==} + + micromark-util-symbol@2.0.1: + resolution: {integrity: sha512-vs5t8Apaud9N28kgCrRUdEed4UJ+wWNvicHLPxCa9ENlYuAY31M0ETy5y1vA33YoNPDFTghEbnh6efaE8h4x0Q==} + + micromark-util-types@2.0.2: + resolution: {integrity: sha512-Yw0ECSpJoViF1qTU4DC6NwtC4aWGt1EkzaQB8KPPyCRR8z9TWeV0HbEFGTO+ZY1wB22zmxnJqhPyTpOVCpeHTA==} + micromatch@4.0.8: resolution: {integrity: sha512-PXwfBhYu0hBCPw8Dn0E+WDYb7af3dSLVWKi3HGv84IdF4TyFoC0ysxFd0Goxw7nSv4T/PzEJQxsYsEiFCKo2BA==} engines: {node: '>=8.6'} @@ -2784,6 +2890,12 @@ packages: resolution: {integrity: sha512-VXJjc87FScF88uafS3JllDgvAm+c/Slfz06lorj2uAY34rlUu0Nt+v8wreiImcrgAjjIHp1rXpTDlLOGw29WwQ==} engines: {node: '>=18'} + oniguruma-parser@0.12.2: + resolution: {integrity: sha512-6HVa5oIrgMC6aA6WF6XyyqbhRPJrKR02L20+2+zpDtO5QAzGHAUGw5TKQvwi5vctNnRHkJYmjAhRVQF2EKdTQw==} + + oniguruma-to-es@4.3.6: + resolution: {integrity: sha512-csuQ9x3Yr0cEIs/Zgx/OEt9iBw9vqIunAPQkx19R/fiMq2oGVTgcMqO/V3Ybqefr1TBvosI6jU539ksaBULJyA==} + open@11.0.0: resolution: {integrity: sha512-smsWv2LzFjP03xmvFoJ331ss6h+jixfA4UUV/Bsiyuu4YJPfN+FIQGOIiv4w9/+MoHkfkJ22UIaQWRVFRfH6Vw==} engines: {node: '>=20'} @@ -2903,6 +3015,14 @@ packages: resolution: {integrity: sha512-dM0jVuXJPsDN6DvRpea484tCUaMiXWjuCn++HGTqUWzGDjv5tZkEZldAJ/UMlqRYGFrD/etByo4/xOuC/snX2A==} engines: {node: '>=20'} + preact-render-to-string@6.6.5: + resolution: {integrity: sha512-O6MHzYNIKYaiSX3bOw0gGZfEbOmlIDtDfWwN1JJdc/T3ihzRT6tGGSEWE088dWrEDGa1u7101q+6fzQnO9XCPA==} + peerDependencies: + preact: '>=10 || >= 11.0.0-0' + + preact@11.0.0-beta.0: + resolution: {integrity: sha512-IcODoASASYwJ9kxz7+MJeiJhvLriwSb4y4mHIyxdgaRZp6kPUud7xytrk/6GZw8U3y6EFJaRb5wi9SrEK+8+lg==} + prettier@3.8.3: resolution: {integrity: sha512-7igPTM53cGHMW8xWuVTydi2KO233VFiTNyF5hLJqpilHfmn8C8gPf+PS7dUT64YcXFbiMGZxS9pCSxL/Dxm/Jw==} engines: {node: '>=14'} @@ -2920,6 +3040,9 @@ packages: resolution: {integrity: sha512-NxNv/kLguCA7p3jE8oL2aEBsrJWgAakBpgmgK6lpPWV+WuOmY6r2/zbAVnP+T8bQlA0nzHXSJSJW0Hq7ylaD2Q==} engines: {node: '>= 6'} + property-information@7.1.0: + resolution: {integrity: sha512-TwEZ+X+yCJmYfL7TPUOcvBZ4QfoT5YenQiJuX//0th53DE6w0xxLEtfK3iyryQFddXuvkIk51EEgrJQ0WJkOmQ==} + proxy-addr@2.0.7: resolution: {integrity: sha512-llQsMLSUDUPT44jdrU/O37qlnifitDP+ZwrmmZcoSKyLKvtZxpyV0n2/bD/N4tBAAZ/gJEdZU7KMraoK1+XYAg==} engines: {node: '>= 0.10'} @@ -2963,6 +3086,15 @@ packages: resolution: {integrity: sha512-YTUo+Flmw4ZXiWfQKGcwwc11KnoRAYgzAE2E7mXKCjSviTKShtxBsN6YUUBB2gtaBzKzeKunxhUwNHQuRryhWA==} engines: {node: '>= 4'} + regex-recursion@6.0.2: + resolution: {integrity: sha512-0YCaSCq2VRIebiaUviZNs0cBz1kg5kVS2UKUfNIx8YVs1cN3AV7NTctO5FOKBA+UT2BPJIWZauYHPqJODG50cg==} + + regex-utilities@2.3.0: + resolution: {integrity: sha512-8VhliFJAWRaUiVvREIiW2NXXTmHs4vMNnSzuJVhscgmGav3g9VDxLrQndI3dZZVVdp0ZO/5v0xmX516/7M9cng==} + + regex@6.1.0: + resolution: {integrity: sha512-6VwtthbV4o/7+OaAF9I5L5V3llLEsoPyq9P1JVXkedTP33c7MfCG0/5NOPcSJn0TzXcG9YUrR0gQSWioew3LDg==} + require-directory@2.1.1: resolution: {integrity: sha512-fGxEI7+wsG9xrvdjsrlmL22OMTTiHRwAMroiEeMgq8gzoLC/PQr7RsRDSTLUg/bZAZtF+TVIkHc6/4RIKrui+Q==} engines: {node: '>=0.10.0'} @@ -3067,6 +3199,10 @@ packages: resolution: {integrity: sha512-ObmnIF4hXNg1BqhnHmgbDETF8dLPCggZWBjkQfhZpbszZnYur5DUljTcCHii5LC3J5E0yeO/1LIMyH+UvHQgyw==} engines: {node: '>= 0.4'} + shiki@4.1.0: + resolution: {integrity: sha512-l/ABZPUR5v70jI10EzqfMS/I96vjSGv2y0ihUV+WYFzv0EfvW4s54m0Lg8wCrrL+2IkwBzFTuxkZjPf8b2NX9Q==} + engines: {node: '>=20'} + side-channel-list@1.0.1: resolution: {integrity: sha512-mjn/0bi/oUURjc5Xl7IaWi/OJJJumuoJFQJfDDyO46+hBWsfaVM65TBHq2eoZBhzl9EchxOijpkbRC8SVBQU0w==} engines: {node: '>= 0.4'} @@ -3099,6 +3235,12 @@ packages: solid-js@1.9.13: resolution: {integrity: sha512-6hJeJMOcEX8ktqjpDoJZEmld3ijvcvWBDtiXBm7f4332SiFN66QeAQI1REQshvyUoISsSeJ4PHDauKYbwao9JQ==} + sonner@2.0.7: + resolution: {integrity: sha512-W6ZN4p58k8aDKA4XPcx2hpIQXBRAgyiWVkYhT7CvK6D3iAu7xjvVyhQHg2/iaKJZ1XVJ4r7XuwGL+WGEK37i9w==} + peerDependencies: + react: ^18.0.0 || ^19.0.0 || ^19.0.0-rc + react-dom: ^18.0.0 || ^19.0.0 || ^19.0.0-rc + source-map-js@1.2.1: resolution: {integrity: sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA==} engines: {node: '>=0.10.0'} @@ -3111,6 +3253,9 @@ packages: resolution: {integrity: sha512-i5uvt8C3ikiWeNZSVZNWcfZPItFQOsYTUAOkcUPGd8DqDy1uOUikjt5dG+uRlwyvR108Fb9DOd4GvXfT0N2/uQ==} engines: {node: '>= 12'} + space-separated-tokens@2.0.2: + resolution: {integrity: sha512-PEGlAwrG8yXGXRjW32fGbg66JAlOAwbObuqVoJpv/mRgoWDQfgH1wDPvtzWyUSNAXBGSk8h755YDbbcEy3SH2Q==} + srvx@0.11.15: resolution: {integrity: sha512-iXsux0UcOjdvs0LCMa2Ws3WwcDUozA3JN3BquNXkaFPP7TpRqgunKdEgoZ/uwb1J6xaYHfxtz9Twlh6yzwM6Tg==} engines: {node: '>=20.16.0'} @@ -3141,6 +3286,9 @@ packages: resolution: {integrity: sha512-tsaTIkKW9b4N+AEj+SVA+WhJzV7/zMhcSu78mLKWSk7cXMOSHsBKFWUs0fWwq8QyK3MgJBQRX6Gbi4kYbdvGkQ==} engines: {node: '>=18'} + stringify-entities@4.0.4: + resolution: {integrity: sha512-IwfBptatlO+QCJUo19AqvrPNqlVMpW9YEL2LIVY+Rpv2qsjCGxaDLNRgeGsQWJhfItebuJhsGSLjaBbNSQ+ieg==} + stringify-object@5.0.0: resolution: {integrity: sha512-zaJYxz2FtcMb4f+g60KsRNFOpVMUyuJgA51Zi5Z1DOTC3S59+OQiVOzE9GZt0x72uBGWKsQIuBKeF9iusmKFsg==} engines: {node: '>=14.16'} @@ -3227,6 +3375,9 @@ packages: resolution: {integrity: sha512-bLVMLPtstlZ4iMQHpFHTR7GAGj2jxi8Dg0s2h2MafAE4uSWF98FC/3MomU51iQAMf8/qDUbKWf5GxuvvVcXEhw==} engines: {node: '>=20'} + trim-lines@3.0.1: + resolution: {integrity: sha512-kRj8B+YHZCc9kQYdWfJB2/oUl9rA99qbowYYBtr4ui4mZyAQ2JpvVBd/6U2YloATfqBhBTSMhTpgBHtU0Mf3Rg==} + ts-morph@26.0.0: resolution: {integrity: sha512-ztMO++owQnz8c/gIENcM9XfCEzgoGphTv+nKpYNM1bgsdOVC/jRZuEBf6N+mLLDNg68Kl+GgUZfOySaRiG1/Ug==} @@ -3277,6 +3428,21 @@ packages: resolution: {integrity: sha512-+QBBXBCvifc56fsbuxZQ6Sic3wqqc3WWaqxs58gvJrcOuN83HGTCwz3oS5phzU9LthRNE9VrJCFCLUgHeeFnfA==} engines: {node: '>=18'} + unist-util-is@6.0.1: + resolution: {integrity: sha512-LsiILbtBETkDz8I9p1dQ0uyRUWuaQzd/cuEeS1hoRSyW5E5XGmTzlwY1OrNzzakGowI9Dr/I8HVaw4hTtnxy8g==} + + unist-util-position@5.0.0: + resolution: {integrity: sha512-fucsC7HjXvkB5R3kTCO7kUjRdrS0BJt3M/FPxmHMBOm8JQi2BsHAHFsy27E0EolP8rp0NzXsJ+jNPyDWvOJZPA==} + + unist-util-stringify-position@4.0.0: + resolution: {integrity: sha512-0ASV06AAoKCDkS2+xw5RXJywruurpbC4JZSm7nr7MOt1ojAzvyyaO+UxZf18j8FCF6kmzCZKcAgN/yu2gm2XgQ==} + + unist-util-visit-parents@6.0.2: + resolution: {integrity: sha512-goh1s1TBrqSqukSc8wrjwWhL0hiJxgA8m4kFxGlQ+8FYQ3C/m11FcTs4YYem7V664AhHVvgoQLk890Ssdsr2IQ==} + + unist-util-visit@5.1.0: + resolution: {integrity: sha512-m+vIdyeCOpdr/QeQCu2EzxX/ohgS8KbnPDgFni4dQsfSCtpz8UqDyY5GjRru8PDKuYn7Fq19j1CQ+nJSsGKOzg==} + universalify@2.0.1: resolution: {integrity: sha512-gptHNQghINnc/vTGIk0SOFGFNXw7JVrlRUtConJRlvaw6DuX0wO5Jeko9sWrMBhh+PsYAZ7oXAiOnf/UKogyiw==} engines: {node: '>= 10.0.0'} @@ -3388,6 +3554,12 @@ packages: resolution: {integrity: sha512-BNGbWLfd0eUPabhkXUVm0j8uuvREyTh5ovRa/dyow/BqAbZJyC+5fU+IzQOzmAKzYqYRAISoRhdQr3eIZ/PXqg==} engines: {node: '>= 0.8'} + vfile-message@4.0.3: + resolution: {integrity: sha512-QTHzsGd1EhbZs4AsQ20JX1rC3cOlt/IWJruk893DfLRr57lcnOeMaWG4K0JrRta4mIJZKth2Au3mM3u03/JWKw==} + + vfile@6.0.3: + resolution: {integrity: sha512-KzIbH/9tXat2u30jf+smMwFCsno4wHVdNmzFyL+T/L3UGqqk6JKfVqOFOZEpZSHADH1k40ab6NUIXZq422ov3Q==} + vite@8.0.13: resolution: {integrity: sha512-MFtjBYgzmSxmgA4RAfjIyXWpGe1oALnjgUTzzV7QLx/TKxCzjtMH6Fd9/eVK+5Fg1qNoz5VAwsmMs/NofrmJvw==} engines: {node: ^20.19.0 || >=22.12.0} @@ -3598,6 +3770,9 @@ packages: zod@4.4.3: resolution: {integrity: sha512-ytENFjIJFl2UwYglde2jchW2Hwm4GJFLDiSXWdTrJQBIN9Fcyp7n4DhxJEiWNAJMV1/BqWfW/kkg71UDcHJyTQ==} + zwitch@2.0.4: + resolution: {integrity: sha512-bXE4cR/kVZhKZX/RjPEflHaKVhUVl85noU3v6b8apfQEc1x4A+zBxjZ4lN8LqGd6WZ3dl98pY4o717VFmoPp+A==} + snapshots: '@asamuzakjp/css-color@5.1.11': @@ -3931,6 +4106,8 @@ snapshots: '@floating-ui/utils@0.2.11': {} + '@fontsource-variable/geist-mono@5.2.8': {} + '@fontsource-variable/geist@5.2.9': {} '@hono/node-server@1.19.14(hono@4.12.21)': @@ -4252,6 +4429,13 @@ snapshots: '@oxlint/binding-win32-x64-msvc@1.66.0': optional: true + '@pierre/trees@1.0.0-beta.4(react-dom@19.2.6(react@19.2.6))(react@19.2.6)': + dependencies: + preact: 11.0.0-beta.0 + preact-render-to-string: 6.6.5(preact@11.0.0-beta.0) + react: 19.2.6 + react-dom: 19.2.6(react@19.2.6) + '@posthog/core@1.29.6': dependencies: '@posthog/types': 1.374.3 @@ -4311,6 +4495,46 @@ snapshots: '@sec-ant/readable-stream@0.4.1': {} + '@shikijs/core@4.1.0': + dependencies: + '@shikijs/primitive': 4.1.0 + '@shikijs/types': 4.1.0 + '@shikijs/vscode-textmate': 10.0.2 + '@types/hast': 3.0.4 + hast-util-to-html: 9.0.5 + + '@shikijs/engine-javascript@4.1.0': + dependencies: + '@shikijs/types': 4.1.0 + '@shikijs/vscode-textmate': 10.0.2 + oniguruma-to-es: 4.3.6 + + '@shikijs/engine-oniguruma@4.1.0': + dependencies: + '@shikijs/types': 4.1.0 + '@shikijs/vscode-textmate': 10.0.2 + + '@shikijs/langs@4.1.0': + dependencies: + '@shikijs/types': 4.1.0 + + '@shikijs/primitive@4.1.0': + dependencies: + '@shikijs/types': 4.1.0 + '@shikijs/vscode-textmate': 10.0.2 + '@types/hast': 3.0.4 + + '@shikijs/themes@4.1.0': + dependencies: + '@shikijs/types': 4.1.0 + + '@shikijs/types@4.1.0': + dependencies: + '@shikijs/vscode-textmate': 10.0.2 + '@types/hast': 3.0.4 + + '@shikijs/vscode-textmate@10.0.2': {} + '@sindresorhus/merge-streams@4.0.0': {} '@solid-primitives/event-listener@2.4.5(solid-js@1.9.13)': @@ -4805,6 +5029,14 @@ snapshots: '@types/estree@1.0.9': {} + '@types/hast@3.0.4': + dependencies: + '@types/unist': 3.0.3 + + '@types/mdast@4.0.4': + dependencies: + '@types/unist': 3.0.3 + '@types/node@25.9.1': dependencies: undici-types: 7.24.6 @@ -4825,8 +5057,12 @@ snapshots: '@types/statuses@2.0.6': {} + '@types/unist@3.0.3': {} + '@types/validate-npm-package-name@4.0.2': {} + '@ungap/structured-clone@1.3.1': {} + '@vitejs/plugin-react@6.0.2(vite@8.0.13(@types/node@25.9.1)(jiti@2.7.0))': dependencies: '@rolldown/pluginutils': 1.0.1 @@ -4989,10 +5225,16 @@ snapshots: caniuse-lite@1.0.30001793: {} + ccount@2.0.1: {} + chai@6.2.2: {} chalk@5.6.2: {} + character-entities-html4@2.1.0: {} + + character-entities-legacy@3.0.0: {} + cheerio-select@2.1.0: dependencies: boolbase: 1.0.0 @@ -5048,6 +5290,8 @@ snapshots: color-name@1.1.4: {} + comma-separated-tokens@2.0.3: {} + commander@11.1.0: {} commander@14.0.3: {} @@ -5151,6 +5395,10 @@ snapshots: detect-libc@2.1.2: {} + devlop@1.1.0: + dependencies: + dequal: 2.0.3 + diff@8.0.4: {} dom-accessibility-api@0.5.16: {} @@ -5463,6 +5711,24 @@ snapshots: dependencies: function-bind: 1.1.2 + hast-util-to-html@9.0.5: + dependencies: + '@types/hast': 3.0.4 + '@types/unist': 3.0.3 + ccount: 2.0.1 + comma-separated-tokens: 2.0.3 + hast-util-whitespace: 3.0.0 + html-void-elements: 3.0.0 + mdast-util-to-hast: 13.2.1 + property-information: 7.1.0 + space-separated-tokens: 2.0.2 + stringify-entities: 4.0.4 + zwitch: 2.0.4 + + hast-util-whitespace@3.0.0: + dependencies: + '@types/hast': 3.0.4 + headers-polyfill@5.0.1: dependencies: '@types/set-cookie-parser': 2.4.10 @@ -5478,6 +5744,8 @@ snapshots: transitivePeerDependencies: - '@noble/hashes' + html-void-elements@3.0.0: {} + htmlparser2@10.1.0: dependencies: domelementtype: 2.3.0 @@ -5710,6 +5978,18 @@ snapshots: math-intrinsics@1.1.0: {} + mdast-util-to-hast@13.2.1: + dependencies: + '@types/hast': 3.0.4 + '@types/mdast': 4.0.4 + '@ungap/structured-clone': 1.3.1 + devlop: 1.1.0 + micromark-util-sanitize-uri: 2.0.1 + trim-lines: 3.0.1 + unist-util-position: 5.0.0 + unist-util-visit: 5.1.0 + vfile: 6.0.3 + mdn-data@2.27.1: {} media-typer@1.1.0: {} @@ -5720,6 +6000,23 @@ snapshots: merge2@1.4.1: {} + micromark-util-character@2.1.1: + dependencies: + micromark-util-symbol: 2.0.1 + micromark-util-types: 2.0.2 + + micromark-util-encode@2.0.1: {} + + micromark-util-sanitize-uri@2.0.1: + dependencies: + micromark-util-character: 2.1.1 + micromark-util-encode: 2.0.1 + micromark-util-symbol: 2.0.1 + + micromark-util-symbol@2.0.1: {} + + micromark-util-types@2.0.2: {} + micromatch@4.0.8: dependencies: braces: 3.0.3 @@ -5884,6 +6181,14 @@ snapshots: dependencies: mimic-function: 5.0.1 + oniguruma-parser@0.12.2: {} + + oniguruma-to-es@4.3.6: + dependencies: + oniguruma-parser: 0.12.2 + regex: 6.1.0 + regex-recursion: 6.0.2 + open@11.0.0: dependencies: default-browser: 5.5.0 @@ -6050,6 +6355,12 @@ snapshots: powershell-utils@0.1.0: {} + preact-render-to-string@6.6.5(preact@11.0.0-beta.0): + dependencies: + preact: 11.0.0-beta.0 + + preact@11.0.0-beta.0: {} + prettier@3.8.3: {} pretty-format@27.5.1: @@ -6067,6 +6378,8 @@ snapshots: kleur: 3.0.3 sisteransi: 1.0.5 + property-information@7.1.0: {} + proxy-addr@2.0.7: dependencies: forwarded: 0.2.0 @@ -6108,6 +6421,16 @@ snapshots: tiny-invariant: 1.3.3 tslib: 2.8.1 + regex-recursion@6.0.2: + dependencies: + regex-utilities: 2.3.0 + + regex-utilities@2.3.0: {} + + regex@6.1.0: + dependencies: + regex-utilities: 2.3.0 + require-directory@2.1.1: {} require-from-string@2.0.2: {} @@ -6262,6 +6585,17 @@ snapshots: shell-quote@1.8.3: {} + shiki@4.1.0: + dependencies: + '@shikijs/core': 4.1.0 + '@shikijs/engine-javascript': 4.1.0 + '@shikijs/engine-oniguruma': 4.1.0 + '@shikijs/langs': 4.1.0 + '@shikijs/themes': 4.1.0 + '@shikijs/types': 4.1.0 + '@shikijs/vscode-textmate': 10.0.2 + '@types/hast': 3.0.4 + side-channel-list@1.0.1: dependencies: es-errors: 1.3.0 @@ -6304,12 +6638,19 @@ snapshots: seroval: 1.5.4 seroval-plugins: 1.5.4(seroval@1.5.4) + sonner@2.0.7(react-dom@19.2.6(react@19.2.6))(react@19.2.6): + dependencies: + react: 19.2.6 + react-dom: 19.2.6(react@19.2.6) + source-map-js@1.2.1: {} source-map@0.6.1: {} source-map@0.7.6: {} + space-separated-tokens@2.0.2: {} + srvx@0.11.15: {} stackback@0.0.2: {} @@ -6334,6 +6675,11 @@ snapshots: get-east-asian-width: 1.6.0 strip-ansi: 7.2.0 + stringify-entities@4.0.4: + dependencies: + character-entities-html4: 2.1.0 + character-entities-legacy: 3.0.0 + stringify-object@5.0.0: dependencies: get-own-enumerable-keys: 1.0.0 @@ -6399,6 +6745,8 @@ snapshots: dependencies: punycode: 2.3.1 + trim-lines@3.0.1: {} + ts-morph@26.0.0: dependencies: '@ts-morph/common': 0.27.0 @@ -6452,6 +6800,29 @@ snapshots: unicorn-magic@0.3.0: {} + unist-util-is@6.0.1: + dependencies: + '@types/unist': 3.0.3 + + unist-util-position@5.0.0: + dependencies: + '@types/unist': 3.0.3 + + unist-util-stringify-position@4.0.0: + dependencies: + '@types/unist': 3.0.3 + + unist-util-visit-parents@6.0.2: + dependencies: + '@types/unist': 3.0.3 + unist-util-is: 6.0.1 + + unist-util-visit@5.1.0: + dependencies: + '@types/unist': 3.0.3 + unist-util-is: 6.0.1 + unist-util-visit-parents: 6.0.2 + universalify@2.0.1: {} unpipe@1.0.0: {} @@ -6487,6 +6858,16 @@ snapshots: vary@1.1.2: {} + vfile-message@4.0.3: + dependencies: + '@types/unist': 3.0.3 + unist-util-stringify-position: 4.0.0 + + vfile@6.0.3: + dependencies: + '@types/unist': 3.0.3 + vfile-message: 4.0.3 + vite@8.0.13(@types/node@25.9.1)(jiti@2.7.0): dependencies: lightningcss: 1.32.0 @@ -6627,3 +7008,5 @@ snapshots: zod@3.25.76: {} zod@4.4.3: {} + + zwitch@2.0.4: {} diff --git a/registry/modules/auth-better-auth/logo-dark.svg b/registry/modules/auth-better-auth/logo-dark.svg new file mode 100644 index 0000000..f0dee60 --- /dev/null +++ b/registry/modules/auth-better-auth/logo-dark.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/registry/modules/auth-better-auth/logo-light.svg b/registry/modules/auth-better-auth/logo-light.svg new file mode 100644 index 0000000..731a425 --- /dev/null +++ b/registry/modules/auth-better-auth/logo-light.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/registry/modules/auth-clerk/logo-dark.svg b/registry/modules/auth-clerk/logo-dark.svg new file mode 100644 index 0000000..4410df4 --- /dev/null +++ b/registry/modules/auth-clerk/logo-dark.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/registry/modules/auth-clerk/logo-light.svg b/registry/modules/auth-clerk/logo-light.svg new file mode 100644 index 0000000..468b611 --- /dev/null +++ b/registry/modules/auth-clerk/logo-light.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/registry/modules/db-postgres/logo.svg b/registry/modules/db-postgres/logo.svg new file mode 100644 index 0000000..375e3d2 --- /dev/null +++ b/registry/modules/db-postgres/logo.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/registry/modules/db-sqlite/logo.svg b/registry/modules/db-sqlite/logo.svg new file mode 100644 index 0000000..7e21294 --- /dev/null +++ b/registry/modules/db-sqlite/logo.svg @@ -0,0 +1,13 @@ + + + + + + + + + + + + + diff --git a/registry/modules/framework-next/logo.svg b/registry/modules/framework-next/logo.svg new file mode 100644 index 0000000..ff82d0b --- /dev/null +++ b/registry/modules/framework-next/logo.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/registry/modules/framework-tanstack-start/logo.svg b/registry/modules/framework-tanstack-start/logo.svg new file mode 100644 index 0000000..9c596b7 --- /dev/null +++ b/registry/modules/framework-tanstack-start/logo.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/registry/modules/orm-drizzle/logo-dark.svg b/registry/modules/orm-drizzle/logo-dark.svg new file mode 100644 index 0000000..d9b8730 --- /dev/null +++ b/registry/modules/orm-drizzle/logo-dark.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/registry/modules/orm-drizzle/logo-light.svg b/registry/modules/orm-drizzle/logo-light.svg new file mode 100644 index 0000000..1d7ced1 --- /dev/null +++ b/registry/modules/orm-drizzle/logo-light.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/registry/modules/orm-prisma/logo-dark.svg b/registry/modules/orm-prisma/logo-dark.svg new file mode 100644 index 0000000..f1679b9 --- /dev/null +++ b/registry/modules/orm-prisma/logo-dark.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/registry/modules/orm-prisma/logo-light.svg b/registry/modules/orm-prisma/logo-light.svg new file mode 100644 index 0000000..3210efa --- /dev/null +++ b/registry/modules/orm-prisma/logo-light.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/registry/modules/styling-tailwind/logo.svg b/registry/modules/styling-tailwind/logo.svg new file mode 100644 index 0000000..4e8cbb0 --- /dev/null +++ b/registry/modules/styling-tailwind/logo.svg @@ -0,0 +1 @@ + \ No newline at end of file