feat: add module detail pages, ⌘K search, dynamic OG images, sitemap, and SEO groundwork for apps/web

- Add `/m/$slot/$id` route (`apps/web/src/routes/m.$slot.$id.tsx`) with `module-detail.ts` server function — full adapter switcher (peer chip rows), deps/devDeps/scripts table, env-var table, templates list with click-to-expand Shiki preview, and a "Try it" copy-command bar; new detail components land under `apps/web/src/components/detail/`
- Add ⌘K search popover (`apps/web/src/components/search/site-search.tsx`) backed by `apps/web/src/lib/module-search.ts` — fuzzy-matches id/label/description/slot exactly as the CLI does; wired into the header
- Add dynamic OG image generation via `@vercel/og` + Satori: `server/routes/og/[slot]/[id].ts` renders a per-module card, `server/routes/og.ts` renders the default fallback; both served as Nitro route handlers with appropriate CDN cache headers; OG React components in `apps/web/src/server/og-card.tsx`
- Add `server/routes/sitemap.xml.ts` (enumerates `/` + all `/m/$slot/$id` URLs from registry index) and `public/robots.txt` pointing at it
- Add `apps/web/src/lib/seo.ts` — `buildHead()` helper that emits `title`, `og:*`, `twitter:*`, and `canonical` tags; consumed by `__root.tsx`, `index.tsx`, and `m.$slot.$id.tsx`
- Extract `ModuleLogo` into `apps/web/src/components/module-logo.tsx` — shared between slot-cards and the new detail page; remove the duplicate from `slot-cards.tsx`
- Add new shadcn-style UI primitives: `command.tsx` (cmdk wrapper), `dialog.tsx`, `input-group.tsx`, `kbd.tsx`, `popover.tsx`, `textarea.tsx`
- Add `apps/web/src/server/registry-base.ts` (filesystem reads to avoid SSR loopback-fetch deadlock), `registry-index.ts`, and `module-detail.ts` as the server-side data layer
- Add `@vercel/og` and `cmdk` to `apps/web` dependencies; add `vitest run` as `pnpm test` in web package
- Mark all corresponding TODO items done
This commit is contained in:
jake committed 2026-05-20 19:36:34 -04:00
1 parent ad622c8579
commit 5ca679cf32
34 files changed
+2782 -100

No files matched your search

+15 -15
View File
@@ -13,21 +13,21 @@ State at end of last session: pre-1.0 architectural cleanup landed.
The builder is functionally wired but visually unfinished. It's inline-styled radios + a code block. The builder is functionally wired but visually unfinished. It's inline-styled radios + a code block.
- [ ] Add Tailwind 4 to `apps/web` — use the `@tailwindcss/vite` plugin (mirror what the `styling-tailwind` module emits for the `tanstack-start` adapter) - [x] Add Tailwind 4 to `apps/web` — `@tailwindcss/vite` v4 wired in `vite.config.ts`, theme variables in `styles.css` via `@theme` (OKLCH), dark mode via `.dark` class
- [ ] Hand-roll a small component layer or pull in shadcn (web variant) for cards, buttons, inputs, code blocks - [x] Hand-roll a small component layer or pull in shadcn (web variant) — shadcn-style primitives at `apps/web/src/components/ui/` (Card, Button, Input, Badge, Tooltip, Tabs, DropdownMenu, Separator, Sonner). All use base-ui under the hood
- [ ] Replace the radio-list `Builder` with a card grid per slot, showing module label + description + homepage link - [x] Replace the radio-list `Builder` with a card grid per slot — `apps/web/src/components/builder/slot-cards.tsx`
- [ ] Show _why_ a module is filtered out (e.g. "needs `framework: next`, you picked tanstack-start") instead of just dropping it - [ ] Show _why_ a module is filtered out (e.g. "needs `framework: next`, you picked tanstack-start") instead of just dropping it — partly covered by the tooltip in `slot-cards.tsx`, could expand to inline why-disabled copy on the card itself
- [ ] Deep linking: encode selections in URL search params (`?framework=next&orm=drizzle&...`) so users can share configurations - [x] Deep linking: encode selections in URL search params (`?framework=next&orm=drizzle&...`) — typed `validateSearch` on `routes/index.tsx`, parser in `lib/selection.ts`
- [ ] Copy-to-clipboard for the generated `pnpm create stanza ...` command - [x] Copy-to-clipboard for the generated `pnpm create stanza ...` command — `command-bar.tsx`
- [ ] Preview pane: list of files stanza will write (derived from `module.adapters[].templates[].dest` for the selected adapter combo) - [x] Preview pane: list of files stanza will write — `file-preview.tsx` with `@pierre/trees` + Shiki-rendered server-side preview
- [ ] Show pinned npm versions per selected module (from `adapter.dependencies` / `adapter.devDependencies`) - [x] Show pinned npm versions per selected module — `apps/web/src/components/detail/deps-table.tsx` on `/m/$slot/$id` (also covers devDeps + scripts + env). Builder cards stay clean per the design call; versions surface on the detail page
- [ ] Module detail route at `/m/$slot/$id` — full description, all adapters, deps, env vars, templates list - [x] Module detail route at `/m/$slot/$id` — full description, adapter switcher (peer chip rows), deps tables, env table, templates list with click-to-expand Shiki preview, "Try it" command. `apps/web/src/routes/m.$slot.$id.tsx`
- [ ] Search route at `/search` backed by the same registry index - [x] Search route — implemented as a header-only popover (`apps/web/src/components/search/site-search.tsx`) bound to ⌘K, mirrors the CLI's id/label/description/slot match (`lib/module-search.ts`)
- [ ] Layout: header (logo, GitHub link, docs link), footer - [x] Layout: header (logo, GitHub link, search), footer — `apps/web/src/components/header.tsx`, `footer.tsx`
- [ ] Dark mode (Tailwind 4 `light-dark()` + system pref) - [x] Dark mode — Tailwind 4 `.dark` variant + ThemeProvider, system pref aware
- [ ] SEO: meta tags via `head()` on routes, OG image, sitemap - [x] SEO: meta tags via `head()` on routes, OG image, sitemap — `apps/web/src/lib/seo.ts` builds `head()` output with title/og:\*/twitter:\*/canonical for every route. Dynamic OG via `@vercel/og` at `/og/$slot/$id` and `/og` (Nitro server routes under `apps/web/server/routes/`). `sitemap.xml` server route enumerates the index; `public/robots.txt` is static
- [ ] Host the registry on the same domain — wire the registry-build output (`scripts/registry-build.ts` → `dist/registry/`) to `apps/web/public/registry/` and serve statically, or via a Vercel route handler - [x] Host the registry on the same domain — `prebuild` script copies `dist/registry/` into `apps/web/public/registry/`. CLI's `DEFAULT_REGISTRY_URL` points at the same path. Server-side reads use the filesystem directly (`apps/web/src/server/registry-base.ts`) to avoid the SSR loopback-fetch deadlock
- [ ] Vercel deploy config — `vercel.json` if needed, env vars for `STANZA_REGISTRY` (point dev at local FS, prod at the deployed CDN path) - [ ] Vercel deploy config — Vercel auto-detects TanStack Start's `.output/` directory, no `vercel.json` needed. Env vars (`STANZA_REGISTRY`) optional override
- [ ] Docs section (could be MDX routes): overview, authoring guide, registry spec - [ ] Docs section (could be MDX routes): overview, authoring guide, registry spec
## CLI (apps/cli) ## CLI (apps/cli)
+4 -1
View File
@@ -9,7 +9,8 @@
"build": "vite build", "build": "vite build",
"prebuild": "./scripts/copy-registry.sh", "prebuild": "./scripts/copy-registry.sh",
"start": "node .output/server/index.mjs", "start": "node .output/server/index.mjs",
"check-types": "tsc --noEmit" "check-types": "tsc --noEmit",
"test": "vitest run"
}, },
"dependencies": { "dependencies": {
"@base-ui/react": "^1.5.0", "@base-ui/react": "^1.5.0",
@@ -25,8 +26,10 @@
"@tanstack/react-router-ssr-query": "^1.167.0", "@tanstack/react-router-ssr-query": "^1.167.0",
"@tanstack/react-start": "^1.168.7", "@tanstack/react-start": "^1.168.7",
"@tanstack/router-plugin": "^1.168.7", "@tanstack/router-plugin": "^1.168.7",
"@vercel/og": "^0.11.1",
"class-variance-authority": "^0.7.1", "class-variance-authority": "^0.7.1",
"clsx": "^2.1.1", "clsx": "^2.1.1",
"cmdk": "^1.1.1",
"nitro": "^3.0.260429-beta", "nitro": "^3.0.260429-beta",
"react": "^19.2.6", "react": "^19.2.6",
"react-dom": "^19.2.6", "react-dom": "^19.2.6",
+4
View File
@@ -0,0 +1,4 @@
User-agent: *
Allow: /
Sitemap: https://stanza.tools/sitemap.xml
+19
View File
@@ -0,0 +1,19 @@
import { ImageResponse } from "@vercel/og";
import { defineHandler } from "nitro/h3";
import { OgDefault } from "@/server/og-card";
/**
* `/og` — default OG used by `/`, `/search`, and anywhere without a more
* specific image. Static-ish content; the SWR header lets the CDN serve it
* stale for a long time.
*/
export default defineHandler(() => {
return new ImageResponse(OgDefault(), {
width: 1200,
height: 630,
headers: {
"cache-control": "public, max-age=86400, s-maxage=604800, stale-while-revalidate=604800",
},
});
});
+39
View File
@@ -0,0 +1,39 @@
import type { RegistryIndex } from "@stanza/registry";
import { ImageResponse } from "@vercel/og";
import { defineHandler, getRouterParams } from "nitro/h3";
import { OgCard } from "@/server/og-card";
import { loadRegistryFile } from "@/server/registry-base";
/**
* `/og/$slot/$id` — per-module OG card. Dynamically rendered at request time
* via Satori (bundled inside `@vercel/og`). Looks the module up in the
* registry index and bails 404 if it's not there.
*/
export default defineHandler(async (event) => {
const params = getRouterParams(event);
const slot = String(params.slot ?? "");
const id = String(params.id ?? "").replace(/\.png$/, "");
if (!slot || !id) {
return new Response("Not found", { status: 404 });
}
let index: RegistryIndex;
try {
index = await loadRegistryFile<RegistryIndex>("index.json");
} catch {
return new Response("Registry unavailable", { status: 502 });
}
const summary = index.modules.find((m) => m.slot === slot && m.id === id);
if (!summary) {
return new Response("Not found", { status: 404 });
}
return new ImageResponse(OgCard({ summary }), {
width: 1200,
height: 630,
headers: {
"cache-control": "public, max-age=3600, s-maxage=86400, stale-while-revalidate=86400",
},
});
});
+54
View File
@@ -0,0 +1,54 @@
import type { RegistryIndex } from "@stanza/registry";
import type { H3Event } from "nitro/h3";
import { defineHandler, getRequestHost, getRequestProtocol } from "nitro/h3";
import { loadRegistryFile } from "@/server/registry-base";
/**
* `/sitemap.xml` — enumerates the home plus every module detail page. Search
* params (peer overrides) are intentionally excluded: the canonical URL for a
* module is the bare `/m/$slot/$id`, and the page renders an auto-default
* adapter from there.
*/
export default defineHandler(async (event) => {
const origin = originFromEvent(event);
let index: RegistryIndex;
try {
index = await loadRegistryFile<RegistryIndex>("index.json");
} catch {
return new Response("Registry unavailable", { status: 502 });
}
const urls = [
{ loc: `${origin}/`, changefreq: "weekly", priority: "1.0" },
...index.modules.map((m) => ({
loc: `${origin}/m/${m.slot}/${m.id}`,
changefreq: "monthly",
priority: "0.7",
})),
];
const body = `<?xml version="1.0" encoding="UTF-8"?>
<urlset xmlns="http://www.sitemaps.org/schemas/sitemap/0.9">
${urls
.map(
(u) =>
` <url><loc>${u.loc}</loc><changefreq>${u.changefreq}</changefreq><priority>${u.priority}</priority></url>`,
)
.join("\n")}
</urlset>
`;
return new Response(body, {
headers: {
"content-type": "application/xml; charset=utf-8",
"cache-control": "public, max-age=3600, s-maxage=86400",
},
});
});
function originFromEvent(event: H3Event): string {
const host = getRequestHost(event);
const proto = getRequestProtocol(event);
return `${proto}://${host}`;
}
+2 -41
View File
@@ -1,7 +1,8 @@
import type { Logo, Module, ModuleSummary, SlotId } from "@stanza/registry"; import type { Module, ModuleSummary, SlotId } from "@stanza/registry";
import { KNOWN_SLOTS, emptyManifest, resolveAdapter } from "@stanza/registry"; import { KNOWN_SLOTS, emptyManifest, resolveAdapter } from "@stanza/registry";
import { IconCheck } from "@tabler/icons-react"; import { IconCheck } from "@tabler/icons-react";
import { ModuleLogo } from "@/components/module-logo";
import { Tooltip, TooltipContent, TooltipTrigger } from "@/components/ui/tooltip"; import { Tooltip, TooltipContent, TooltipTrigger } from "@/components/ui/tooltip";
import type { Selections } from "@/lib/selection"; import type { Selections } from "@/lib/selection";
import { cn } from "@/lib/utils"; import { cn } from "@/lib/utils";
@@ -158,46 +159,6 @@ function ModuleCard({
); );
} }
function ModuleLogo({ logo, label }: { logo: Logo | undefined; label: string }) {
if (!logo) {
// Fallback: a subtle initial-letter tile. Keeps card geometry consistent.
return (
<div
aria-hidden
className="flex size-9 shrink-0 items-center justify-center rounded-md border border-border bg-muted/40 text-xs font-semibold text-muted-foreground"
>
{label.slice(0, 1)}
</div>
);
}
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 (
<div
aria-hidden
className={wrapClass}
// Inlined SVG from our trusted first-party registry payload.
dangerouslySetInnerHTML={{ __html: logo }}
/>
);
}
return (
<>
<div
aria-hidden
className={cn(wrapClass, "dark:hidden")}
dangerouslySetInnerHTML={{ __html: logo.light }}
/>
<div
aria-hidden
className={cn(wrapClass, "hidden dark:flex")}
dangerouslySetInnerHTML={{ __html: logo.dark }}
/>
</>
);
}
function describeError(kind: string): string { function describeError(kind: string): string {
switch (kind) { switch (kind) {
case "missing-peer": case "missing-peer":
@@ -0,0 +1,73 @@
import type { ModuleSummary, RegistryIndex, SlotId } from "@stanza/registry";
import { slotLabel } from "@stanza/registry";
import { cn } from "@/lib/utils";
/**
* Chip-row picker for each peer slot the detail page can switch on. Hides any
* slot with fewer than 2 options — there's nothing to pick. The active value
* is read from `resolvedPeers` so the UI reflects auto-defaults too.
*/
export function AdapterSwitcher({
index,
peerOptions,
resolvedPeers,
onChange,
}: {
index: RegistryIndex;
peerOptions: Partial<Record<SlotId, string[]>>;
resolvedPeers: Partial<Record<SlotId, string>>;
onChange: (slot: SlotId, id: string) => void;
}) {
const switchable = (Object.entries(peerOptions) as [SlotId, string[]][]).filter(
([, opts]) => opts.length > 1,
);
if (switchable.length === 0) return null;
return (
<div className="space-y-3">
{switchable.map(([slot, options]) => {
const active = resolvedPeers[slot];
return (
<div key={slot} className="flex flex-wrap items-center gap-2">
<span className="w-20 shrink-0 text-xs font-medium text-muted-foreground">
{slotLabel(slot)}
</span>
<div className="flex flex-wrap gap-1.5">
{options.map((id) => {
const summary = index.modules.find((m) => m.slot === slot && m.id === id);
const label = summary?.label ?? id;
const isActive = active === id;
return (
<button
key={id}
type="button"
onClick={() => onChange(slot, id)}
aria-pressed={isActive}
className={cn(
"inline-flex items-center gap-1.5 rounded-none border px-2.5 py-1 text-xs transition-colors",
"focus-visible:ring-2 focus-visible:ring-ring focus-visible:outline-none",
isActive
? "border-foreground bg-foreground text-background"
: "border-border bg-background text-foreground hover:bg-muted",
)}
>
{label}
</button>
);
})}
</div>
</div>
);
})}
</div>
);
}
export function summaryFor(
index: RegistryIndex,
slot: SlotId,
id: string,
): ModuleSummary | undefined {
return index.modules.find((m) => m.slot === slot && m.id === id);
}
@@ -0,0 +1,28 @@
/**
* Renders a deps map as a key/value list. Used for `dependencies`,
* `devDependencies`, and `scripts`. Returns `null` when the map is empty so
* the page can simply drop the section.
*/
export function DepsTable({ title, entries }: { title: string; entries: Record<string, string> }) {
const items = Object.entries(entries);
if (items.length === 0) return null;
return (
<section>
<h3 className="mb-2 text-xs font-semibold tracking-tight text-muted-foreground uppercase">
{title}
</h3>
<ul className="divide-y divide-border rounded-none border border-border">
{items.map(([name, version]) => (
<li
key={name}
className="flex items-center justify-between gap-3 px-3 py-2 font-mono text-xs"
>
<span className="truncate text-foreground">{name}</span>
<span className="shrink-0 text-muted-foreground">{version}</span>
</li>
))}
</ul>
</section>
);
}
@@ -0,0 +1,28 @@
import type { EnvVar } from "@stanza/registry";
import { Badge } from "@/components/ui/badge";
export function EnvTable({ env }: { env: EnvVar[] }) {
if (env.length === 0) return null;
return (
<section>
<h3 className="mb-2 text-xs font-semibold tracking-tight text-muted-foreground uppercase">
Environment variables
</h3>
<ul className="divide-y divide-border rounded-none border border-border">
{env.map((e) => (
<li key={e.name} className="grid gap-1 px-3 py-2.5 text-xs">
<div className="flex flex-wrap items-center gap-2">
<span className="font-mono font-medium text-foreground">{e.name}</span>
<Badge variant={e.required ? "default" : "outline"}>
{e.required ? "required" : "optional"}
</Badge>
<span className="font-mono text-muted-foreground/70">= {e.example}</span>
</div>
{e.description && <p className="text-muted-foreground/80">{e.description}</p>}
</li>
))}
</ul>
</section>
);
}
@@ -0,0 +1,87 @@
import type { TemplateRef } from "@stanza/registry";
import { useMemo, useState } from "react";
import { useTheme } from "@/components/theme-provider";
import type { Preview } from "@/server/highlighter";
/**
* Lists the templates an adapter ships, with click-to-expand Shiki previews.
* Previews are pre-rendered server-side and keyed by `dest`.
*/
export function TemplatesList({
templates,
previews,
}: {
templates: TemplateRef[];
previews: Record<string, Preview>;
}) {
if (templates.length === 0) return null;
return (
<section>
<h3 className="mb-2 text-xs font-semibold tracking-tight text-muted-foreground uppercase">
Templates
</h3>
<ul className="divide-y divide-border rounded-none border border-border">
{templates.map((tpl) => (
<TemplateRow key={tpl.dest} template={tpl} preview={previews[tpl.dest]} />
))}
</ul>
</section>
);
}
function TemplateRow({
template,
preview,
}: {
template: TemplateRef;
preview: Preview | undefined;
}) {
const [open, setOpen] = useState(false);
const hasPreview = Boolean(preview);
return (
<li>
<button
type="button"
onClick={() => hasPreview && setOpen((o) => !o)}
disabled={!hasPreview}
className="flex w-full items-center justify-between gap-3 px-3 py-2 text-left font-mono text-xs transition-colors hover:bg-muted/40 disabled:cursor-default disabled:hover:bg-transparent"
aria-expanded={open}
>
<span className="truncate text-foreground">{template.dest}</span>
<span className="shrink-0 text-[10px] tracking-wider text-muted-foreground/60 uppercase">
{scopeLabel(template.scope)}
</span>
</button>
{open && preview && <PreviewBlock preview={preview} />}
</li>
);
}
function PreviewBlock({ preview }: { preview: Preview }) {
const { theme } = useTheme();
const resolved = useResolvedTheme(theme);
const html = useMemo(
() => (resolved === "dark" ? preview.dark : preview.light),
[preview, resolved],
);
return (
<div
className="overflow-auto border-t border-border text-xs leading-relaxed [&_pre]:bg-transparent! [&_pre]:p-4!"
dangerouslySetInnerHTML={{ __html: html }}
/>
);
}
function scopeLabel(scope: TemplateRef["scope"]): string {
if (scope === "repo") return "repo";
if (scope === "package") return "package";
return "app";
}
function useResolvedTheme(theme: string): "light" | "dark" {
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";
}
+39
View File
@@ -0,0 +1,39 @@
import { IconCopy } from "@tabler/icons-react";
import { toast } from "sonner";
import { Button } from "@/components/ui/button";
import { Tooltip, TooltipContent, TooltipTrigger } from "@/components/ui/tooltip";
export function TryIt({ command }: { command: string }) {
const onCopy = async () => {
try {
await navigator.clipboard.writeText(command);
toast.success("Copied to clipboard");
} catch {
toast.error("Couldn't access the clipboard");
}
};
return (
<section>
<h3 className="mb-2 text-xs font-semibold tracking-tight text-muted-foreground uppercase">
Try it
</h3>
<div className="flex items-stretch gap-2">
<pre className="flex-1 overflow-x-auto rounded-none border border-border bg-muted/50 px-3 py-2 font-mono text-xs">
<code>{command}</code>
</pre>
<Tooltip>
<TooltipTrigger
render={
<Button variant="outline" size="icon" onClick={onCopy} aria-label="Copy command">
<IconCopy className="size-4" />
</Button>
}
/>
<TooltipContent>Copy</TooltipContent>
</Tooltip>
</div>
</section>
);
}
+10 -4
View File
@@ -1,17 +1,23 @@
import { IconBrandGithub } from "@tabler/icons-react"; import { IconBrandGithub } from "@tabler/icons-react";
import { Link } from "@tanstack/react-router"; import { Link, useLoaderData } from "@tanstack/react-router";
import { ModeToggle } from "@/components/mode-toggle"; import { ModeToggle } from "@/components/mode-toggle";
import { SiteSearch } from "@/components/search/site-search";
import { Button } from "@/components/ui/button"; import { Button } from "@/components/ui/button";
export function Header() { export function Header() {
// Root loader stocks the registry index. `useLoaderData` reads it via the
// typed root-match path so the search popover has data without re-fetching.
const index = useLoaderData({ from: "__root__" });
return ( return (
<header className="sticky top-0 z-40 border-b border-border bg-background/80 backdrop-blur"> <header className="sticky top-0 z-40 border-b border-border bg-background/80 backdrop-blur">
<div className="mx-auto flex h-14 max-w-6xl items-center justify-between px-4 sm:px-6"> <div className="mx-auto flex h-14 max-w-6xl items-center justify-between gap-3 px-4 sm:px-6">
<Link to="/" className="font-semibold tracking-tight"> <Link to="/" className="font-semibold tracking-tight">
stanza stanza
</Link> </Link>
<nav className="flex items-center gap-1"> <div className="flex items-center gap-2">
<SiteSearch index={index} />
<Button <Button
render={ render={
<a <a
@@ -28,7 +34,7 @@ export function Header() {
<span className="hidden sm:inline">GitHub</span> <span className="hidden sm:inline">GitHub</span>
</Button> </Button>
<ModeToggle /> <ModeToggle />
</nav> </div>
</div> </div>
</header> </header>
); );
+62
View File
@@ -0,0 +1,62 @@
import type { Logo } from "@stanza/registry";
import { cn } from "@/lib/utils";
/**
* Renders an inline-SVG module logo, falling back to a single-letter tile.
* Used by the slot cards, header search results, and the module detail page.
* Logos come from our trusted first-party registry payload.
*/
export function ModuleLogo({
logo,
label,
size = "md",
}: {
logo: Logo | undefined;
label: string;
size?: "sm" | "md" | "lg";
}) {
const sizeClass =
size === "lg"
? "size-12 [&_svg]:size-9 [&_svg]:max-h-9 [&_svg]:max-w-9"
: size === "sm"
? "size-6 [&_svg]:size-4 [&_svg]:max-h-4 [&_svg]:max-w-4"
: "size-9 [&_svg]:size-7 [&_svg]:max-h-7 [&_svg]:max-w-7";
const textSize = size === "lg" ? "text-base" : size === "sm" ? "text-[10px]" : "text-xs";
if (!logo) {
return (
<div
aria-hidden
className={cn(
"flex shrink-0 items-center justify-center rounded-md border border-border bg-muted/40 font-semibold text-muted-foreground",
sizeClass,
textSize,
)}
>
{label.slice(0, 1)}
</div>
);
}
const wrapClass = cn("flex shrink-0 items-center justify-center", sizeClass);
if (typeof logo === "string") {
return <div aria-hidden className={wrapClass} dangerouslySetInnerHTML={{ __html: logo }} />;
}
return (
<>
<div
aria-hidden
className={cn(wrapClass, "dark:hidden")}
dangerouslySetInnerHTML={{ __html: logo.light }}
/>
<div
aria-hidden
className={cn(wrapClass, "hidden dark:flex")}
dangerouslySetInnerHTML={{ __html: logo.dark }}
/>
</>
);
}
@@ -0,0 +1,91 @@
import type { ModuleSummary, RegistryIndex, SlotId } from "@stanza/registry";
import { slotLabel } from "@stanza/registry";
import { IconSearch } from "@tabler/icons-react";
import { useNavigate } from "@tanstack/react-router";
import { useEffect, useMemo, useState } from "react";
import { ModuleLogo } from "@/components/module-logo";
import {
CommandDialog,
CommandEmpty,
CommandGroup,
CommandInput,
CommandItem,
CommandList,
} from "@/components/ui/command";
import { groupBySlot } from "@/lib/module-search";
import { cn } from "@/lib/utils";
/**
* Global module search. Triggered by a search-input-looking button in the
* header and by `⌘K` / `Ctrl-K`. cmdk filters the registry index client-side,
* groups by slot, and navigates to `/m/$slot/$id` on selection.
*/
export function SiteSearch({ index }: { index: RegistryIndex }) {
const [open, setOpen] = useState(false);
const navigate = useNavigate();
useEffect(() => {
function onKey(e: KeyboardEvent) {
if ((e.metaKey || e.ctrlKey) && e.key.toLowerCase() === "k") {
e.preventDefault();
setOpen((o) => !o);
}
}
window.addEventListener("keydown", onKey);
return () => window.removeEventListener("keydown", onKey);
}, []);
const groups = useMemo(() => groupBySlot(index.modules), [index.modules]);
function go(summary: ModuleSummary) {
setOpen(false);
void navigate({ to: "/m/$slot/$id", params: { slot: summary.slot, id: summary.id } });
}
return (
<>
<button
type="button"
onClick={() => setOpen(true)}
className={cn(
"inline-flex h-8 items-center gap-2 rounded-none border border-border bg-background px-2.5 text-xs text-muted-foreground transition-colors",
"hover:bg-muted hover:text-foreground focus-visible:ring-2 focus-visible:ring-ring focus-visible:outline-none",
"min-w-[180px]",
)}
>
<IconSearch className="size-3.5" />
<span className="flex-1 text-left">Search modules…</span>
<kbd className="hidden font-mono text-[10px] text-muted-foreground/60 sm:inline">⌘K</kbd>
</button>
<CommandDialog open={open} onOpenChange={setOpen}>
<CommandInput placeholder="Search modules…" />
<CommandList>
<CommandEmpty>No matches.</CommandEmpty>
{groups.map(({ slot, modules }) => (
<CommandGroup key={slot} heading={slotLabel(slot as SlotId)}>
{modules.map((m) => (
<CommandItem
key={`${m.slot}:${m.id}`}
value={`${m.slot} ${m.id} ${m.label} ${m.description}`}
onSelect={() => go(m)}
>
<ModuleLogo logo={m.logo} label={m.label} size="sm" />
<div className="min-w-0 flex-1">
<div className="truncate font-medium">{m.label}</div>
<div className="truncate text-[11px] text-muted-foreground">
{m.description}
</div>
</div>
<span className="shrink-0 font-mono text-[10px] text-muted-foreground/60">
{m.slot}/{m.id}
</span>
</CommandItem>
))}
</CommandGroup>
))}
</CommandList>
</CommandDialog>
</>
);
}
+181
View File
@@ -0,0 +1,181 @@
"use client";
import { IconSearch, IconCheck } from "@tabler/icons-react";
import { Command as CommandPrimitive } from "cmdk";
import * as React from "react";
import {
Dialog,
DialogContent,
DialogDescription,
DialogHeader,
DialogTitle,
} from "@/components/ui/dialog";
import { InputGroup, InputGroupAddon } from "@/components/ui/input-group";
import { cn } from "@/lib/utils";
function Command({ className, ...props }: React.ComponentProps<typeof CommandPrimitive>) {
return (
<CommandPrimitive
data-slot="command"
className={cn(
"flex size-full flex-col overflow-hidden rounded-none bg-popover text-popover-foreground",
className,
)}
{...props}
/>
);
}
function CommandDialog({
title = "Command Palette",
description = "Search for a command to run...",
children,
className,
showCloseButton = false,
...props
}: Omit<React.ComponentProps<typeof Dialog>, "children"> & {
title?: string;
description?: string;
className?: string;
showCloseButton?: boolean;
children: React.ReactNode;
}) {
return (
<Dialog {...props}>
<DialogHeader className="sr-only">
<DialogTitle>{title}</DialogTitle>
<DialogDescription>{description}</DialogDescription>
</DialogHeader>
<DialogContent
className={cn("top-1/3 translate-y-0 overflow-hidden rounded-none p-0", className)}
showCloseButton={showCloseButton}
>
{children}
</DialogContent>
</Dialog>
);
}
function CommandInput({
className,
...props
}: React.ComponentProps<typeof CommandPrimitive.Input>) {
return (
<div data-slot="command-input-wrapper" className="border-b pb-0">
<InputGroup className="h-8 border-none border-input/30 bg-input/30 shadow-none! *:data-[slot=input-group-addon]:pl-2!">
<CommandPrimitive.Input
data-slot="command-input"
className={cn(
"w-full text-xs outline-hidden disabled:cursor-not-allowed disabled:opacity-50",
className,
)}
{...props}
/>
<InputGroupAddon>
<IconSearch className="size-4 shrink-0 opacity-50" />
</InputGroupAddon>
</InputGroup>
</div>
);
}
function CommandList({ className, ...props }: React.ComponentProps<typeof CommandPrimitive.List>) {
return (
<CommandPrimitive.List
data-slot="command-list"
className={cn(
"no-scrollbar max-h-72 scroll-py-0 overflow-x-hidden overflow-y-auto outline-none",
className,
)}
{...props}
/>
);
}
function CommandEmpty({
className,
...props
}: React.ComponentProps<typeof CommandPrimitive.Empty>) {
return (
<CommandPrimitive.Empty
data-slot="command-empty"
className={cn("py-6 text-center text-xs", className)}
{...props}
/>
);
}
function CommandGroup({
className,
...props
}: React.ComponentProps<typeof CommandPrimitive.Group>) {
return (
<CommandPrimitive.Group
data-slot="command-group"
className={cn(
"overflow-hidden text-foreground **:[[cmdk-group-heading]]:px-2 **:[[cmdk-group-heading]]:py-1.5 **:[[cmdk-group-heading]]:text-xs **:[[cmdk-group-heading]]:text-muted-foreground",
className,
)}
{...props}
/>
);
}
function CommandSeparator({
className,
...props
}: React.ComponentProps<typeof CommandPrimitive.Separator>) {
return (
<CommandPrimitive.Separator
data-slot="command-separator"
className={cn("-mx-1 h-px bg-border", className)}
{...props}
/>
);
}
function CommandItem({
className,
children,
...props
}: React.ComponentProps<typeof CommandPrimitive.Item>) {
return (
<CommandPrimitive.Item
data-slot="command-item"
className={cn(
"group/command-item relative flex cursor-default items-center gap-2 rounded-none px-2 py-2 text-xs outline-hidden select-none in-data-[slot=dialog-content]:rounded-none! data-[disabled=true]:pointer-events-none data-[disabled=true]:opacity-50 data-selected:bg-muted data-selected:text-foreground [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4 data-selected:*:[svg]:text-foreground",
className,
)}
{...props}
>
{children}
<IconCheck className="ml-auto opacity-0 group-has-data-[slot=command-shortcut]/command-item:hidden group-data-[checked=true]/command-item:opacity-100" />
</CommandPrimitive.Item>
);
}
function CommandShortcut({ className, ...props }: React.ComponentProps<"span">) {
return (
<span
data-slot="command-shortcut"
className={cn(
"ml-auto text-xs tracking-widest text-muted-foreground group-data-selected/command-item:text-foreground",
className,
)}
{...props}
/>
);
}
export {
Command,
CommandDialog,
CommandInput,
CommandList,
CommandEmpty,
CommandGroup,
CommandItem,
CommandShortcut,
CommandSeparator,
};
+139
View File
@@ -0,0 +1,139 @@
"use client";
import { Dialog as DialogPrimitive } from "@base-ui/react/dialog";
import { IconX } from "@tabler/icons-react";
import * as React from "react";
import { Button } from "@/components/ui/button";
import { cn } from "@/lib/utils";
function Dialog({ ...props }: DialogPrimitive.Root.Props) {
return <DialogPrimitive.Root data-slot="dialog" {...props} />;
}
function DialogTrigger({ ...props }: DialogPrimitive.Trigger.Props) {
return <DialogPrimitive.Trigger data-slot="dialog-trigger" {...props} />;
}
function DialogPortal({ ...props }: DialogPrimitive.Portal.Props) {
return <DialogPrimitive.Portal data-slot="dialog-portal" {...props} />;
}
function DialogClose({ ...props }: DialogPrimitive.Close.Props) {
return <DialogPrimitive.Close data-slot="dialog-close" {...props} />;
}
function DialogOverlay({ className, ...props }: DialogPrimitive.Backdrop.Props) {
return (
<DialogPrimitive.Backdrop
data-slot="dialog-overlay"
className={cn(
"fixed inset-0 isolate z-50 bg-black/10 duration-100 supports-backdrop-filter:backdrop-blur-xs data-open:animate-in data-open:fade-in-0 data-closed:animate-out data-closed:fade-out-0",
className,
)}
{...props}
/>
);
}
function DialogContent({
className,
children,
showCloseButton = true,
...props
}: DialogPrimitive.Popup.Props & {
showCloseButton?: boolean;
}) {
return (
<DialogPortal>
<DialogOverlay />
<DialogPrimitive.Popup
data-slot="dialog-content"
className={cn(
"fixed top-1/2 left-1/2 z-50 grid w-full max-w-[calc(100%-2rem)] -translate-x-1/2 -translate-y-1/2 gap-4 rounded-none bg-popover p-4 text-xs/relaxed text-popover-foreground ring-1 ring-foreground/10 duration-100 outline-none sm:max-w-sm data-open:animate-in data-open:fade-in-0 data-open:zoom-in-95 data-closed:animate-out data-closed:fade-out-0 data-closed:zoom-out-95",
className,
)}
{...props}
>
{children}
{showCloseButton && (
<DialogPrimitive.Close
data-slot="dialog-close"
render={<Button variant="ghost" className="absolute top-2 right-2" size="icon-sm" />}
>
<IconX />
<span className="sr-only">Close</span>
</DialogPrimitive.Close>
)}
</DialogPrimitive.Popup>
</DialogPortal>
);
}
function DialogHeader({ className, ...props }: React.ComponentProps<"div">) {
return (
<div
data-slot="dialog-header"
className={cn("flex flex-col gap-1 text-left", className)}
{...props}
/>
);
}
function DialogFooter({
className,
showCloseButton = false,
children,
...props
}: React.ComponentProps<"div"> & {
showCloseButton?: boolean;
}) {
return (
<div
data-slot="dialog-footer"
className={cn("flex flex-col-reverse gap-2 sm:flex-row sm:justify-end", className)}
{...props}
>
{children}
{showCloseButton && (
<DialogPrimitive.Close render={<Button variant="outline" />}>Close</DialogPrimitive.Close>
)}
</div>
);
}
function DialogTitle({ className, ...props }: DialogPrimitive.Title.Props) {
return (
<DialogPrimitive.Title
data-slot="dialog-title"
className={cn("font-heading text-sm font-medium", className)}
{...props}
/>
);
}
function DialogDescription({ className, ...props }: DialogPrimitive.Description.Props) {
return (
<DialogPrimitive.Description
data-slot="dialog-description"
className={cn(
"text-xs/relaxed text-muted-foreground *:[a]:underline *:[a]:underline-offset-3 *:[a]:hover:text-foreground",
className,
)}
{...props}
/>
);
}
export {
Dialog,
DialogClose,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogOverlay,
DialogPortal,
DialogTitle,
DialogTrigger,
};
+144
View File
@@ -0,0 +1,144 @@
import { cva, type VariantProps } from "class-variance-authority";
import * as React from "react";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import { Textarea } from "@/components/ui/textarea";
import { cn } from "@/lib/utils";
function InputGroup({ className, ...props }: React.ComponentProps<"div">) {
return (
<div
data-slot="input-group"
role="group"
className={cn(
"group/input-group relative flex h-8 w-full min-w-0 items-center rounded-none border border-input transition-colors outline-none in-data-[slot=combobox-content]:focus-within:border-inherit in-data-[slot=combobox-content]:focus-within:ring-0 has-disabled:bg-input/50 has-disabled:opacity-50 has-[[data-slot=input-group-control]:focus-visible]:border-ring has-[[data-slot=input-group-control]:focus-visible]:ring-1 has-[[data-slot=input-group-control]:focus-visible]:ring-ring/50 has-[[data-slot][aria-invalid=true]]:border-destructive has-[[data-slot][aria-invalid=true]]:ring-1 has-[[data-slot][aria-invalid=true]]:ring-destructive/20 has-[>[data-align=block-end]]:h-auto has-[>[data-align=block-end]]:flex-col has-[>[data-align=block-start]]:h-auto has-[>[data-align=block-start]]:flex-col has-[>textarea]:h-auto dark:bg-input/30 dark:has-disabled:bg-input/80 dark:has-[[data-slot][aria-invalid=true]]:ring-destructive/40 has-[>[data-align=block-end]]:[&>input]:pt-3 has-[>[data-align=block-start]]:[&>input]:pb-3 has-[>[data-align=inline-end]]:[&>input]:pr-1.5 has-[>[data-align=inline-start]]:[&>input]:pl-1.5",
className,
)}
{...props}
/>
);
}
const inputGroupAddonVariants = cva(
"flex h-auto cursor-text items-center justify-center gap-2 py-1.5 text-xs font-medium text-muted-foreground select-none group-data-[disabled=true]/input-group:opacity-50 [&>kbd]:rounded-none [&>svg:not([class*='size-'])]:size-4",
{
variants: {
align: {
"inline-start": "order-first pl-2 has-[>button]:ml-[-0.3rem] has-[>kbd]:ml-[-0.15rem]",
"inline-end": "order-last pr-2 has-[>button]:mr-[-0.3rem] has-[>kbd]:mr-[-0.15rem]",
"block-start":
"order-first w-full justify-start px-2.5 pt-2 group-has-[>input]/input-group:pt-2 [.border-b]:pb-2",
"block-end":
"order-last w-full justify-start px-2.5 pb-2 group-has-[>input]/input-group:pb-2 [.border-t]:pt-2",
},
},
defaultVariants: {
align: "inline-start",
},
},
);
function InputGroupAddon({
className,
align = "inline-start",
...props
}: React.ComponentProps<"div"> & VariantProps<typeof inputGroupAddonVariants>) {
return (
<div
role="group"
data-slot="input-group-addon"
data-align={align}
className={cn(inputGroupAddonVariants({ align }), className)}
onClick={(e) => {
if ((e.target as HTMLElement).closest("button")) {
return;
}
e.currentTarget.parentElement?.querySelector("input")?.focus();
}}
{...props}
/>
);
}
const inputGroupButtonVariants = cva("flex items-center gap-2 text-xs shadow-none", {
variants: {
size: {
xs: "h-6 gap-1 rounded-none px-1.5 [&>svg:not([class*='size-'])]:size-3.5",
sm: "gap-1",
"icon-xs": "size-6 rounded-none p-0 has-[>svg]:p-0",
"icon-sm": "size-7 p-0 has-[>svg]:p-0",
},
},
defaultVariants: {
size: "xs",
},
});
function InputGroupButton({
className,
type = "button",
variant = "ghost",
size = "xs",
...props
}: Omit<React.ComponentProps<typeof Button>, "size" | "type"> &
VariantProps<typeof inputGroupButtonVariants> & {
type?: "button" | "submit" | "reset";
}) {
return (
<Button
type={type}
data-size={size}
variant={variant}
className={cn(inputGroupButtonVariants({ size }), className)}
{...props}
/>
);
}
function InputGroupText({ className, ...props }: React.ComponentProps<"span">) {
return (
<span
className={cn(
"flex items-center gap-2 text-xs text-muted-foreground [&_svg]:pointer-events-none [&_svg:not([class*='size-'])]:size-4",
className,
)}
{...props}
/>
);
}
function InputGroupInput({ className, ...props }: React.ComponentProps<"input">) {
return (
<Input
data-slot="input-group-control"
className={cn(
"flex-1 rounded-none border-0 bg-transparent shadow-none ring-0 focus-visible:ring-0 disabled:bg-transparent aria-invalid:ring-0 dark:bg-transparent dark:disabled:bg-transparent",
className,
)}
{...props}
/>
);
}
function InputGroupTextarea({ className, ...props }: React.ComponentProps<"textarea">) {
return (
<Textarea
data-slot="input-group-control"
className={cn(
"flex-1 resize-none rounded-none border-0 bg-transparent py-2 shadow-none ring-0 focus-visible:ring-0 disabled:bg-transparent aria-invalid:ring-0 dark:bg-transparent dark:disabled:bg-transparent",
className,
)}
{...props}
/>
);
}
export {
InputGroup,
InputGroupAddon,
InputGroupButton,
InputGroupText,
InputGroupInput,
InputGroupTextarea,
};
+26
View File
@@ -0,0 +1,26 @@
import { cn } from "@/lib/utils";
function Kbd({ className, ...props }: React.ComponentProps<"kbd">) {
return (
<kbd
data-slot="kbd"
className={cn(
"pointer-events-none inline-flex h-5 w-fit min-w-5 items-center justify-center gap-1 rounded-none bg-muted px-1 font-sans text-xs font-medium text-muted-foreground select-none in-data-[slot=tooltip-content]:bg-background/20 in-data-[slot=tooltip-content]:text-background dark:in-data-[slot=tooltip-content]:bg-background/10 [&_svg:not([class*='size-'])]:size-3",
className,
)}
{...props}
/>
);
}
function KbdGroup({ className, ...props }: React.ComponentProps<"div">) {
return (
<kbd
data-slot="kbd-group"
className={cn("inline-flex items-center gap-1", className)}
{...props}
/>
);
}
export { Kbd, KbdGroup };
+75
View File
@@ -0,0 +1,75 @@
import { Popover as PopoverPrimitive } from "@base-ui/react/popover";
import * as React from "react";
import { cn } from "@/lib/utils";
function Popover({ ...props }: PopoverPrimitive.Root.Props) {
return <PopoverPrimitive.Root data-slot="popover" {...props} />;
}
function PopoverTrigger({ ...props }: PopoverPrimitive.Trigger.Props) {
return <PopoverPrimitive.Trigger data-slot="popover-trigger" {...props} />;
}
function PopoverContent({
className,
align = "center",
alignOffset = 0,
side = "bottom",
sideOffset = 4,
...props
}: PopoverPrimitive.Popup.Props &
Pick<PopoverPrimitive.Positioner.Props, "align" | "alignOffset" | "side" | "sideOffset">) {
return (
<PopoverPrimitive.Portal>
<PopoverPrimitive.Positioner
align={align}
alignOffset={alignOffset}
side={side}
sideOffset={sideOffset}
className="isolate z-50"
>
<PopoverPrimitive.Popup
data-slot="popover-content"
className={cn(
"z-50 flex w-72 origin-(--transform-origin) flex-col gap-2.5 rounded-none bg-popover p-2.5 text-xs text-popover-foreground shadow-md ring-1 ring-foreground/10 outline-hidden duration-100 data-[side=bottom]:slide-in-from-top-2 data-[side=inline-end]:slide-in-from-left-2 data-[side=inline-start]:slide-in-from-right-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 data-open:animate-in data-open:fade-in-0 data-open:zoom-in-95 data-closed:animate-out data-closed:fade-out-0 data-closed:zoom-out-95",
className,
)}
{...props}
/>
</PopoverPrimitive.Positioner>
</PopoverPrimitive.Portal>
);
}
function PopoverHeader({ className, ...props }: React.ComponentProps<"div">) {
return (
<div
data-slot="popover-header"
className={cn("flex flex-col gap-1 text-xs", className)}
{...props}
/>
);
}
function PopoverTitle({ className, ...props }: PopoverPrimitive.Title.Props) {
return (
<PopoverPrimitive.Title
data-slot="popover-title"
className={cn("text-sm font-medium", className)}
{...props}
/>
);
}
function PopoverDescription({ className, ...props }: PopoverPrimitive.Description.Props) {
return (
<PopoverPrimitive.Description
data-slot="popover-description"
className={cn("text-xs/relaxed text-muted-foreground", className)}
{...props}
/>
);
}
export { Popover, PopoverContent, PopoverDescription, PopoverHeader, PopoverTitle, PopoverTrigger };
+18
View File
@@ -0,0 +1,18 @@
import * as React from "react";
import { cn } from "@/lib/utils";
function Textarea({ className, ...props }: React.ComponentProps<"textarea">) {
return (
<textarea
data-slot="textarea"
className={cn(
"flex field-sizing-content min-h-16 w-full rounded-none border border-input bg-transparent px-2.5 py-2 text-xs transition-colors outline-none placeholder:text-muted-foreground focus-visible:border-ring focus-visible:ring-1 focus-visible:ring-ring/50 disabled:cursor-not-allowed disabled:bg-input/50 disabled:opacity-50 aria-invalid:border-destructive aria-invalid:ring-1 aria-invalid:ring-destructive/20 md:text-xs dark:bg-input/30 dark:disabled:bg-input/80 dark:aria-invalid:border-destructive/50 dark:aria-invalid:ring-destructive/40",
className,
)}
{...props}
/>
);
}
export { Textarea };
+13
View File
@@ -0,0 +1,13 @@
import type { ModuleSummary, SlotId } from "@stanza/registry";
export function groupBySlot(
modules: ModuleSummary[],
): Array<{ slot: SlotId; modules: ModuleSummary[] }> {
const groups = new Map<SlotId, ModuleSummary[]>();
for (const m of modules) {
const list = groups.get(m.slot) ?? [];
list.push(m);
groups.set(m.slot, list);
}
return [...groups.entries()].map(([slot, mods]) => ({ slot, modules: mods }));
}
+58
View File
@@ -0,0 +1,58 @@
const SITE_URL = process.env.SITE_URL ?? "https://stanza.tools";
const DEFAULT_TITLE = "stanza";
const DEFAULT_DESCRIPTION = "Modular monorepo template builder.";
export type HeadInput = {
/** Page-specific title. Concatenated to the site name: `${title} · stanza`. */
title?: string;
description?: string;
/** Path-only URL (e.g. `/m/auth/better-auth`). Used to build `og:url` + canonical. */
path: string;
/** OG image URL. Path-only ok — gets resolved against the site origin. */
ogImage?: string;
/** Defaults to `"website"`; module detail pages use `"article"`. */
type?: "website" | "article";
};
export type HeadOutput = {
meta: Array<Record<string, string>>;
links: Array<Record<string, string>>;
};
export function buildHead(input: HeadInput): HeadOutput {
const title = input.title ? `${input.title} · ${DEFAULT_TITLE}` : DEFAULT_TITLE;
const description = input.description ?? DEFAULT_DESCRIPTION;
const url = abs(input.path);
const ogImage = abs(input.ogImage ?? "/og");
const type = input.type ?? "website";
return {
meta: [
{ charSet: "utf-8" },
{ name: "viewport", content: "width=device-width, initial-scale=1" },
{ title },
{ name: "description", content: description },
{ property: "og:type", content: type },
{ property: "og:title", content: title },
{ property: "og:description", content: description },
{ property: "og:url", content: url },
{ property: "og:image", content: ogImage },
{ property: "og:site_name", content: DEFAULT_TITLE },
{ name: "twitter:card", content: "summary_large_image" },
{ name: "twitter:title", content: title },
{ name: "twitter:description", content: description },
{ name: "twitter:image", content: ogImage },
],
links: [{ rel: "canonical", href: url }],
};
}
function abs(pathOrUrl: string): string {
if (/^https?:\/\//.test(pathOrUrl)) return pathOrUrl;
const base = SITE_URL.replace(/\/$/, "");
const path = pathOrUrl.startsWith("/") ? pathOrUrl : `/${pathOrUrl}`;
return `${base}${path}`;
}
export { SITE_URL };
+21 -3
View File
@@ -10,33 +10,43 @@
import { Route as rootRouteImport } from './routes/__root' import { Route as rootRouteImport } from './routes/__root'
import { Route as IndexRouteImport } from './routes/index' import { Route as IndexRouteImport } from './routes/index'
import { Route as MSlotIdRouteImport } from './routes/m.$slot.$id'
const IndexRoute = IndexRouteImport.update({ const IndexRoute = IndexRouteImport.update({
id: '/', id: '/',
path: '/', path: '/',
getParentRoute: () => rootRouteImport, getParentRoute: () => rootRouteImport,
} as any) } as any)
const MSlotIdRoute = MSlotIdRouteImport.update({
id: '/m/$slot/$id',
path: '/m/$slot/$id',
getParentRoute: () => rootRouteImport,
} as any)
export interface FileRoutesByFullPath { export interface FileRoutesByFullPath {
'/': typeof IndexRoute '/': typeof IndexRoute
'/m/$slot/$id': typeof MSlotIdRoute
} }
export interface FileRoutesByTo { export interface FileRoutesByTo {
'/': typeof IndexRoute '/': typeof IndexRoute
'/m/$slot/$id': typeof MSlotIdRoute
} }
export interface FileRoutesById { export interface FileRoutesById {
__root__: typeof rootRouteImport __root__: typeof rootRouteImport
'/': typeof IndexRoute '/': typeof IndexRoute
'/m/$slot/$id': typeof MSlotIdRoute
} }
export interface FileRouteTypes { export interface FileRouteTypes {
fileRoutesByFullPath: FileRoutesByFullPath fileRoutesByFullPath: FileRoutesByFullPath
fullPaths: '/' fullPaths: '/' | '/m/$slot/$id'
fileRoutesByTo: FileRoutesByTo fileRoutesByTo: FileRoutesByTo
to: '/' to: '/' | '/m/$slot/$id'
id: '__root__' | '/' id: '__root__' | '/' | '/m/$slot/$id'
fileRoutesById: FileRoutesById fileRoutesById: FileRoutesById
} }
export interface RootRouteChildren { export interface RootRouteChildren {
IndexRoute: typeof IndexRoute IndexRoute: typeof IndexRoute
MSlotIdRoute: typeof MSlotIdRoute
} }
declare module '@tanstack/react-router' { declare module '@tanstack/react-router' {
@@ -48,11 +58,19 @@ declare module '@tanstack/react-router' {
preLoaderRoute: typeof IndexRouteImport preLoaderRoute: typeof IndexRouteImport
parentRoute: typeof rootRouteImport parentRoute: typeof rootRouteImport
} }
'/m/$slot/$id': {
id: '/m/$slot/$id'
path: '/m/$slot/$id'
fullPath: '/m/$slot/$id'
preLoaderRoute: typeof MSlotIdRouteImport
parentRoute: typeof rootRouteImport
}
} }
} }
const rootRouteChildren: RootRouteChildren = { const rootRouteChildren: RootRouteChildren = {
IndexRoute: IndexRoute, IndexRoute: IndexRoute,
MSlotIdRoute: MSlotIdRoute,
} }
export const routeTree = rootRouteImport export const routeTree = rootRouteImport
._addFileChildren(rootRouteChildren) ._addFileChildren(rootRouteChildren)
+4 -2
View File
@@ -7,16 +7,18 @@ import { Header } from "@/components/header";
import { ThemeProvider } from "@/components/theme-provider"; import { ThemeProvider } from "@/components/theme-provider";
import { Toaster } from "@/components/ui/sonner"; import { Toaster } from "@/components/ui/sonner";
import { TooltipProvider } from "@/components/ui/tooltip"; import { TooltipProvider } from "@/components/ui/tooltip";
import { getRegistryIndex } from "@/server/registry-index";
import appCss from "../styles.css?url"; import appCss from "../styles.css?url";
export const Route = createRootRoute({ export const Route = createRootRoute({
loader: () => getRegistryIndex(),
// Per-route `head()` overrides supply title / OG / canonical. The root just
// sets the always-present basics and ships the global stylesheet.
head: () => ({ head: () => ({
meta: [ meta: [
{ charSet: "utf-8" }, { charSet: "utf-8" },
{ name: "viewport", content: "width=device-width, initial-scale=1" }, { name: "viewport", content: "width=device-width, initial-scale=1" },
{ title: "stanza" },
{ name: "description", content: "Modular monorepo template builder." },
], ],
links: [{ rel: "stylesheet", href: appCss }], links: [{ rel: "stylesheet", href: appCss }],
}), }),
+8
View File
@@ -2,6 +2,7 @@ import { createFileRoute } from "@tanstack/react-router";
import { Builder } from "@/components/builder"; import { Builder } from "@/components/builder";
import type { BuilderSearch } from "@/lib/selection"; import type { BuilderSearch } from "@/lib/selection";
import { buildHead } from "@/lib/seo";
import { getBuilderState } from "@/server/builder-state"; import { getBuilderState } from "@/server/builder-state";
function validateSearch(input: Record<string, unknown>): BuilderSearch { function validateSearch(input: Record<string, unknown>): BuilderSearch {
@@ -17,6 +18,13 @@ export const Route = createFileRoute("/")({
validateSearch, validateSearch,
loaderDeps: ({ search }) => search, loaderDeps: ({ search }) => search,
loader: ({ deps }) => getBuilderState({ data: deps }), loader: ({ deps }) => getBuilderState({ data: deps }),
head: () =>
buildHead({
title: "Build your stack",
description:
"Pick a framework, ORM, database, auth provider, and styling. Get a clean monorepo with idiomatic, vendored code.",
path: "/",
}),
component: Page, component: Page,
}); });
+156
View File
@@ -0,0 +1,156 @@
import type { SlotId } from "@stanza/registry";
import { KNOWN_SLOTS, slotLabel } from "@stanza/registry";
import { IconExternalLink } from "@tabler/icons-react";
import { Link, createFileRoute, notFound, useNavigate } from "@tanstack/react-router";
import { AdapterSwitcher } from "@/components/detail/adapter-switcher";
import { DepsTable } from "@/components/detail/deps-table";
import { EnvTable } from "@/components/detail/env-table";
import { TemplatesList } from "@/components/detail/templates-list";
import { TryIt } from "@/components/detail/try-it";
import { ModuleLogo } from "@/components/module-logo";
import { Badge } from "@/components/ui/badge";
import { Separator } from "@/components/ui/separator";
import { buildCommand } from "@/lib/selection";
import { buildHead } from "@/lib/seo";
import { getModuleDetail } from "@/server/module-detail";
type DetailSearch = Partial<Record<SlotId, string>>;
function validateSearch(input: Record<string, unknown>): DetailSearch {
const out: DetailSearch = {};
for (const slot of KNOWN_SLOTS) {
const v = input[slot];
if (typeof v === "string" && v.length > 0) out[slot] = v;
}
return out;
}
export const Route = createFileRoute("/m/$slot/$id")({
validateSearch,
loaderDeps: ({ search }) => search,
loader: async ({ params, deps }) => {
if (!isSlotId(params.slot)) throw notFound();
const detail = await getModuleDetail({
data: { slot: params.slot, id: params.id, peers: deps },
});
if (!detail) throw notFound();
return detail;
},
head: ({ loaderData, params }) =>
buildHead({
title: loaderData?.module.label ?? params.id,
description: loaderData?.module.description,
path: `/m/${params.slot}/${params.id}`,
ogImage: `/og/${params.slot}/${params.id}.png`,
type: "article",
}),
component: ModuleDetailPage,
});
function isSlotId(slot: string): slot is SlotId {
return (KNOWN_SLOTS as readonly string[]).includes(slot);
}
function ModuleDetailPage() {
const detail = Route.useLoaderData();
const search = Route.useSearch();
const navigate = useNavigate({ from: Route.fullPath });
const { module, adapter, resolvedPeers, peerOptions, effective, previews, index } = detail;
const onPeerChange = (slot: SlotId, id: string) => {
void navigate({
search: { ...search, [slot]: id },
replace: true,
});
};
// Build a "Try it" command using the same builder helper, treating the
// current module + resolved peers as a complete-enough selection.
const command = buildCommand({
name: "my-app",
selections: { ...resolvedPeers, [module.slot]: module.id },
});
return (
<div className="mx-auto max-w-3xl px-4 py-10 sm:px-6">
<div className="mb-6">
<Link
to="/"
className="text-xs text-muted-foreground transition-colors hover:text-foreground"
>
← Back to builder
</Link>
</div>
<header className="flex flex-wrap items-start gap-4">
<ModuleLogo logo={module.logo} label={module.label} size="lg" />
<div className="min-w-0 flex-1">
<div className="flex flex-wrap items-center gap-2">
<h1 className="text-2xl font-semibold tracking-tight">{module.label}</h1>
<Badge variant="outline">{slotLabel(module.slot)}</Badge>
<span className="font-mono text-xs text-muted-foreground/60">v{module.version}</span>
</div>
<p className="mt-1.5 text-sm text-muted-foreground">{module.description}</p>
<div className="mt-3 flex flex-wrap gap-3 text-xs">
{module.homepage && (
<a
href={module.homepage}
target="_blank"
rel="noreferrer"
className="inline-flex items-center gap-1 text-muted-foreground transition-colors hover:text-foreground"
>
Homepage <IconExternalLink className="size-3" />
</a>
)}
{module.author && <span className="text-muted-foreground">by {module.author}</span>}
<span className="font-mono text-muted-foreground/70">
{module.slot}/{module.id}
</span>
</div>
</div>
</header>
<Separator className="my-8" />
{/* Adapter switcher — hidden when no peer has multiple options */}
<AdapterSwitcher
index={index}
peerOptions={peerOptions}
resolvedPeers={resolvedPeers}
onChange={onPeerChange}
/>
{hasSwitchable(peerOptions) && <Separator className="my-8" />}
<div className="space-y-8">
<DepsTable title="Dependencies" entries={effective.dependencies} />
<DepsTable title="Dev dependencies" entries={effective.devDependencies} />
<EnvTable env={effective.env} />
<DepsTable title="Scripts" entries={effective.scripts} />
<TemplatesList templates={adapter.templates ?? []} previews={previews} />
<TryIt command={command} />
</div>
{/* Hidden meta — useful for the future when more fields exist */}
<Separator className="my-8" />
<p className="text-[11px] text-muted-foreground/60">
Adapter key: <code className="font-mono">{adapter.key}</code>
{Object.keys(adapter.match).length > 0 && (
<>
{" · "}
matches{" "}
{Object.entries(adapter.match)
.map(([slot, id]) => `${slot}=${id}`)
.join(", ")}
</>
)}
</p>
</div>
);
}
function hasSwitchable(peerOptions: Partial<Record<SlotId, string[]>>): boolean {
return Object.values(peerOptions).some((opts) => opts && opts.length > 1);
}
+4 -34
View File
@@ -4,8 +4,7 @@ import { createServerFn } from "@tanstack/react-start";
import { parseSelections, resolveSelectedAdapters, selectedFiles } from "@/lib/selection"; import { parseSelections, resolveSelectedAdapters, selectedFiles } from "@/lib/selection";
import type { BuilderSearch } from "@/lib/selection"; import type { BuilderSearch } from "@/lib/selection";
import { renderPreview, type Preview } from "@/server/highlighter"; import { renderPreview, type Preview } from "@/server/highlighter";
import { loadRegistryFile } from "@/server/registry-base";
const REGISTRY_BASE = process.env.STANZA_REGISTRY ?? "/registry";
export type BuilderState = { export type BuilderState = {
index: RegistryIndex; index: RegistryIndex;
@@ -25,24 +24,15 @@ export type BuilderState = {
export const getBuilderState = createServerFn({ method: "GET" }) export const getBuilderState = createServerFn({ method: "GET" })
.inputValidator((data: BuilderSearch) => data) .inputValidator((data: BuilderSearch) => data)
.handler(async ({ data }): Promise<BuilderState> => { .handler(async ({ data }): Promise<BuilderState> => {
const base = await resolveBase(); const index = await loadRegistryFile<RegistryIndex>("index.json");
const indexRes = await fetch(`${base}/index.json`); // Load every module in parallel. The dataset is small (10s of KB at most)
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 // and having everything client-side lets the user toggle slots without
// extra roundtrips. The Shiki preview map is the only piece that changes // extra roundtrips. The Shiki preview map is the only piece that changes
// per-selection — recomputed each loader run. // per-selection — recomputed each loader run.
const fullModules = await Promise.all( const fullModules = await Promise.all(
index.modules.map(async (summary) => { index.modules.map(async (summary) => {
const url = `${base}/modules/${summary.slot}-${summary.id}.json`; const mod = await loadRegistryFile<Module>(`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; return [`${mod.slot}:${mod.id}`, mod] as const;
}), }),
); );
@@ -67,23 +57,3 @@ export const getBuilderState = createServerFn({ method: "GET" })
filePaths: files.map((f) => f.path), 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<string> {
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}`;
}
}
+218
View File
@@ -0,0 +1,218 @@
import type {
EnvVar,
Module,
ModuleAdapter,
ModuleSummary,
PeerRequirement,
RegistryIndex,
SlotId,
} from "@stanza/registry";
import { KNOWN_SLOTS, emptyManifest, resolveAdapter } from "@stanza/registry";
import { createServerFn } from "@tanstack/react-start";
import { renderPreview, type Preview } from "@/server/highlighter";
import { loadRegistryFile } from "@/server/registry-base";
export type ModuleDetailInput = {
slot: SlotId;
id: string;
/**
* Explicit peer choices from the URL search params. Any peer not present
* here gets an auto-default chosen from the module's `peers` list (when
* declared) or from the registry index.
*/
peers: Partial<Record<SlotId, string>>;
};
export type EffectiveInstallFields = {
dependencies: Record<string, string>;
devDependencies: Record<string, string>;
env: EnvVar[];
scripts: Record<string, string>;
};
export type ModuleDetail = {
module: Module;
/** Adapter the resolver picked, given the (possibly auto-defaulted) peers. */
adapter: ModuleAdapter;
/**
* Peer assignments actually used for resolution — explicit + auto-defaults.
* The detail page renders the switcher with these values pre-selected so the
* UI reflects what's being shown even when the URL is empty.
*/
resolvedPeers: Partial<Record<SlotId, string>>;
/** For each peer slot, the set of valid module ids the switcher can offer. */
peerOptions: Partial<Record<SlotId, string[]>>;
/** Module-level + adapter-level fields merged with adapter-wins semantics. */
effective: EffectiveInstallFields;
/** Pre-rendered Shiki HTML for each template in the resolved adapter, keyed by `dest`. */
previews: Record<string, Preview>;
/** Light index of every module so client UI can label peer chips, etc. */
index: RegistryIndex;
};
export const getModuleDetail = createServerFn({ method: "GET" })
.inputValidator((data: ModuleDetailInput) => data)
.handler(async ({ data }): Promise<ModuleDetail | null> => {
const index = await loadRegistryFile<RegistryIndex>("index.json");
const summary = index.modules.find((m) => m.slot === data.slot && m.id === data.id);
if (!summary) return null;
const module = await loadRegistryFile<Module>(`modules/${data.slot}-${data.id}.json`);
const peerOptions = computePeerOptions(module, index);
const resolvedPeers = applyAutoDefaults(module, data.peers, peerOptions);
const adapter = pickAdapter(module, resolvedPeers);
const effective = mergeInstallFields(module, adapter);
const previewEntries = await Promise.all(
(adapter.templates ?? []).map(async (tpl) => {
const content = tpl.content ?? "";
const preview = await renderPreview(content, tpl.dest);
return [tpl.dest, preview] as const;
}),
);
return {
module,
adapter,
resolvedPeers,
peerOptions,
effective,
previews: Object.fromEntries(previewEntries),
index,
};
});
/**
* For each peer slot referenced by the module (via `peers` or any adapter's
* `match`), list the allowed module ids. Falls back to the registry index
* when the module only declares `"any"`.
*/
function computePeerOptions(
module: Module,
index: RegistryIndex,
): Partial<Record<SlotId, string[]>> {
const out: Partial<Record<SlotId, string[]>> = {};
const declared = module.peers ?? ({} as PeerRequirement);
const referenced = new Set<SlotId>();
for (const slot of Object.keys(declared) as SlotId[]) referenced.add(slot);
for (const adapter of module.adapters) {
for (const slot of Object.keys(adapter.match) as SlotId[]) referenced.add(slot);
}
for (const slot of referenced) {
const constraint = declared[slot];
if (Array.isArray(constraint)) {
out[slot] = constraint;
} else {
// "any" or undefined — list every module that lives in this slot.
out[slot] = index.modules.filter((m) => m.slot === slot).map((m) => m.id);
}
// De-dup and keep declaration order. Also union in any ids referenced by
// adapters that weren't in the declared list — defensive against authors
// adding adapter `match` entries without updating `peers`.
const fromAdapters = new Set<string>();
for (const adapter of module.adapters) {
const v = adapter.match[slot];
if (v) fromAdapters.add(v);
}
const merged: string[] = [];
const seen = new Set<string>();
for (const id of [...(out[slot] ?? []), ...fromAdapters]) {
if (!seen.has(id)) {
seen.add(id);
merged.push(id);
}
}
out[slot] = merged;
}
return out;
}
/**
* Fills in any unset peer slot with the first option from `peerOptions`. The
* detail page is usable from a bare URL (no search params) — the first
* allowed value is a reasonable starting point and the user can switch via
* the chip-row UI.
*/
function applyAutoDefaults(
module: Module,
peers: Partial<Record<SlotId, string>>,
peerOptions: Partial<Record<SlotId, string[]>>,
): Partial<Record<SlotId, string>> {
const out: Partial<Record<SlotId, string>> = { ...peers };
for (const slot of Object.keys(peerOptions) as SlotId[]) {
if (out[slot]) continue;
const opts = peerOptions[slot];
if (opts && opts.length > 0) out[slot] = opts[0];
}
// Honor module.peers for any slot that's declared but not on a list (e.g.
// "any" constraint without an adapter match). Above already covers it via
// the index lookup.
void module; // keep param for future use; signature kept stable
return out;
}
/**
* Pick the matching adapter for the resolved peer set. Builds a synthetic
* `pending` from the resolved peers so we can reuse `resolveAdapter`.
*
* Falls back to the first adapter if resolution fails — the page still has
* something useful to render and the switcher will let the user fix it.
*/
function pickAdapter(module: Module, peers: Partial<Record<SlotId, string>>): ModuleAdapter {
const pending: Partial<Record<SlotId, Module>> = {};
for (const slot of KNOWN_SLOTS) {
const id = peers[slot];
if (!id) continue;
// Build a minimal placeholder module — only `id`, `slot`, `adapters` are
// read by the resolver path we hit.
pending[slot] = {
id,
slot,
label: id,
description: "",
version: "0.0.0",
adapters: [],
} satisfies Module;
}
const r = resolveAdapter(module, { manifest: emptyManifest({ name: "t" }), pending });
if (r.ok) return r.adapter;
// Defensive: the auto-defaults should usually satisfy the constraints, but
// if the module declares conflicting peer + adapter sets we still need to
// render something. The first adapter is the closest analogue to a default.
return module.adapters[0]!;
}
/**
* Adapter-wins merge of install fields. Matches the runner's behavior:
* - `dependencies` / `devDependencies` / `scripts` merge per-key
* - `env` merges by `name` (adapter overrides module)
*/
export function mergeInstallFields(module: Module, adapter: ModuleAdapter): EffectiveInstallFields {
const dependencies: Record<string, string> = {
...module.dependencies,
...adapter.dependencies,
};
const devDependencies: Record<string, string> = {
...module.devDependencies,
...adapter.devDependencies,
};
const scripts: Record<string, string> = {
...module.scripts,
...adapter.scripts,
};
const envByName = new Map<string, EnvVar>();
for (const e of module.env ?? []) envByName.set(e.name, e);
for (const e of adapter.env ?? []) envByName.set(e.name, e);
return {
dependencies,
devDependencies,
env: [...envByName.values()],
scripts,
};
}
export type { ModuleSummary };
+163
View File
@@ -0,0 +1,163 @@
import type { ModuleSummary } from "@stanza/registry";
import { slotLabel } from "@stanza/registry";
import type { ReactElement } from "react";
/**
* The visual layout shared by all OG images. Lives in `src/server/` so the
* tsx/JSX parsing applies — Nitro server routes are plain .ts files.
*
* Satori (used inside @vercel/og) supports a subset of CSS — flex layout,
* absolute positioning, basic typography. No grid, no shadows on text, etc.
*/
export function OgCard({ summary }: { summary: ModuleSummary }): ReactElement {
const logo = summary.logo;
const logoSrc = typeof logo === "string" ? logo : logo?.light;
return (
<div
style={{
height: "100%",
width: "100%",
display: "flex",
flexDirection: "column",
background: "#0a0a0a",
color: "#fafafa",
padding: "80px",
fontFamily: "Inter, system-ui",
}}
>
<div style={{ display: "flex", alignItems: "center", gap: "12px" }}>
<span style={{ fontSize: "28px", fontWeight: 600, letterSpacing: "-0.02em" }}>stanza</span>
<span style={{ color: "#52525b", fontSize: "20px" }}>·</span>
<span style={{ color: "#a1a1aa", fontSize: "20px" }}>{slotLabel(summary.slot)}</span>
</div>
<div
style={{
flex: 1,
display: "flex",
flexDirection: "column",
justifyContent: "center",
gap: "20px",
}}
>
<div style={{ display: "flex", alignItems: "center", gap: "28px" }}>
{logoSrc ? (
<div
style={{
width: "96px",
height: "96px",
display: "flex",
alignItems: "center",
justifyContent: "center",
background: "#18181b",
borderRadius: "16px",
padding: "16px",
}}
>
{/* Satori renders <img> with `src` set to a data URI for SVGs */}
<img
src={svgToDataUri(logoSrc)}
width={64}
height={64}
alt=""
style={{ filter: "invert(1)" }}
/>
</div>
) : (
<div
style={{
width: "96px",
height: "96px",
display: "flex",
alignItems: "center",
justifyContent: "center",
background: "#18181b",
borderRadius: "16px",
fontSize: "48px",
fontWeight: 700,
color: "#fafafa",
}}
>
{summary.label.slice(0, 1)}
</div>
)}
<div
style={{ fontSize: "72px", fontWeight: 600, letterSpacing: "-0.03em", lineHeight: 1 }}
>
{summary.label}
</div>
</div>
<div
style={{
fontSize: "28px",
color: "#a1a1aa",
lineHeight: 1.3,
maxWidth: "900px",
}}
>
{summary.description}
</div>
</div>
<div
style={{
display: "flex",
justifyContent: "space-between",
color: "#52525b",
fontSize: "20px",
}}
>
<span>
{summary.slot}/{summary.id}
</span>
<span>v{summary.version}</span>
</div>
</div>
);
}
/**
* Default OG used by the home / search routes. Big wordmark + tagline.
*/
export function OgDefault(): ReactElement {
return (
<div
style={{
height: "100%",
width: "100%",
display: "flex",
flexDirection: "column",
background: "#0a0a0a",
color: "#fafafa",
padding: "80px",
fontFamily: "Inter, system-ui",
}}
>
<div
style={{
flex: 1,
display: "flex",
flexDirection: "column",
justifyContent: "center",
gap: "24px",
}}
>
<div
style={{ fontSize: "120px", fontWeight: 700, letterSpacing: "-0.04em", lineHeight: 1 }}
>
stanza
</div>
<div style={{ fontSize: "36px", color: "#a1a1aa", lineHeight: 1.3, maxWidth: "900px" }}>
Modular monorepo template builder.
</div>
</div>
<div style={{ color: "#52525b", fontSize: "22px" }}>pnpm create stanza my-app</div>
</div>
);
}
function svgToDataUri(svg: string): string {
// Encode SVG → data URI. Satori's `<img>` accepts data: URIs for SVG.
const encoded = encodeURIComponent(svg).replace(/'/g, "%27").replace(/"/g, "%22");
return `data:image/svg+xml;charset=utf-8,${encoded}`;
}
+46
View File
@@ -0,0 +1,46 @@
/**
* Reads registry data from the public dir on disk — the same JSON the deployed
* site serves at `/registry/`. Done via fs instead of fetching ourselves
* because prod SSR loopback connections get refused, and the file is sitting
* right next to the bundle anyway.
*/
import { existsSync } from "node:fs";
import fs from "node:fs/promises";
import path from "node:path";
import { fileURLToPath } from "node:url";
export async function loadRegistryFile<T>(relativePath: string): Promise<T> {
const full = path.join(publicRegistryDir(), relativePath);
const buf = await fs.readFile(full, "utf8");
return JSON.parse(buf) as T;
}
/**
* Resolve `apps/web/public/registry/` relative to this module.
*
* - In dev: `import.meta.url` is `src/server/registry-base.ts` — walk to
* `apps/web/` and into `public/registry/`.
* - In the prod Nitro bundle: this module is inlined into a chunk under
* `.output/server/`. The public dir is sibling: `.output/public/registry/`.
*
* We try both. The first that exists wins. Cached.
*/
let resolved: string | undefined;
function publicRegistryDir(): string {
if (resolved) return resolved;
const here = fileURLToPath(import.meta.url);
const candidates = [
path.resolve(path.dirname(here), "../../public/registry"),
path.resolve(path.dirname(here), "../public/registry"),
path.resolve(process.cwd(), "public/registry"),
path.resolve(process.cwd(), ".output/public/registry"),
];
for (const c of candidates) {
if (existsSync(path.join(c, "index.json"))) {
resolved = c;
return c;
}
}
resolved = candidates[0]!;
return resolved;
}
+16
View File
@@ -0,0 +1,16 @@
import type { RegistryIndex } from "@stanza/registry";
import { createServerFn } from "@tanstack/react-start";
import { loadRegistryFile } from "@/server/registry-base";
/**
* Lightweight read of just the registry index (`index.json`). Wired into the
* root route loader so every page can read it from `useLoaderData({ from: "__root__" })`
* without re-fetching — the index is small (~10 KB) and a single read per
* navigation is plenty for the header search and per-route SEO/OG.
*/
export const getRegistryIndex = createServerFn({ method: "GET" }).handler(
async (): Promise<RegistryIndex> => {
return loadRegistryFile<RegistryIndex>("index.json");
},
);
+11
View File
@@ -1,3 +1,5 @@
import { fileURLToPath } from "node:url";
import tailwindcss from "@tailwindcss/vite"; import tailwindcss from "@tailwindcss/vite";
import { devtools } from "@tanstack/devtools-vite"; import { devtools } from "@tanstack/devtools-vite";
import { tanstackStart } from "@tanstack/react-start/plugin/vite"; import { tanstackStart } from "@tanstack/react-start/plugin/vite";
@@ -7,4 +9,13 @@ import { defineConfig } from "vite";
export default defineConfig({ export default defineConfig({
plugins: [nitro(), devtools(), tailwindcss(), tanstackStart(), react()], plugins: [nitro(), devtools(), tailwindcss(), tanstackStart(), react()],
resolve: {
tsconfigPaths: true,
},
// `serverDir: "./server"` opts Nitro into scanning `./server/routes/` (and
// `api/`, `middleware/`, `utils/`, etc) for filesystem-routed handlers. This
// is how the `/og/$slot/$id` and `/sitemap.xml` endpoints get registered.
nitro: {
serverDir: "./server",
},
}); });
+926
View File
File diff suppressed because it is too large. Load diff