feat: add /registry root index page, sort detail tables, and rename OG routes to .webp URLs

- Add `registry.index.tsx` — a root registry landing page at `/registry` listing all modules; emit `/registry` in `prerender.ts` so it is prerendered alongside category and module paths
- Sort deps, scripts, env vars, and templates alphabetically (env vars: required-first then alpha; templates: by scope rank then dest) for consistent display order
- Rename OG image routes to include the `.webp` extension in the URL (`og.index.ts` → `og[.]webp.ts`, `og.docs.$.ts` → `og.docs.{$}[.]webp.ts`, `og.registry.$category.$id.ts` → `og.registry.$category.{$id}[.]webp.ts`) and update the default `ogImage` fallback in `seo.ts` to `/og.webp`
- Swap version/icon layout in `DepsTable` so version is hidden on hover and the external-link icon appears in its place
This commit is contained in:
2026-05-29 18:06:42 -04:00
parent 9237dd7f46
commit 4fbb060226
15 changed files with 271 additions and 113 deletions
+15 -7
View File
@@ -5,7 +5,7 @@ import { Section, SectionList } from "@/components/detail/section";
import { Badge } from "@/components/ui/badge";
export function DepsTable({ title, entries }: { title: string; entries: Record<string, string> }) {
const items = Object.entries(entries);
const items = Object.entries(entries).toSorted(([a], [b]) => a.localeCompare(b));
if (items.length === 0) return null;
return (
@@ -20,14 +20,18 @@ export function DepsTable({ title, entries }: { title: string; entries: Record<s
aria-label={`${name} ${version} (opens in new tab)`}
className="group flex items-center justify-between gap-3 px-3 py-2 font-mono text-xs transition-colors hover:bg-muted/50"
>
<span className="inline-flex min-w-0 items-center gap-1 text-foreground group-hover:underline group-hover:underline-offset-1">
<span className="truncate">{name}</span>
<span className="min-w-0 truncate text-foreground group-hover:underline group-hover:underline-offset-1">
{name}
</span>
<span className="inline-flex shrink-0 items-center gap-1">
<span className="text-muted-foreground tabular-nums group-hover:hidden">
{version}
</span>
<IconExternalLink
className="size-3 shrink-0 text-muted-foreground/60"
className="hidden size-3 shrink-0 text-muted-foreground/60 group-hover:block"
aria-hidden="true"
/>
</span>
<span className="shrink-0 text-muted-foreground tabular-nums">{version}</span>
</a>
</li>
))}
@@ -37,7 +41,7 @@ export function DepsTable({ title, entries }: { title: string; entries: Record<s
}
export function ScriptsTable({ entries }: { entries: Record<string, string> }) {
const items = Object.entries(entries);
const items = Object.entries(entries).toSorted(([a], [b]) => a.localeCompare(b));
if (items.length === 0) return null;
return (
@@ -63,10 +67,14 @@ export function ScriptsTable({ entries }: { entries: Record<string, string> }) {
export function EnvTable({ env }: { env: EnvVar[] }) {
if (env.length === 0) return null;
const items = env.toSorted(
(a, b) => Number(b.required) - Number(a.required) || a.name.localeCompare(b.name),
);
return (
<Section title="Environment Variables" count={env.length}>
<SectionList>
{env.map((e) => (
{items.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>
@@ -17,10 +17,13 @@ export function TemplatesList({
previews: Record<string, Preview>;
}) {
if (templates.length === 0) return null;
const sorted = templates.toSorted(
(a, b) => scopeRank(a.scope) - scopeRank(b.scope) || a.dest.localeCompare(b.dest),
);
return (
<Section title="Templates" count={templates.length}>
<SectionList>
{templates.map((tpl) => (
{sorted.map((tpl) => (
<TemplateRow key={tpl.dest} template={tpl} preview={previews[tpl.dest]} />
))}
</SectionList>
@@ -80,3 +83,9 @@ function scopeLabel(scope: TemplateRef["scope"]): string {
if (scope === "package") return "package";
return "app";
}
function scopeRank(scope: TemplateRef["scope"]): number {
if (scope === "package") return 1;
if (scope === "app") return 2;
return 0; // repo (or undefined default)
}
+1 -1
View File
@@ -53,7 +53,7 @@ function listRegistryPaths(): string[] {
};
const categoryPaths = index.categories.map((c) => `/registry/${c.id}`);
const modulePaths = index.modules.map((m) => `/registry/${m.category}/${m.id}`);
return [...categoryPaths, ...modulePaths].toSorted((a, b) => a.localeCompare(b));
return ["/registry", ...categoryPaths, ...modulePaths].toSorted((a, b) => a.localeCompare(b));
}
export function listPrerenderPages() {
+1 -1
View File
@@ -40,7 +40,7 @@ export function buildHead(input: HeadInput): HeadOutput {
input.titleOverride ?? (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 ogImage = abs(input.ogImage ?? "/og.webp");
const type = input.type ?? "website";
const links: Array<Record<string, string>> = [{ rel: "canonical", href: url }];
+79 -55
View File
@@ -10,9 +10,10 @@
import { Route as rootRouteImport } from './routes/__root'
import { Route as StatsRouteImport } from './routes/stats'
import { Route as OgDotwebpRouteImport } from './routes/og[.]webp'
import { Route as DocsDotmdRouteImport } from './routes/docs[.]md'
import { Route as IndexRouteImport } from './routes/index'
import { Route as OgIndexRouteImport } from './routes/og.index'
import { Route as RegistryIndexRouteImport } from './routes/registry.index'
import { Route as DocsChar123Char125DotmdRouteImport } from './routes/docs.{$}[.]md'
import { Route as DocsLlmsDottxtRouteImport } from './routes/docs.llms[.]txt'
import { Route as DocsLlmsFullDottxtRouteImport } from './routes/docs.llms-full[.]txt'
@@ -20,16 +21,21 @@ import { Route as DocsSplatRouteImport } from './routes/docs.$'
import { Route as ApiEventsRouteImport } from './routes/api.events'
import { Route as RegistryCategoryIndexRouteImport } from './routes/registry.$category.index'
import { Route as RegistryCategoryIdRouteImport } from './routes/registry.$category.$id'
import { Route as OgDocsSplatRouteImport } from './routes/og.docs.$'
import { Route as OgDocsChar123Char125DotwebpRouteImport } from './routes/og.docs.{$}[.]webp'
import { Route as ApiSearchModulesRouteImport } from './routes/api.search.modules'
import { Route as ApiSearchDocsRouteImport } from './routes/api.search.docs'
import { Route as OgRegistryCategoryIdRouteImport } from './routes/og.registry.$category.$id'
import { Route as OgRegistryCategoryChar123idChar125DotwebpRouteImport } from './routes/og.registry.$category.{$id}[.]webp'
const StatsRoute = StatsRouteImport.update({
id: '/stats',
path: '/stats',
getParentRoute: () => rootRouteImport,
} as any)
const OgDotwebpRoute = OgDotwebpRouteImport.update({
id: '/og.webp',
path: '/og.webp',
getParentRoute: () => rootRouteImport,
} as any)
const DocsDotmdRoute = DocsDotmdRouteImport.update({
id: '/docs.md',
path: '/docs.md',
@@ -40,9 +46,9 @@ const IndexRoute = IndexRouteImport.update({
path: '/',
getParentRoute: () => rootRouteImport,
} as any)
const OgIndexRoute = OgIndexRouteImport.update({
id: '/og/',
path: '/og/',
const RegistryIndexRoute = RegistryIndexRouteImport.update({
id: '/registry/',
path: '/registry/',
getParentRoute: () => rootRouteImport,
} as any)
const DocsChar123Char125DotmdRoute = DocsChar123Char125DotmdRouteImport.update({
@@ -80,11 +86,12 @@ const RegistryCategoryIdRoute = RegistryCategoryIdRouteImport.update({
path: '/registry/$category/$id',
getParentRoute: () => rootRouteImport,
} as any)
const OgDocsSplatRoute = OgDocsSplatRouteImport.update({
id: '/og/docs/$',
path: '/og/docs/$',
getParentRoute: () => rootRouteImport,
} as any)
const OgDocsChar123Char125DotwebpRoute =
OgDocsChar123Char125DotwebpRouteImport.update({
id: '/og/docs/{$}.webp',
path: '/og/docs/{$}.webp',
getParentRoute: () => rootRouteImport,
} as any)
const ApiSearchModulesRoute = ApiSearchModulesRouteImport.update({
id: '/api/search/modules',
path: '/api/search/modules',
@@ -95,134 +102,142 @@ const ApiSearchDocsRoute = ApiSearchDocsRouteImport.update({
path: '/api/search/docs',
getParentRoute: () => rootRouteImport,
} as any)
const OgRegistryCategoryIdRoute = OgRegistryCategoryIdRouteImport.update({
id: '/og/registry/$category/$id',
path: '/og/registry/$category/$id',
getParentRoute: () => rootRouteImport,
} as any)
const OgRegistryCategoryChar123idChar125DotwebpRoute =
OgRegistryCategoryChar123idChar125DotwebpRouteImport.update({
id: '/og/registry/$category/{$id}.webp',
path: '/og/registry/$category/{$id}.webp',
getParentRoute: () => rootRouteImport,
} as any)
export interface FileRoutesByFullPath {
'/': typeof IndexRoute
'/docs.md': typeof DocsDotmdRoute
'/og.webp': typeof OgDotwebpRoute
'/stats': typeof StatsRoute
'/api/events': typeof ApiEventsRoute
'/docs/$': typeof DocsSplatRoute
'/docs/llms-full.txt': typeof DocsLlmsFullDottxtRoute
'/docs/llms.txt': typeof DocsLlmsDottxtRoute
'/docs/{$}.md': typeof DocsChar123Char125DotmdRoute
'/og/': typeof OgIndexRoute
'/registry/': typeof RegistryIndexRoute
'/api/search/docs': typeof ApiSearchDocsRoute
'/api/search/modules': typeof ApiSearchModulesRoute
'/og/docs/$': typeof OgDocsSplatRoute
'/og/docs/{$}.webp': typeof OgDocsChar123Char125DotwebpRoute
'/registry/$category/$id': typeof RegistryCategoryIdRoute
'/registry/$category/': typeof RegistryCategoryIndexRoute
'/og/registry/$category/$id': typeof OgRegistryCategoryIdRoute
'/og/registry/$category/{$id}.webp': typeof OgRegistryCategoryChar123idChar125DotwebpRoute
}
export interface FileRoutesByTo {
'/': typeof IndexRoute
'/docs.md': typeof DocsDotmdRoute
'/og.webp': typeof OgDotwebpRoute
'/stats': typeof StatsRoute
'/api/events': typeof ApiEventsRoute
'/docs/$': typeof DocsSplatRoute
'/docs/llms-full.txt': typeof DocsLlmsFullDottxtRoute
'/docs/llms.txt': typeof DocsLlmsDottxtRoute
'/docs/{$}.md': typeof DocsChar123Char125DotmdRoute
'/og': typeof OgIndexRoute
'/registry': typeof RegistryIndexRoute
'/api/search/docs': typeof ApiSearchDocsRoute
'/api/search/modules': typeof ApiSearchModulesRoute
'/og/docs/$': typeof OgDocsSplatRoute
'/og/docs/{$}.webp': typeof OgDocsChar123Char125DotwebpRoute
'/registry/$category/$id': typeof RegistryCategoryIdRoute
'/registry/$category': typeof RegistryCategoryIndexRoute
'/og/registry/$category/$id': typeof OgRegistryCategoryIdRoute
'/og/registry/$category/{$id}.webp': typeof OgRegistryCategoryChar123idChar125DotwebpRoute
}
export interface FileRoutesById {
__root__: typeof rootRouteImport
'/': typeof IndexRoute
'/docs.md': typeof DocsDotmdRoute
'/og.webp': typeof OgDotwebpRoute
'/stats': typeof StatsRoute
'/api/events': typeof ApiEventsRoute
'/docs/$': typeof DocsSplatRoute
'/docs/llms-full.txt': typeof DocsLlmsFullDottxtRoute
'/docs/llms.txt': typeof DocsLlmsDottxtRoute
'/docs/{$}.md': typeof DocsChar123Char125DotmdRoute
'/og/': typeof OgIndexRoute
'/registry/': typeof RegistryIndexRoute
'/api/search/docs': typeof ApiSearchDocsRoute
'/api/search/modules': typeof ApiSearchModulesRoute
'/og/docs/$': typeof OgDocsSplatRoute
'/og/docs/{$}.webp': typeof OgDocsChar123Char125DotwebpRoute
'/registry/$category/$id': typeof RegistryCategoryIdRoute
'/registry/$category/': typeof RegistryCategoryIndexRoute
'/og/registry/$category/$id': typeof OgRegistryCategoryIdRoute
'/og/registry/$category/{$id}.webp': typeof OgRegistryCategoryChar123idChar125DotwebpRoute
}
export interface FileRouteTypes {
fileRoutesByFullPath: FileRoutesByFullPath
fullPaths:
| '/'
| '/docs.md'
| '/og.webp'
| '/stats'
| '/api/events'
| '/docs/$'
| '/docs/llms-full.txt'
| '/docs/llms.txt'
| '/docs/{$}.md'
| '/og/'
| '/registry/'
| '/api/search/docs'
| '/api/search/modules'
| '/og/docs/$'
| '/og/docs/{$}.webp'
| '/registry/$category/$id'
| '/registry/$category/'
| '/og/registry/$category/$id'
| '/og/registry/$category/{$id}.webp'
fileRoutesByTo: FileRoutesByTo
to:
| '/'
| '/docs.md'
| '/og.webp'
| '/stats'
| '/api/events'
| '/docs/$'
| '/docs/llms-full.txt'
| '/docs/llms.txt'
| '/docs/{$}.md'
| '/og'
| '/registry'
| '/api/search/docs'
| '/api/search/modules'
| '/og/docs/$'
| '/og/docs/{$}.webp'
| '/registry/$category/$id'
| '/registry/$category'
| '/og/registry/$category/$id'
| '/og/registry/$category/{$id}.webp'
id:
| '__root__'
| '/'
| '/docs.md'
| '/og.webp'
| '/stats'
| '/api/events'
| '/docs/$'
| '/docs/llms-full.txt'
| '/docs/llms.txt'
| '/docs/{$}.md'
| '/og/'
| '/registry/'
| '/api/search/docs'
| '/api/search/modules'
| '/og/docs/$'
| '/og/docs/{$}.webp'
| '/registry/$category/$id'
| '/registry/$category/'
| '/og/registry/$category/$id'
| '/og/registry/$category/{$id}.webp'
fileRoutesById: FileRoutesById
}
export interface RootRouteChildren {
IndexRoute: typeof IndexRoute
DocsDotmdRoute: typeof DocsDotmdRoute
OgDotwebpRoute: typeof OgDotwebpRoute
StatsRoute: typeof StatsRoute
ApiEventsRoute: typeof ApiEventsRoute
DocsSplatRoute: typeof DocsSplatRoute
DocsLlmsFullDottxtRoute: typeof DocsLlmsFullDottxtRoute
DocsLlmsDottxtRoute: typeof DocsLlmsDottxtRoute
DocsChar123Char125DotmdRoute: typeof DocsChar123Char125DotmdRoute
OgIndexRoute: typeof OgIndexRoute
RegistryIndexRoute: typeof RegistryIndexRoute
ApiSearchDocsRoute: typeof ApiSearchDocsRoute
ApiSearchModulesRoute: typeof ApiSearchModulesRoute
OgDocsSplatRoute: typeof OgDocsSplatRoute
OgDocsChar123Char125DotwebpRoute: typeof OgDocsChar123Char125DotwebpRoute
RegistryCategoryIdRoute: typeof RegistryCategoryIdRoute
RegistryCategoryIndexRoute: typeof RegistryCategoryIndexRoute
OgRegistryCategoryIdRoute: typeof OgRegistryCategoryIdRoute
OgRegistryCategoryChar123idChar125DotwebpRoute: typeof OgRegistryCategoryChar123idChar125DotwebpRoute
}
declare module '@tanstack/react-router' {
@@ -234,6 +249,13 @@ declare module '@tanstack/react-router' {
preLoaderRoute: typeof StatsRouteImport
parentRoute: typeof rootRouteImport
}
'/og.webp': {
id: '/og.webp'
path: '/og.webp'
fullPath: '/og.webp'
preLoaderRoute: typeof OgDotwebpRouteImport
parentRoute: typeof rootRouteImport
}
'/docs.md': {
id: '/docs.md'
path: '/docs.md'
@@ -248,11 +270,11 @@ declare module '@tanstack/react-router' {
preLoaderRoute: typeof IndexRouteImport
parentRoute: typeof rootRouteImport
}
'/og/': {
id: '/og/'
path: '/og'
fullPath: '/og/'
preLoaderRoute: typeof OgIndexRouteImport
'/registry/': {
id: '/registry/'
path: '/registry'
fullPath: '/registry/'
preLoaderRoute: typeof RegistryIndexRouteImport
parentRoute: typeof rootRouteImport
}
'/docs/{$}.md': {
@@ -304,11 +326,11 @@ declare module '@tanstack/react-router' {
preLoaderRoute: typeof RegistryCategoryIdRouteImport
parentRoute: typeof rootRouteImport
}
'/og/docs/$': {
id: '/og/docs/$'
path: '/og/docs/$'
fullPath: '/og/docs/$'
preLoaderRoute: typeof OgDocsSplatRouteImport
'/og/docs/{$}.webp': {
id: '/og/docs/{$}.webp'
path: '/og/docs/{$}.webp'
fullPath: '/og/docs/{$}.webp'
preLoaderRoute: typeof OgDocsChar123Char125DotwebpRouteImport
parentRoute: typeof rootRouteImport
}
'/api/search/modules': {
@@ -325,11 +347,11 @@ declare module '@tanstack/react-router' {
preLoaderRoute: typeof ApiSearchDocsRouteImport
parentRoute: typeof rootRouteImport
}
'/og/registry/$category/$id': {
id: '/og/registry/$category/$id'
path: '/og/registry/$category/$id'
fullPath: '/og/registry/$category/$id'
preLoaderRoute: typeof OgRegistryCategoryIdRouteImport
'/og/registry/$category/{$id}.webp': {
id: '/og/registry/$category/{$id}.webp'
path: '/og/registry/$category/{$id}.webp'
fullPath: '/og/registry/$category/{$id}.webp'
preLoaderRoute: typeof OgRegistryCategoryChar123idChar125DotwebpRouteImport
parentRoute: typeof rootRouteImport
}
}
@@ -338,19 +360,21 @@ declare module '@tanstack/react-router' {
const rootRouteChildren: RootRouteChildren = {
IndexRoute: IndexRoute,
DocsDotmdRoute: DocsDotmdRoute,
OgDotwebpRoute: OgDotwebpRoute,
StatsRoute: StatsRoute,
ApiEventsRoute: ApiEventsRoute,
DocsSplatRoute: DocsSplatRoute,
DocsLlmsFullDottxtRoute: DocsLlmsFullDottxtRoute,
DocsLlmsDottxtRoute: DocsLlmsDottxtRoute,
DocsChar123Char125DotmdRoute: DocsChar123Char125DotmdRoute,
OgIndexRoute: OgIndexRoute,
RegistryIndexRoute: RegistryIndexRoute,
ApiSearchDocsRoute: ApiSearchDocsRoute,
ApiSearchModulesRoute: ApiSearchModulesRoute,
OgDocsSplatRoute: OgDocsSplatRoute,
OgDocsChar123Char125DotwebpRoute: OgDocsChar123Char125DotwebpRoute,
RegistryCategoryIdRoute: RegistryCategoryIdRoute,
RegistryCategoryIndexRoute: RegistryCategoryIndexRoute,
OgRegistryCategoryIdRoute: OgRegistryCategoryIdRoute,
OgRegistryCategoryChar123idChar125DotwebpRoute:
OgRegistryCategoryChar123idChar125DotwebpRoute,
}
export const routeTree = rootRouteImport
._addFileChildren(rootRouteChildren)
+1 -1
View File
@@ -86,7 +86,7 @@ export const Route = createFileRoute("/docs/$")({
title,
description,
path,
ogImage: loaderData ? `/og${loaderData.url}` : undefined,
ogImage: loaderData ? `/og${loaderData.url}.webp` : undefined,
markdownPath: loaderData ? `${path}.md` : undefined,
jsonLd:
loaderData && title
@@ -5,11 +5,11 @@ import { source } from "@/lib/source";
import { OgDocs } from "@/server/og-card.server";
/**
* `/og/docs/$splat` per-docs-page OG card, mirroring the public
* `/og/docs/$splat.webp` per-docs-page OG card, mirroring the public
* `/docs/$splat` URL. The splat resolves to fumadocs page slugs the same way
* `routes/docs.$.tsx` does. Bails 404 when the page is unknown.
*/
export const Route = createFileRoute("/og/docs/$")({
export const Route = createFileRoute("/og/docs/{$}.webp")({
server: {
handlers: {
GET: ({ params }) => {
@@ -6,11 +6,11 @@ import { OgCard } from "@/server/og-card.server";
import { loadRegistryFile } from "@/server/registry-base.server";
/**
* `/og/registry/$category/$id` per-module OG card (e.g. `/og/registry/auth/clerk`), mirroring
* the public `/registry/$category/$id` URL. Dynamically rendered at request time via
* `/og/registry/$category/$id.webp` per-module OG card (e.g. `/og/registry/auth/clerk.webp`),
* mirroring the public `/registry/$category/$id` URL. Dynamically rendered at request time via
* Takumi. Bails 404 when the module is unknown.
*/
export const Route = createFileRoute("/og/registry/$category/$id")({
export const Route = createFileRoute("/og/registry/$category/{$id}.webp")({
server: {
handlers: {
GET: async ({ params }) => {
@@ -4,12 +4,12 @@ import { createFileRoute } from "@tanstack/react-router";
import { OgDefault } from "@/server/og-card.server";
/**
* `/og` default OG used by `/`, `/search`, and anywhere without a more
* `/og.webp` 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. A TanStack Start server route (no page component):
* the `GET` handler streams a WebP straight back.
*/
export const Route = createFileRoute("/og/")({
export const Route = createFileRoute("/og.webp")({
server: {
handlers: {
GET: () =>
@@ -45,7 +45,7 @@ export const Route = createFileRoute("/registry/$category/$id")({
title: module.label,
description: module.description,
path,
ogImage: `/og/registry/${params.category}/${params.id}`,
ogImage: `/og${path}.webp`,
type: "article",
jsonLd: [
getSoftwareSourceCodeJsonLd({
@@ -118,7 +118,7 @@ function ModuleDetailPage() {
<h1 className="text-2xl font-medium tracking-tight text-balance" translate="no">
{module.label}
</h1>
<Badge variant="default">{categoryLabel(module.category)}</Badge>
<Badge variant="secondary">{categoryLabel(module.category)}</Badge>
</div>
<p className="mt-1.5 text-sm text-pretty text-muted-foreground">{module.description}</p>
<div className="mt-3 flex flex-wrap gap-3 text-xs">
@@ -1,5 +1,4 @@
import type { CategoryId } from "@stanza/registry";
import { categoryLabel, isCategoryId } from "@stanza/registry";
import { categoryDescription, categoryLabel, isCategoryId } from "@stanza/registry";
import { IconArrowLeft } from "@tabler/icons-react";
import { Link, createFileRoute, notFound, useLoaderData } from "@tanstack/react-router";
import { useMemo } from "react";
@@ -28,21 +27,6 @@ export const Route = createFileRoute("/registry/$category/")({
component: CategoryLandingPage,
});
const CATEGORY_BLURBS: Record<CategoryId, string> = {
framework: "Web and native app frameworks.",
ui: "Styling systems and component primitives.",
db: "Database engines.",
orm: "Typed query layers over your database.",
auth: "Authentication providers and session handling.",
payments: "Checkout, customer portal, and webhooks.",
email: "Transactional email providers and templates.",
ai: "AI SDK and provider wiring.",
tooling: "Linter and formatter toolchains.",
testing: "Test runners — unit and end-to-end.",
deploy: "Deploy targets.",
monorepo: "Workspace task orchestrators.",
};
function CategoryLandingPage() {
const { category } = Route.useLoaderData();
const { registry } = useLoaderData({ from: "__root__" });
@@ -74,7 +58,7 @@ function CategoryLandingPage() {
<h1 className="text-2xl font-medium tracking-tight text-balance">{label}</h1>
</div>
<p className="mt-1.5 text-sm text-pretty text-muted-foreground">
{CATEGORY_BLURBS[category]}
{categoryDescription(category)}
</p>
</header>
+126
View File
@@ -0,0 +1,126 @@
import type { Category, ModuleMetadata } from "@stanza/registry";
import { IconArrowLeft } from "@tabler/icons-react";
import { Link, createFileRoute, useLoaderData } from "@tanstack/react-router";
import { useMemo } from "react";
import { SectionList } from "@/components/detail/section";
import { ModuleLogo } from "@/components/module-logo";
import { Badge } from "@/components/ui/badge";
import { Separator } from "@/components/ui/separator";
import { buildHead, getWebSiteJsonLd } from "@/lib/seo";
export const Route = createFileRoute("/registry/")({
head: () =>
buildHead({
title: "Registry",
description: "Every first-party module Stanza can install, grouped by category.",
path: "/registry",
jsonLd: [getWebSiteJsonLd()],
}),
component: RegistryIndexPage,
});
type CategoryGroup = { category: Category; modules: ModuleMetadata[] };
function RegistryIndexPage() {
const { registry } = useLoaderData({ from: "__root__" });
// Group modules under their category, preserving the index's topological
// category order. Every category is rendered (even empty ones) so the page
// links to all category landing pages — it doubles as a registry sitemap.
const groups = useMemo<CategoryGroup[]>(() => {
const byCategory = new Map<string, ModuleMetadata[]>();
for (const m of registry.modules) {
const list = byCategory.get(m.category);
if (list) list.push(m);
else byCategory.set(m.category, [m]);
}
return registry.categories.map((category) => ({
category,
modules: (byCategory.get(category.id) ?? []).toSorted((a, b) =>
a.label.localeCompare(b.label),
),
}));
}, [registry]);
return (
<div className="mx-auto max-w-3xl px-4 py-10 sm:px-6">
<div className="mb-6">
<Link
to="/"
className="inline-flex items-center gap-1 text-xs text-muted-foreground transition-colors hover:text-foreground"
>
<IconArrowLeft className="size-3" aria-hidden="true" />
Back to builder
</Link>
</div>
<header>
<div className="flex flex-wrap items-center gap-3">
<h1 className="text-2xl font-medium tracking-tight text-balance">Registry</h1>
</div>
<p className="mt-1.5 text-sm text-pretty text-muted-foreground">
Every first-party module Stanza can install, grouped by category.
</p>
</header>
<Separator className="my-8" />
<div className="space-y-8">
{groups.map(({ category, modules }) => (
<CategoryGroupSection key={category.id} category={category} modules={modules} />
))}
</div>
</div>
);
}
function CategoryGroupSection({
category,
modules,
}: {
category: Category;
modules: ModuleMetadata[];
}) {
return (
<section>
<div className="mb-2 flex items-center gap-2">
<Link to="/registry/$category" params={{ category: category.id }}>
<h2 className="text-[13px] font-medium tracking-tight text-muted-foreground transition-colors group-hover:text-foreground">
{category.label}
</h2>
</Link>
<Badge
variant="secondary"
className="h-auto px-1.5 py-1 font-mono text-[11px] leading-none tabular-nums"
>
{modules.length}
</Badge>
</div>
{modules.length === 0 ? (
<p className="mt-4 text-xs text-muted-foreground">No modules in this category (yet).</p>
) : (
<SectionList>
{modules.map((m) => (
<li key={m.id}>
<Link
to="/registry/$category/$id"
params={{ category: m.category, id: m.id }}
className="flex items-start gap-3 p-3 transition-colors hover:bg-muted/50"
>
<ModuleLogo logo={m.logo} label={m.label} />
<div className="min-w-0 flex-1">
<h3 className="truncate text-sm leading-tight font-medium">{m.label}</h3>
<p className="mt-1 text-xs leading-relaxed text-muted-foreground">
{m.description}
</p>
</div>
</Link>
</li>
))}
</SectionList>
)}
</section>
);
}
+10 -9
View File
@@ -26,8 +26,8 @@ const PAGE: CSSProperties = {
};
const HEADER_ROW: CSSProperties = { display: "flex", alignItems: "center", gap: "12px" };
const DOT: CSSProperties = { color: "#52525b", fontSize: "20px" };
const SLOT: CSSProperties = { color: "#a1a1aa", fontSize: "20px" };
const SEPARATOR: CSSProperties = { color: "#52525b", fontSize: "24px" };
const SLOT: CSSProperties = { color: "#a1a1aa", fontSize: "24px" };
// Brand mark sized and colored for the always-dark OG background. The viewer
// `<img>` sets the rendered dimensions; the explicit fill keeps it visible
@@ -84,7 +84,9 @@ const FOOTER: CSSProperties = {
display: "flex",
justifyContent: "space-between",
color: "#6c6c6c",
fontSize: "20px",
fontSize: "22px",
fontWeight: 500,
fontFamily: "Geist Mono, monospace",
};
const DEFAULT_BODY: CSSProperties = {
@@ -100,7 +102,6 @@ const TAGLINE: CSSProperties = {
lineHeight: 1.3,
maxWidth: "900px",
};
const DEFAULT_FOOTER: CSSProperties = { color: "#52525b", fontSize: "22px" };
export function OgCard({ meta }: { meta: ModuleMetadata }): ReactElement {
const logo = meta.logo;
@@ -113,7 +114,7 @@ export function OgCard({ meta }: { meta: ModuleMetadata }): ReactElement {
<div style={PAGE}>
<div style={HEADER_ROW}>
<img src={svgToDataUri(BRAND_LOGO_SVG)} width={32} height={32} alt="Stanza" />
<span style={DOT}>·</span>
<span style={SEPARATOR}>/</span>
<span style={SLOT}>{categoryLabel(meta.category)}</span>
</div>
@@ -134,7 +135,7 @@ export function OgCard({ meta }: { meta: ModuleMetadata }): ReactElement {
<div style={FOOTER}>
<span>
stanza.tools/registry/{meta.category}/{meta.id}
npx stanza-cli add {meta.category} {meta.id}
</span>
</div>
</div>
@@ -159,7 +160,7 @@ export function OgDocs({
<div style={PAGE}>
<div style={HEADER_ROW}>
<img src={svgToDataUri(BRAND_LOGO_SVG)} width={32} height={32} alt="Stanza" />
<span style={DOT}>·</span>
<span style={SEPARATOR}>/</span>
<span style={SLOT}>Docs</span>
</div>
@@ -182,10 +183,10 @@ export function OgDefault(): ReactElement {
return (
<div style={PAGE}>
<div style={DEFAULT_BODY}>
<img src={svgToDataUri(BRAND_LOGO_SVG)} width={160} height={160} alt="Stanza" />
<img src={svgToDataUri(BRAND_LOGO_SVG)} width={120} height={120} alt="Stanza" />
<div style={TAGLINE}>Modular monorepo template builder.</div>
</div>
<div style={DEFAULT_FOOTER}>npm init stanza my-app</div>
<div style={FOOTER}>npm init stanza my-revolutionary-app</div>
</div>
);
}
+1
View File
@@ -25,6 +25,7 @@ export {
PEER_CATEGORIES,
PACKAGE_DIRS,
categoryLabel,
categoryDescription,
categoryHome,
categoryCardinality,
isMulti,
+16 -11
View File
@@ -43,70 +43,70 @@ export const CATEGORIES = [
{
id: "framework",
label: "Framework",
description: "Web/native app framework.",
description: "Web and native app frameworks.",
cardinality: "one",
home: { kind: "app" },
},
{
id: "ui",
label: "UI",
description: "Styling system + component primitives.",
description: "Styling systems and component primitives.",
cardinality: "one",
home: { kind: "package", dir: "ui" },
},
{
id: "db",
label: "Database",
description: "Database engine.",
description: "Database engines.",
cardinality: "one",
home: { kind: "package", dir: "db" },
},
{
id: "orm",
label: "ORM",
description: "Database query layer.",
description: "Typed query layers over your database.",
cardinality: "one",
home: { kind: "package", dir: "db" },
},
{
id: "auth",
label: "Auth",
description: "Authentication provider.",
description: "Authentication providers and session handling.",
cardinality: "one",
home: { kind: "package", dir: "auth" },
},
{
id: "payments",
label: "Payments",
description: "Checkout + webhooks.",
description: "Checkout, customer portal, and webhooks.",
cardinality: "one",
home: { kind: "package", dir: "payments" },
},
{
id: "email",
label: "Email",
description: "Transactional email.",
description: "Transactional email providers and templates.",
cardinality: "one",
home: { kind: "package", dir: "email" },
},
{
id: "ai",
label: "AI",
description: "AI SDK + provider wiring.",
description: "AI SDK and provider wiring.",
cardinality: "one",
home: { kind: "package", dir: "ai" },
},
{
id: "tooling",
label: "Tooling",
description: "Linter + formatter toolchain.",
description: "Linter and formatter toolchains.",
cardinality: "one",
home: { kind: "repo" },
},
{
id: "testing",
label: "Testing",
description: "Test runners (unit, e2e).",
description: "Test runners unit and end-to-end.",
cardinality: "many",
home: { kind: "app" },
},
@@ -120,7 +120,7 @@ export const CATEGORIES = [
{
id: "monorepo",
label: "Monorepo",
description: "Workspace task orchestrator.",
description: "Workspace task orchestrators.",
cardinality: "one",
home: { kind: "repo" },
},
@@ -161,6 +161,11 @@ export function categoryLabel(id: CategoryId): string {
return CATEGORY_BY_ID[id].label;
}
/** One-line blurb — used as the category landing-page subtitle. */
export function categoryDescription(id: CategoryId): string {
return CATEGORY_BY_ID[id].description;
}
/** Install destination for a category's modules. */
export function categoryHome(id: CategoryId): InstallHome {
return CATEGORY_BY_ID[id].home;