diff --git a/apps/registry/app/llms-full.txt/route.ts b/apps/registry/app/llms-full.txt/route.ts index c1c5fb42..6cd49e95 100644 --- a/apps/registry/app/llms-full.txt/route.ts +++ b/apps/registry/app/llms-full.txt/route.ts @@ -1,20 +1,25 @@ import { readFile } from "node:fs/promises"; import path from "node:path"; +import { + getTemplateGithubUrl, + getTemplatePath, + TEMPLATES, +} from "../../lib/templates"; import registry from "../../registry.json"; const SITE_URL = process.env.NEXT_PUBLIC_SITE_URL ?? "https://ui.vllnt.ai"; type RegistryItem = { - readonly name: string; - readonly title: string; - readonly description: string; readonly category: string; readonly dependencies?: readonly string[]; + readonly description: string; + readonly name: string; readonly registryDependencies?: readonly string[]; + readonly title: string; }; -const DOC_PAGES: ReadonlyArray<{ slug: string; title: string }> = [ +const DOC_PAGES: readonly { slug: string; title: string }[] = [ { slug: "home", title: "Get Started" }, { slug: "docs", title: "Documentation" }, { slug: "philosophy", title: "Philosophy" }, @@ -28,13 +33,8 @@ function stripFrontmatter(source: string): string { return source.slice(end + 4).replace(/^\n+/, ""); } -async function readDocPage(slug: string): Promise { - const file = path.join( - process.cwd(), - "content", - "pages", - `${slug}.mdx`, - ); +async function readDocumentPage(slug: string): Promise { + const file = path.join(process.cwd(), "content", "pages", `${slug}.mdx`); try { const raw = await readFile(file, "utf8"); return stripFrontmatter(raw).trim(); @@ -61,12 +61,11 @@ async function buildLlmsFullTxt(): Promise { lines.push(""); lines.push("```bash"); lines.push(`pnpm dlx shadcn@latest add ${SITE_URL}/r/.json`); - lines.push(`# Or with npm: npx shadcn@latest add ${SITE_URL}/r/.json`); lines.push("```"); lines.push(""); for (const page of DOC_PAGES) { - const body = await readDocPage(page.slug); + const body = await readDocumentPage(page.slug); if (!body) continue; lines.push(`## ${page.title}`); lines.push(""); @@ -76,6 +75,25 @@ async function buildLlmsFullTxt(): Promise { lines.push(""); } + lines.push("## Templates"); + lines.push(""); + lines.push( + "Starter kits pair complete app shapes with component lists and source paths that can be copied or adapted until a supported template CLI is available.", + ); + lines.push(""); + + for (const template of TEMPLATES) { + lines.push(`### ${template.title}`); + lines.push(""); + lines.push(`- Slug: \`${template.slug}\``); + lines.push(`- Page: ${SITE_URL}${getTemplatePath(template)}`); + lines.push(`- Demo: ${template.demoUrl}`); + lines.push(`- Source: ${getTemplateGithubUrl(template)}`); + lines.push(`- Audience: ${template.audience}`); + lines.push(`- Components: ${template.components.join(", ")}`); + lines.push(""); + } + lines.push("## Components"); lines.push(""); lines.push( @@ -107,14 +125,15 @@ async function buildLlmsFullTxt(): Promise { } export const dynamic = "force-static"; -export const revalidate = 86400; +export const revalidate = 86_400; export async function GET(): Promise { const body = await buildLlmsFullTxt(); return new Response(body, { headers: { + "Cache-Control": + "public, max-age=0, s-maxage=86400, stale-while-revalidate=604800", "Content-Type": "text/plain; charset=utf-8", - "Cache-Control": "public, max-age=0, s-maxage=86400, stale-while-revalidate=604800", }, }); } diff --git a/apps/registry/app/llms.txt/route.ts b/apps/registry/app/llms.txt/route.ts index 32130fb2..75caf853 100644 --- a/apps/registry/app/llms.txt/route.ts +++ b/apps/registry/app/llms.txt/route.ts @@ -1,12 +1,17 @@ +import { + getTemplateGithubUrl, + getTemplatePath, + TEMPLATES, +} from "../../lib/templates"; import registry from "../../registry.json"; const SITE_URL = process.env.NEXT_PUBLIC_SITE_URL ?? "https://ui.vllnt.ai"; type RegistryItem = { + readonly category: string; + readonly description: string; readonly name: string; readonly title: string; - readonly description: string; - readonly category: string; }; const CATEGORY_ORDER: readonly string[] = [ @@ -51,9 +56,7 @@ function buildLlmsTxt(): string { const sortedCategories = [ ...CATEGORY_ORDER.filter((c) => grouped.has(c)), - ...[...grouped.keys()] - .filter((c) => !CATEGORY_ORDER.includes(c)) - .sort(), + ...[...grouped.keys()].filter((c) => !CATEGORY_ORDER.includes(c)).sort(), ]; const lines: string[] = []; @@ -79,6 +82,19 @@ function buildLlmsTxt(): string { lines.push( `- [Components index](${SITE_URL}/components): browse all components by category`, ); + lines.push( + `- [Templates](${SITE_URL}/templates): starter kits for full VLLNT UI apps`, + ); + lines.push(""); + + lines.push("## Templates"); + lines.push(""); + for (const template of TEMPLATES) { + lines.push( + `- [${template.title}](${SITE_URL}${getTemplatePath(template)}): ` + + `${template.description} Source: ${getTemplateGithubUrl(template)}`, + ); + } lines.push(""); lines.push("## Registry API"); @@ -102,7 +118,9 @@ function buildLlmsTxt(): string { const label = CATEGORY_LABEL[category] ?? category; lines.push(`## Components — ${label}`); lines.push(""); - for (const item of [...bucket].sort((a, b) => a.name.localeCompare(b.name))) { + for (const item of [...bucket].sort((a, b) => + a.name.localeCompare(b.name), + )) { lines.push( `- [${item.title}](${SITE_URL}/components/${item.name}): ${item.description}`, ); @@ -114,13 +132,14 @@ function buildLlmsTxt(): string { } export const dynamic = "force-static"; -export const revalidate = 86400; +export const revalidate = 86_400; export function GET(): Response { return new Response(buildLlmsTxt(), { headers: { + "Cache-Control": + "public, max-age=0, s-maxage=86400, stale-while-revalidate=604800", "Content-Type": "text/plain; charset=utf-8", - "Cache-Control": "public, max-age=0, s-maxage=86400, stale-while-revalidate=604800", }, }); } diff --git a/apps/registry/app/sitemap.ts b/apps/registry/app/sitemap.ts index 816f585b..07db7acb 100644 --- a/apps/registry/app/sitemap.ts +++ b/apps/registry/app/sitemap.ts @@ -1,5 +1,6 @@ import type { MetadataRoute } from "next"; +import { getTemplatePath, TEMPLATES } from "../lib/templates"; import registry from "../registry.json"; const SITE_URL = process.env.NEXT_PUBLIC_SITE_URL ?? "https://ui.vllnt.ai"; @@ -36,8 +37,21 @@ export default function sitemap(): MetadataRoute.Sitemap { changeFrequency: "monthly", priority: 0.6, }, + { + url: `${SITE_URL}/templates`, + lastModified, + changeFrequency: "weekly", + priority: 0.8, + }, ]; + const templateRoutes: MetadataRoute.Sitemap = TEMPLATES.map((template) => ({ + url: `${SITE_URL}${getTemplatePath(template)}`, + lastModified, + changeFrequency: "weekly", + priority: 0.7, + })); + const items = (registry as { readonly items: readonly RegistryItem[] }).items; const componentRoutes: MetadataRoute.Sitemap = items.map((item) => ({ @@ -62,5 +76,10 @@ export default function sitemap(): MetadataRoute.Sitemap { })), ]; - return [...staticRoutes, ...componentRoutes, ...registryEndpoints]; + return [ + ...staticRoutes, + ...templateRoutes, + ...componentRoutes, + ...registryEndpoints, + ]; } diff --git a/apps/registry/app/templates/[slug]/page.tsx b/apps/registry/app/templates/[slug]/page.tsx new file mode 100644 index 00000000..b493d72e --- /dev/null +++ b/apps/registry/app/templates/[slug]/page.tsx @@ -0,0 +1,225 @@ +import { Breadcrumb, Sidebar } from "@vllnt/ui"; +import { ExternalLink, Github } from "lucide-react"; +import type { Metadata } from "next"; +import Image from "next/image"; +import Link from "next/link"; +import { notFound } from "next/navigation"; +import Script from "next/script"; + +import { + breadcrumbLd, + jsonLdScriptAttributes, + softwareApplicationLd, +} from "@/lib/jsonld"; +import { generateOGMetadata, generateTwitterMetadata } from "@/lib/og"; +import { canonical } from "@/lib/seo"; +import { getSidebarSections } from "@/lib/sidebar-sections"; +import { + getTemplate, + getTemplateGithubUrl, + getTemplatePath, + TEMPLATES, +} from "@/lib/templates"; + +const SITE_URL = process.env.NEXT_PUBLIC_SITE_URL ?? "https://ui.vllnt.ai"; + +type Props = { + params: Promise<{ slug: string }>; +}; + +export function generateStaticParams(): { slug: string }[] { + return TEMPLATES.map((template) => ({ slug: template.slug })); +} + +export async function generateMetadata(props: Props): Promise { + const { slug } = await props.params; + const template = getTemplate(slug); + + if (!template) { + return { + title: "Templates - VLLNT UI", + }; + } + + const title = `${template.title} Template - VLLNT UI`; + + return { + alternates: { canonical: canonical(getTemplatePath(template)) }, + description: template.description, + openGraph: generateOGMetadata({ + description: template.description, + title, + type: "docs", + }), + title, + twitter: generateTwitterMetadata({ + description: template.description, + title, + type: "docs", + }), + }; +} + +export default async function TemplatePage(props: Props) { + const { slug } = await props.params; + const template = getTemplate(slug); + + if (!template) { + notFound(); + } + + const templatePath = getTemplatePath(template); + const templateUrl = `${SITE_URL}${templatePath}`; + const githubUrl = getTemplateGithubUrl(template); + + return ( + <> +