mirror of
https://github.com/jakejarvis/stanza.git
synced 2026-10-16 06:15:43 -04:00
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:
34 files changed
+2782
-100
No files matched your search
@@ -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)
|
||||||
|
|||||||
@@ -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",
|
||||||
|
|||||||
@@ -0,0 +1,4 @@
|
|||||||
|
User-agent: *
|
||||||
|
Allow: /
|
||||||
|
|
||||||
|
Sitemap: https://stanza.tools/sitemap.xml
|
||||||
@@ -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",
|
||||||
|
},
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -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",
|
||||||
|
},
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -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}`;
|
||||||
|
}
|
||||||
@@ -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";
|
||||||
|
}
|
||||||
@@ -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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -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>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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,
|
||||||
|
};
|
||||||
@@ -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,
|
||||||
|
};
|
||||||
@@ -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,
|
||||||
|
};
|
||||||
@@ -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 };
|
||||||
@@ -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 };
|
||||||
@@ -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 };
|
||||||
@@ -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 }));
|
||||||
|
}
|
||||||
@@ -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 };
|
||||||
@@ -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)
|
||||||
|
|||||||
@@ -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 }],
|
||||||
}),
|
}),
|
||||||
|
|||||||
@@ -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,
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@@ -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,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}`;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -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 };
|
||||||
@@ -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}`;
|
||||||
|
}
|
||||||
@@ -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;
|
||||||
|
}
|
||||||
@@ -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");
|
||||||
|
},
|
||||||
|
);
|
||||||
@@ -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",
|
||||||
|
},
|
||||||
});
|
});
|
||||||
Generated
+926
File diff suppressed because it is too large.
Load diff
Reference in new issue
Block a user