feat: add category index pages and consolidate detail table components

- Merge `deps-table.tsx` and `env-table.tsx` into a single `tables.tsx` that also exports a new `ScriptsTable`; split was unnecessary and the unified file is easier to maintain
- Add `count` prop to `Section` so all detail sections (templates, deps, env vars, scripts) show an item-count badge in the heading
- Link each dependency row in `DepsTable` to `npmx.dev` so users can inspect package versions directly
- Introduce `registry.$category.index.tsx` — a new category landing page at `/registry/<cat>` that lists all modules in that category
- Update `prerender.ts` to emit both `/registry/<cat>` (category index) and `/registry/<cat>/<id>` (module detail) paths so all public registry URLs are prerendered
This commit is contained in:
2026-05-27 20:18:28 -04:00
parent 440d1d38da
commit 703a1f76cf
9 changed files with 243 additions and 71 deletions
@@ -1,28 +0,0 @@
import { Section, SectionList } from "@/components/detail/section";
/**
* 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 title={title}>
<SectionList>
{items.map(([name, version]) => (
<li
key={name}
translate="no"
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>
))}
</SectionList>
</Section>
);
}
@@ -1,26 +0,0 @@
import type { EnvVar } from "@stanza/registry";
import { Section, SectionList } from "@/components/detail/section";
import { Badge } from "@/components/ui/badge";
export function EnvTable({ env }: { env: EnvVar[] }) {
if (env.length === 0) return null;
return (
<Section title="Environment variables">
<SectionList>
{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>
))}
</SectionList>
</Section>
);
}
+22 -2
View File
@@ -1,9 +1,29 @@
import type { ReactNode } from "react";
export function Section({ title, children }: { title: string; children: ReactNode }) {
import { Badge } from "@/components/ui/badge";
export function Section({
title,
count,
children,
}: {
title: string;
count?: number;
children: ReactNode;
}) {
return (
<section>
<h3 className="mb-2 text-[13px] font-medium tracking-tight text-muted-foreground">{title}</h3>
<div className="mb-2 flex items-center gap-2">
<h3 className="text-[13px] font-medium tracking-tight text-muted-foreground">{title}</h3>
{count !== undefined && (
<Badge
variant="secondary"
className="h-auto px-1.5 py-1 font-mono text-[11px] leading-none tabular-nums"
>
{count}
</Badge>
)}
</div>
{children}
</section>
);
+76
View File
@@ -0,0 +1,76 @@
import type { EnvVar } from "@stanza/registry";
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);
if (items.length === 0) return null;
return (
<Section title={title} count={items.length}>
<SectionList>
{items.map(([name, version]) => (
<li key={name} translate="no">
<a
href={`https://npmx.dev/package/${name}/v/${version}`}
target="_blank"
rel="noopener noreferrer"
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="truncate text-foreground group-hover:underline group-hover:underline-offset-1">
{name}
</span>
<span className="shrink-0 text-muted-foreground">{version}</span>
</a>
</li>
))}
</SectionList>
</Section>
);
}
export function ScriptsTable({ entries }: { entries: Record<string, string> }) {
const items = Object.entries(entries);
if (items.length === 0) return null;
return (
<Section title="Scripts" count={items.length}>
<SectionList>
{items.map(([name, command]) => (
<li
key={name}
translate="no"
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">{command}</span>
</li>
))}
</SectionList>
</Section>
);
}
export function EnvTable({ env }: { env: EnvVar[] }) {
if (env.length === 0) return null;
return (
<Section title="Environment variables" count={env.length}>
<SectionList>
{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>
))}
</SectionList>
</Section>
);
}
@@ -18,7 +18,7 @@ export function TemplatesList({
}) {
if (templates.length === 0) return null;
return (
<Section title="Templates">
<Section title="Templates" count={templates.length}>
<SectionList>
{templates.map((tpl) => (
<TemplateRow key={tpl.dest} template={tpl} preview={previews[tpl.dest]} />
+15 -11
View File
@@ -29,10 +29,12 @@ function listDocsPaths(): string[] {
return out.toSorted();
}
// Read modules from the on-disk registry (populated by the `prebuild` script
// before vite runs). We avoid `@/server/registry-base.server` because it goes
// through Nitro's `useStorage`, which only exists at request time.
function listModulePaths(): string[] {
// Read the registry from disk (populated by the `prebuild` script before vite
// runs). We avoid `@/server/registry-base.server` because it goes through
// Nitro's `useStorage`, which only exists at request time. Returns both the
// category landing pages (`/registry/<cat>`) and per-module detail pages
// (`/registry/<cat>/<id>`) so every public registry URL prerenders.
function listRegistryPaths(): string[] {
const registryPath = resolve(appRoot, "public/registry/index.json");
let raw: string;
try {
@@ -44,11 +46,13 @@ function listModulePaths(): string[] {
{ cause: error },
);
}
// oxlint-disable-next-line typescript/no-unsafe-type-assertion
const index = JSON.parse(raw) as { modules: Array<{ category: string; id: string }> };
return index.modules
.map((m) => `/registry/${m.category}/${m.id}`)
.toSorted((a, b) => a.localeCompare(b));
const index = JSON.parse(raw) as {
categories: Array<{ id: string }>;
modules: Array<{ category: string; id: 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));
}
// OG image routes are deliberately not prerendered — they're rendered at
@@ -57,14 +61,14 @@ function listModulePaths(): string[] {
// as a file vs `og/registry/...` as a directory).
export function listPrerenderPages() {
const docs = listDocsPaths();
const modules = listModulePaths();
const registry = listRegistryPaths();
const paths = [
"/",
...docs,
...docs.map((p) => `${p}.md`),
"/docs/llms.txt",
"/docs/llms-full.txt",
...modules,
...registry,
"/stats",
];
return paths.map((path) => ({ path, prerender: { enabled: true } }));
+21
View File
@@ -18,6 +18,7 @@ import { Route as DocsLlmsDottxtRouteImport } from './routes/docs.llms[.]txt'
import { Route as DocsLlmsFullDottxtRouteImport } from './routes/docs.llms-full[.]txt'
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 ApiSearchModulesRouteImport } from './routes/api.search.modules'
@@ -69,6 +70,11 @@ const ApiEventsRoute = ApiEventsRouteImport.update({
path: '/api/events',
getParentRoute: () => rootRouteImport,
} as any)
const RegistryCategoryIndexRoute = RegistryCategoryIndexRouteImport.update({
id: '/registry/$category/',
path: '/registry/$category/',
getParentRoute: () => rootRouteImport,
} as any)
const RegistryCategoryIdRoute = RegistryCategoryIdRouteImport.update({
id: '/registry/$category/$id',
path: '/registry/$category/$id',
@@ -109,6 +115,7 @@ export interface FileRoutesByFullPath {
'/api/search/modules': typeof ApiSearchModulesRoute
'/og/docs/$': typeof OgDocsSplatRoute
'/registry/$category/$id': typeof RegistryCategoryIdRoute
'/registry/$category/': typeof RegistryCategoryIndexRoute
'/og/registry/$category/$id': typeof OgRegistryCategoryIdRoute
}
export interface FileRoutesByTo {
@@ -125,6 +132,7 @@ export interface FileRoutesByTo {
'/api/search/modules': typeof ApiSearchModulesRoute
'/og/docs/$': typeof OgDocsSplatRoute
'/registry/$category/$id': typeof RegistryCategoryIdRoute
'/registry/$category': typeof RegistryCategoryIndexRoute
'/og/registry/$category/$id': typeof OgRegistryCategoryIdRoute
}
export interface FileRoutesById {
@@ -142,6 +150,7 @@ export interface FileRoutesById {
'/api/search/modules': typeof ApiSearchModulesRoute
'/og/docs/$': typeof OgDocsSplatRoute
'/registry/$category/$id': typeof RegistryCategoryIdRoute
'/registry/$category/': typeof RegistryCategoryIndexRoute
'/og/registry/$category/$id': typeof OgRegistryCategoryIdRoute
}
export interface FileRouteTypes {
@@ -160,6 +169,7 @@ export interface FileRouteTypes {
| '/api/search/modules'
| '/og/docs/$'
| '/registry/$category/$id'
| '/registry/$category/'
| '/og/registry/$category/$id'
fileRoutesByTo: FileRoutesByTo
to:
@@ -176,6 +186,7 @@ export interface FileRouteTypes {
| '/api/search/modules'
| '/og/docs/$'
| '/registry/$category/$id'
| '/registry/$category'
| '/og/registry/$category/$id'
id:
| '__root__'
@@ -192,6 +203,7 @@ export interface FileRouteTypes {
| '/api/search/modules'
| '/og/docs/$'
| '/registry/$category/$id'
| '/registry/$category/'
| '/og/registry/$category/$id'
fileRoutesById: FileRoutesById
}
@@ -209,6 +221,7 @@ export interface RootRouteChildren {
ApiSearchModulesRoute: typeof ApiSearchModulesRoute
OgDocsSplatRoute: typeof OgDocsSplatRoute
RegistryCategoryIdRoute: typeof RegistryCategoryIdRoute
RegistryCategoryIndexRoute: typeof RegistryCategoryIndexRoute
OgRegistryCategoryIdRoute: typeof OgRegistryCategoryIdRoute
}
@@ -277,6 +290,13 @@ declare module '@tanstack/react-router' {
preLoaderRoute: typeof ApiEventsRouteImport
parentRoute: typeof rootRouteImport
}
'/registry/$category/': {
id: '/registry/$category/'
path: '/registry/$category'
fullPath: '/registry/$category/'
preLoaderRoute: typeof RegistryCategoryIndexRouteImport
parentRoute: typeof rootRouteImport
}
'/registry/$category/$id': {
id: '/registry/$category/$id'
path: '/registry/$category/$id'
@@ -329,6 +349,7 @@ const rootRouteChildren: RootRouteChildren = {
ApiSearchModulesRoute: ApiSearchModulesRoute,
OgDocsSplatRoute: OgDocsSplatRoute,
RegistryCategoryIdRoute: RegistryCategoryIdRoute,
RegistryCategoryIndexRoute: RegistryCategoryIndexRoute,
OgRegistryCategoryIdRoute: OgRegistryCategoryIdRoute,
}
export const routeTree = rootRouteImport
@@ -5,9 +5,8 @@ import { Link, createFileRoute, notFound, useNavigate } from "@tanstack/react-ro
import { useCallback, useMemo } from "react";
import { AdapterSwitcher } from "@/components/detail/adapter-switcher";
import { DepsTable } from "@/components/detail/deps-table";
import { EnvTable } from "@/components/detail/env-table";
import { Install } from "@/components/detail/install";
import { DepsTable, ScriptsTable, EnvTable } from "@/components/detail/tables";
import { TemplatesList } from "@/components/detail/templates-list";
import { ModuleLogo } from "@/components/module-logo";
import { Badge } from "@/components/ui/badge";
@@ -153,7 +152,7 @@ function ModuleDetailPage() {
<DepsTable title="Dependencies" entries={effective.dependencies} />
<DepsTable title="Dev dependencies" entries={effective.devDependencies} />
<EnvTable env={effective.env} />
<DepsTable title="Scripts" entries={effective.scripts} />
<ScriptsTable entries={effective.scripts} />
<TemplatesList templates={templates} previews={previews} />
<Install name="my-app" selections={selections} />
</div>
@@ -0,0 +1,106 @@
import type { CategoryId } from "@stanza/registry";
import { categoryLabel, isCategoryId } from "@stanza/registry";
import { Link, createFileRoute, notFound, useLoaderData } from "@tanstack/react-router";
import { useMemo } from "react";
import { Section, SectionList } from "@/components/detail/section";
import { ModuleLogo } from "@/components/module-logo";
import { Separator } from "@/components/ui/separator";
import { buildHead, getWebSiteJsonLd } from "@/lib/seo";
export const Route = createFileRoute("/registry/$category/")({
loader: ({ params }) => {
if (!isCategoryId(params.category)) throw notFound();
return { category: params.category };
},
head: ({ loaderData, params }) => {
const path = `/registry/${params.category}`;
if (!loaderData) return buildHead({ title: "Not found", path });
const label = categoryLabel(loaderData.category);
return buildHead({
title: `${label} modules`,
description: `Every ${label.toLowerCase()} module in the Stanza registry.`,
path,
jsonLd: [getWebSiteJsonLd()],
});
},
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__" });
const modules = useMemo(
() =>
registry.modules
.filter((m) => m.category === category)
.sort((a, b) => a.label.localeCompare(b.label)),
[registry.modules, category],
);
const label = categoryLabel(category);
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>
<div className="flex flex-wrap items-center gap-3">
<h1 className="text-2xl font-medium tracking-tight">{label}</h1>
</div>
<p className="mt-1.5 text-sm text-muted-foreground">{CATEGORY_BLURBS[category]}</p>
</header>
<Separator className="my-8" />
{modules.length === 0 ? (
<p className="text-sm text-muted-foreground">No modules in this category yet.</p>
) : (
<Section title="Modules" count={modules.length}>
<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 px-3 py-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>
)}
</div>
);
}