From 703a1f76cfdf60c824f85b7859e835bf407b04a4 Mon Sep 17 00:00:00 2001 From: Jake Jarvis Date: Wed, 27 May 2026 20:18:28 -0400 Subject: [PATCH] feat: add category index pages and consolidate detail table components MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 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/` that lists all modules in that category - Update `prerender.ts` to emit both `/registry/` (category index) and `/registry//` (module detail) paths so all public registry URLs are prerendered --- apps/web/src/components/detail/deps-table.tsx | 28 ----- apps/web/src/components/detail/env-table.tsx | 26 ----- apps/web/src/components/detail/section.tsx | 24 +++- apps/web/src/components/detail/tables.tsx | 76 +++++++++++++ .../src/components/detail/templates-list.tsx | 2 +- apps/web/src/lib/prerender.ts | 26 +++-- apps/web/src/routeTree.gen.ts | 21 ++++ .../web/src/routes/registry.$category.$id.tsx | 5 +- .../src/routes/registry.$category.index.tsx | 106 ++++++++++++++++++ 9 files changed, 243 insertions(+), 71 deletions(-) delete mode 100644 apps/web/src/components/detail/deps-table.tsx delete mode 100644 apps/web/src/components/detail/env-table.tsx create mode 100644 apps/web/src/components/detail/tables.tsx create mode 100644 apps/web/src/routes/registry.$category.index.tsx diff --git a/apps/web/src/components/detail/deps-table.tsx b/apps/web/src/components/detail/deps-table.tsx deleted file mode 100644 index a6922bf..0000000 --- a/apps/web/src/components/detail/deps-table.tsx +++ /dev/null @@ -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 }) { - const items = Object.entries(entries); - if (items.length === 0) return null; - - return ( -
- - {items.map(([name, version]) => ( -
  • - {name} - {version} -
  • - ))} -
    -
    - ); -} diff --git a/apps/web/src/components/detail/env-table.tsx b/apps/web/src/components/detail/env-table.tsx deleted file mode 100644 index 1b003d8..0000000 --- a/apps/web/src/components/detail/env-table.tsx +++ /dev/null @@ -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 ( -
    - - {env.map((e) => ( -
  • -
    - {e.name} - - {e.required ? "required" : "optional"} - - = {e.example} -
    - {e.description &&

    {e.description}

    } -
  • - ))} -
    -
    - ); -} diff --git a/apps/web/src/components/detail/section.tsx b/apps/web/src/components/detail/section.tsx index 7a3410f..ccf71d3 100644 --- a/apps/web/src/components/detail/section.tsx +++ b/apps/web/src/components/detail/section.tsx @@ -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 (
    -

    {title}

    +
    +

    {title}

    + {count !== undefined && ( + + {count} + + )} +
    {children}
    ); diff --git a/apps/web/src/components/detail/tables.tsx b/apps/web/src/components/detail/tables.tsx new file mode 100644 index 0000000..11ca142 --- /dev/null +++ b/apps/web/src/components/detail/tables.tsx @@ -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 }) { + const items = Object.entries(entries); + if (items.length === 0) return null; + + return ( +
    + + {items.map(([name, version]) => ( +
  • + + + {name} + + {version} + +
  • + ))} +
    +
    + ); +} + +export function ScriptsTable({ entries }: { entries: Record }) { + const items = Object.entries(entries); + if (items.length === 0) return null; + + return ( +
    + + {items.map(([name, command]) => ( +
  • + {name} + {command} +
  • + ))} +
    +
    + ); +} + +export function EnvTable({ env }: { env: EnvVar[] }) { + if (env.length === 0) return null; + + return ( +
    + + {env.map((e) => ( +
  • +
    + {e.name} + + {e.required ? "required" : "optional"} + + = {e.example} +
    + {e.description &&

    {e.description}

    } +
  • + ))} +
    +
    + ); +} diff --git a/apps/web/src/components/detail/templates-list.tsx b/apps/web/src/components/detail/templates-list.tsx index 2b647b4..7fa94b9 100644 --- a/apps/web/src/components/detail/templates-list.tsx +++ b/apps/web/src/components/detail/templates-list.tsx @@ -18,7 +18,7 @@ export function TemplatesList({ }) { if (templates.length === 0) return null; return ( -
    +
    {templates.map((tpl) => ( diff --git a/apps/web/src/lib/prerender.ts b/apps/web/src/lib/prerender.ts index 615597d..cbdf8b6 100644 --- a/apps/web/src/lib/prerender.ts +++ b/apps/web/src/lib/prerender.ts @@ -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/`) and per-module detail pages +// (`/registry//`) 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 } })); diff --git a/apps/web/src/routeTree.gen.ts b/apps/web/src/routeTree.gen.ts index c5b02f0..670fc04 100644 --- a/apps/web/src/routeTree.gen.ts +++ b/apps/web/src/routeTree.gen.ts @@ -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 diff --git a/apps/web/src/routes/registry.$category.$id.tsx b/apps/web/src/routes/registry.$category.$id.tsx index 4ca277e..ebce4bd 100644 --- a/apps/web/src/routes/registry.$category.$id.tsx +++ b/apps/web/src/routes/registry.$category.$id.tsx @@ -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() { - + diff --git a/apps/web/src/routes/registry.$category.index.tsx b/apps/web/src/routes/registry.$category.index.tsx new file mode 100644 index 0000000..cb4bdc6 --- /dev/null +++ b/apps/web/src/routes/registry.$category.index.tsx @@ -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 = { + 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 ( +
    +
    + + ← Back to builder + +
    + +
    +
    +

    {label}

    +
    +

    {CATEGORY_BLURBS[category]}

    +
    + + + + {modules.length === 0 ? ( +

    No modules in this category yet.

    + ) : ( +
    + + {modules.map((m) => ( +
  • + + +
    +

    {m.label}

    +

    + {m.description} +

    +
    + +
  • + ))} +
    +
    + )} +
    + ); +}