mirror of
https://github.com/jakejarvis/stanza.git
synced 2026-07-16 18:05:58 -04:00
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:
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
|
||||
@@ -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]} />
|
||||
|
||||
@@ -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 } }));
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user