From 45ab938d17349af66ad5a9b067f78f128b6c8c7a Mon Sep 17 00:00:00 2001 From: bntvllnt Date: Sat, 27 Jun 2026 21:52:11 +0200 Subject: [PATCH 01/16] feat(registry): per-family landing pages MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Add a landing page per component family at /families/[category] — hero, description, component count, and a card grid — generated from the existing `category` enum (11 families; `ai` keeps its curated /ai landing via a new `familyPath` helper). Wires internal links + SEO: - /components family headings and the component-page family breadcrumb crumb now link to the family page. - sitemap: 11 family routes (× locales). - JSON-LD: new `collectionPageLd` (CollectionPage + ItemList) + BreadcrumbList. - e2e: family landing renders, unknown family 404s, breadcrumb crumb navigates. Closes #463 Claude-Session: https://claude.ai/code/session_01KB2yTDBo7Knydqvz1VvFdF --- .../app/[locale]/components/[slug]/page.tsx | 13 +- .../registry/app/[locale]/components/page.tsx | 10 +- .../app/[locale]/families/[category]/page.tsx | 189 ++++++++++++++++++ apps/registry/app/sitemap.ts | 17 ++ apps/registry/e2e/family-homepage.spec.ts | 47 +++++ apps/registry/lib/component-categories.ts | 56 +++++- apps/registry/lib/jsonld.ts | 25 +++ apps/registry/lib/sidebar-sections.ts | 2 + 8 files changed, 356 insertions(+), 3 deletions(-) create mode 100644 apps/registry/app/[locale]/families/[category]/page.tsx create mode 100644 apps/registry/e2e/family-homepage.spec.ts diff --git a/apps/registry/app/[locale]/components/[slug]/page.tsx b/apps/registry/app/[locale]/components/[slug]/page.tsx index bafd7017..2f834440 100644 --- a/apps/registry/app/[locale]/components/[slug]/page.tsx +++ b/apps/registry/app/[locale]/components/[slug]/page.tsx @@ -34,6 +34,7 @@ import { registry } from "@/lib/registry"; import { canonical, languageAlternates, localizePathname } from "@/lib/seo"; import { oembedUrl, withRef } from "@/lib/share"; import { + familyPath, getCategoryForComponent, getSidebarSections, groupedComponents, @@ -242,7 +243,17 @@ export default async function ComponentPage(props: Props) { href: localizePathname("/components", locale), label: "Components", }, - ...(familyGroup ? [{ label: familyGroup.label }] : []), + ...(familyGroup + ? [ + { + href: localizePathname( + familyPath(familyGroup.category), + locale, + ), + label: familyGroup.label, + }, + ] + : []), { label: displayTitle }, ]} /> diff --git a/apps/registry/app/[locale]/components/page.tsx b/apps/registry/app/[locale]/components/page.tsx index a309328b..80840c89 100644 --- a/apps/registry/app/[locale]/components/page.tsx +++ b/apps/registry/app/[locale]/components/page.tsx @@ -11,6 +11,7 @@ import { generateOGMetadata, generateTwitterMetadata } from "@/lib/og"; import { canonical, languageAlternates, localizePathname } from "@/lib/seo"; import { components, + familyPath, getSidebarSections, groupedComponents, } from "@/lib/sidebar-sections"; @@ -75,7 +76,14 @@ export default async function ComponentsPage({ params }: Props) { {groupedComponents.map((group) => (
-

{group.label}

+

+ + {group.label} + +

{group.items.map((component) => { const meta = metadata_map[component.name]; diff --git a/apps/registry/app/[locale]/families/[category]/page.tsx b/apps/registry/app/[locale]/families/[category]/page.tsx new file mode 100644 index 00000000..f1df0237 --- /dev/null +++ b/apps/registry/app/[locale]/families/[category]/page.tsx @@ -0,0 +1,189 @@ +import { Breadcrumb, Sidebar } from "@vllnt/ui"; +import type { Metadata } from "next"; +import Link from "next/link"; +import { notFound } from "next/navigation"; +import { setRequestLocale } from "next-intl/server"; + +import { ComponentThumbnail } from "@/components/component-thumbnail"; +import { type Locale, routing } from "@/i18n/routing"; +import componentMetadata from "@/lib/component-metadata.json"; +import { + breadcrumbLd, + collectionPageLd, + jsonLdScriptAttributes, +} from "@/lib/jsonld"; +import { generateOGMetadata, generateTwitterMetadata } from "@/lib/og"; +import { canonical, languageAlternates, localizePathname } from "@/lib/seo"; +import { + getCategoryDescription, + getSidebarSections, + groupedComponents, +} from "@/lib/sidebar-sections"; +import type { ComponentCategory } from "@/types/registry"; + +type Props = { + readonly params: Promise<{ category: string; locale: Locale }>; +}; + +const metadata_map = componentMetadata as Record< + string, + { + description: string; + stories: { id: string; name: string }[]; + title: string; + } +>; + +const SITE_URL = process.env.NEXT_PUBLIC_SITE_URL ?? "https://ui.vllnt.ai"; + +/** + * Component families that get their own landing page. `ai` stays out — its + * curated landing lives at `/ai` (see `familyPath`). + */ +const familyGroups = groupedComponents.filter( + (group) => group.category !== "ai", +); + +function findFamily(category: string) { + return familyGroups.find((group) => group.category === category); +} + +export function generateStaticParams(): { + category: ComponentCategory; + locale: Locale; +}[] { + return routing.locales.flatMap((locale) => + familyGroups.map((group) => ({ category: group.category, locale })), + ); +} + +export async function generateMetadata({ params }: Props): Promise { + const { category, locale } = await params; + const group = findFamily(category); + + if (!group) { + return {}; + } + + const pathname = `/families/${category}`; + const title = `${group.label} components`; + const description = getCategoryDescription(group.category); + + return { + alternates: { + canonical: canonical(pathname, locale), + languages: languageAlternates(pathname), + }, + description, + openGraph: generateOGMetadata( + { category: group.label, description, title, type: "page" }, + { locale, pathname }, + ), + title, + twitter: generateTwitterMetadata({ + category: group.label, + description, + title, + type: "page", + }), + }; +} + +export default async function FamilyPage({ params }: Props) { + const { category, locale } = await params; + setRequestLocale(locale); + + const group = findFamily(category); + + if (!group) { + notFound(); + } + + const description = getCategoryDescription(group.category); + const pathname = `/families/${category}`; + + return ( + <> +