Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
214 changes: 214 additions & 0 deletions apps/registry/app/api/og/route.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,214 @@
import React from "react";

import { ImageResponse } from "next/og";
import type { NextRequest } from "next/server";
import { z } from "zod";

import { OG_IMAGE_HEIGHT, OG_IMAGE_WIDTH } from "@/lib/og";
import {
getTemplate,
type OGTemplateType,
truncateText,
} from "@/lib/og-templates";

const parametersSchema = z.object({
category: z.string().max(100).optional(),
description: z.string().max(500).optional(),
title: z.string().min(1).max(200).default("VLLNT UI"),
type: z.enum(["home", "component", "docs", "page"]).default("page"),
url: z.string().max(200).optional(),
});

type ValidatedParameters = z.infer<typeof parametersSchema>;

function parseSearchParameters(
searchParameters: URLSearchParams,
): Record<string, string> {
return Object.fromEntries(
[...searchParameters.entries()].filter(([, value]) => value),
);
}

export const revalidate = 604_800;

const h = React.createElement;

function renderCategoryBadge(category: string): React.ReactElement {
return h(
"div",
{
style: {
alignItems: "center",
display: "flex",
fontSize: 48,
width: "100%",
},
},
h(
"span",
{
style: {
border: "2px solid rgba(255, 255, 255, 0.3)",
borderRadius: 9999,
color: "rgba(255, 255, 255, 0.9)",
padding: "12px 32px",
textTransform: "capitalize" as const,
},
},
category.replaceAll("-", " ").toLowerCase(),
),
);
}

function renderTitle(title: string, fontSize: number): React.ReactElement {
return h(
"h1",
{
style: {
color: "white",
fontSize,
letterSpacing: "-0.03em",
lineHeight: 1.2,
margin: 0,
overflow: "hidden",
textOverflow: "ellipsis",
whiteSpace: "nowrap",
width: "100%",
},
},
title,
);
}

function renderDescription(text: string): React.ReactElement {
return h(
"p",
{
style: {
color: "rgba(255, 255, 255, 0.75)",
fontSize: 64,
lineHeight: 1.4,
marginTop: 83,
},
},
text,
);
}

function renderMainContent(
title: string,
titleSize: number,
description?: string,
): React.ReactElement {
return h(
"div",
{
style: {
display: "flex",
flex: 1,
flexDirection: "column",
justifyContent: "center",
maxWidth: 1600,
},
},
renderTitle(title, titleSize),
description ? renderDescription(description) : undefined,
);
}

function renderFooterLabel(label: string): React.ReactElement {
return h(
"span",
{
style: {
color: "rgba(255, 255, 255, 0.5)",
fontSize: 42,
letterSpacing: "0.15em",
},
},
label,
);
}

function renderFooter(footerLabel?: string): React.ReactElement {
if (!footerLabel) {
return h("div", { style: { display: "flex" } });
}
return h(
"div",
{ style: { alignItems: "center", display: "flex", justifyContent: "flex-end", width: "100%" } },
renderFooterLabel(footerLabel),
);
}

const ROOT_STYLE = {
alignItems: "flex-start",
backgroundColor: "black",
color: "white",
display: "flex",
flexDirection: "column",
fontFamily: "system-ui, sans-serif",
height: "100%",
justifyContent: "space-between",
padding: 120,
width: "100%",
} as const;

function renderOGElement(
parameters: ValidatedParameters,
templateType: OGTemplateType,
): React.ReactElement {
const template = getTemplate(templateType);
const displayTitle = truncateText(parameters.title, template.titleMaxLength);
const displayDescription =
parameters.description && template.descriptionMaxLength
? truncateText(parameters.description, template.descriptionMaxLength)
: parameters.description;

const topSection =
template.showCategory && parameters.category
? renderCategoryBadge(parameters.category)
: h("div", { style: { display: "flex" } });

return h(
"div",
{ style: ROOT_STYLE },
topSection,
renderMainContent(displayTitle, template.titleSize, displayDescription),
renderFooter(template.footerLabel),
);
}

function formatZodErrors(zodError: z.ZodError): string {
return zodError.issues
.map((issue) => `${issue.path.join(".")}: ${issue.message}`)
.join(", ");
}

function handleError(error: unknown): Response {
if (error instanceof z.ZodError) {
return new Response(`Invalid parameters: ${formatZodErrors(error)}`, {
status: 400,
});
}
return new Response(
`Failed to generate image: ${error instanceof Error ? error.message : "Unknown error"}`,
{ status: 500 },
);
}

export function GET(request: NextRequest): ImageResponse | Response {
try {
const { searchParams } = new URL(request.url);
const raw = parseSearchParameters(searchParams);
const parameters = parametersSchema.parse(raw);
const element = renderOGElement(parameters, parameters.type);

return new ImageResponse(element, {
height: OG_IMAGE_HEIGHT,
width: OG_IMAGE_WIDTH,
});
} catch (error) {
return handleError(error);
}
}
41 changes: 22 additions & 19 deletions apps/registry/app/components/[slug]/page.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@ import { notFound } from "next/navigation";

import { ComponentPreview } from "@/components/component-preview";
import { QuickAdd } from "@/components/quick-add";
import { generateOGMetadata, generateTwitterMetadata } from "@/lib/og";
import {
getCategoryForComponent,
getSidebarSections,
Expand Down Expand Up @@ -87,31 +88,33 @@ export async function generateMetadata(props: Props): Promise<Metadata> {

const componentDirectory = getComponentDirectory(component);
const headerPath = path.join(componentDirectory, "header.mdx");
const category = getCategoryForComponent(slug);

let title = `${component.title} - VLLNT UI`;
let description = component.description;

try {
const headerContent = await readFile(headerPath, "utf8");
const { metadata } = parseFrontmatter(headerContent);

return {
description: metadata.description || component.description,
openGraph: {
description: metadata.description || component.description,
title: metadata.title || component.title,
type: "website",
},
title: metadata.title || `${component.title} - VLLNT UI`,
twitter: {
card: "summary_large_image",
description: metadata.description || component.description,
title: metadata.title || component.title,
},
};
title = metadata.title || title;
description = metadata.description || description;
} catch {
return {
description: component.description,
title: `${component.title} - VLLNT UI`,
};
// header.mdx is optional — use registry.json defaults
}

const ogParameters = {
category,
description,
title: component.title,
type: "component" as const,
};

return {
description,
openGraph: generateOGMetadata(ogParameters),
title,
twitter: generateTwitterMetadata(ogParameters),
};
}

export default async function ComponentPage(props: Props) {
Expand Down
23 changes: 23 additions & 0 deletions apps/registry/app/components/page.tsx
Original file line number Diff line number Diff line change
@@ -1,13 +1,36 @@
import { Sidebar } from "@vllnt/ui";
import type { Metadata } from "next";
import Link from "next/link";

import { ComponentPreview } from "@/components/component-preview/component-preview";
import { getPageContent } from "@/lib/content";
import { generateOGMetadata, generateTwitterMetadata } from "@/lib/og";
import {
components,
getSidebarSections,
groupedComponents,
} from "@/lib/sidebar-sections";

export async function generateMetadata(): Promise<Metadata> {
const { frontmatter } = await getPageContent("components");
const og = frontmatter.og;

return {
description: frontmatter.description,
openGraph: generateOGMetadata({
description: og?.description ?? frontmatter.description,
title: og?.title ?? frontmatter.title,
type: og?.type ?? frontmatter.type,
}),
title: frontmatter.title,
twitter: generateTwitterMetadata({
description: og?.description ?? frontmatter.description,
title: og?.title ?? frontmatter.title,
type: og?.type ?? frontmatter.type,
}),
};
}

export default function ComponentsPage() {
return (
<>
Expand Down
50 changes: 25 additions & 25 deletions apps/registry/app/docs/page.tsx
Original file line number Diff line number Diff line change
@@ -1,32 +1,32 @@
import { MDXContent, Sidebar } from "@vllnt/ui";
import type { Metadata } from "next";

import { getPageContent } from "@/lib/content";
import { generateOGMetadata, generateTwitterMetadata } from "@/lib/og";
import { getSidebarSections } from "@/lib/sidebar-sections";

export default async function DocumentationPage() {
const gettingStarted = `# Getting Started

Welcome to VLLNT UI. This is a component registry built with shadcn/ui.

## Installation

Install components using the shadcn CLI:

\`\`\`bash
pnpm dlx shadcn@latest add https://ui.vllnt.com/r/[component-name].json
\`\`\`

## Usage

Import components from \`@vllnt/ui\`:

\`\`\`tsx
import { Button } from '@vllnt/ui'

export function MyComponent() {
return <Button>Click me</Button>
export async function generateMetadata(): Promise<Metadata> {
const { frontmatter } = await getPageContent("docs");
const og = frontmatter.og;

return {
description: frontmatter.description,
openGraph: generateOGMetadata({
description: og?.description ?? frontmatter.description,
title: og?.title ?? frontmatter.title,
type: og?.type ?? frontmatter.type,
}),
title: frontmatter.title,
twitter: generateTwitterMetadata({
description: og?.description ?? frontmatter.description,
title: og?.title ?? frontmatter.title,
type: og?.type ?? frontmatter.type,
}),
};
}
\`\`\`
`;

export default async function DocumentationPage() {
const { content } = await getPageContent("docs");

return (
<>
Expand All @@ -42,7 +42,7 @@ export function MyComponent() {

<div className="prose prose-lg dark:prose-invert max-w-none">
<div className="prose prose-lg dark:prose-invert max-w-none prose-headings:mt-8 prose-headings:font-semibold prose-headings:text-black prose-h1:text-5xl prose-h2:text-4xl prose-h3:text-3xl prose-h4:text-2xl prose-h5:text-xl prose-h6:text-lg dark:prose-headings:text-white prose-p:leading-7 prose-blockquote:mt-6 prose-blockquote:border-l-2 prose-blockquote:pl-6 prose-blockquote:italic prose-ul:my-6 prose-ul:ml-6 prose-ul:list-disc prose-ol:my-6 prose-ol:ml-6 prose-ol:list-decimal prose-code:relative prose-code:rounded prose-code:bg-muted prose-code:px-[0.3rem] prose-code:py-[0.3rem] prose-code:text-sm prose-pre:my-6 prose-pre:overflow-x-auto prose-pre:rounded-lg prose-pre:border prose-pre:bg-black prose-pre:py-4 prose-pre:text-sm prose-pre:text-white prose-pre:shadow-lg dark:prose-pre:bg-zinc-900 prose-hr:my-8 prose-hr:border-border prose-table:w-full prose-table:border-collapse prose-table:border prose-table:border-border prose-th:border prose-th:border-border prose-th:bg-muted prose-th:p-2 prose-th:text-left prose-th:font-medium prose-td:border prose-td:border-border prose-td:p-2 prose-img:rounded-lg prose-img:border prose-img:border-border prose-img:shadow-lg prose-a:font-medium prose-a:text-primary prose-a:underline prose-a:underline-offset-4 hover:prose-a:text-primary/80 prose-strong:font-semibold prose-em:italic prose-blockquote:border-l-primary prose-blockquote:text-muted-foreground">
<MDXContent content={gettingStarted} />
<MDXContent content={content} />
</div>
</div>
</div>
Expand Down
3 changes: 3 additions & 0 deletions apps/registry/app/layout.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,9 @@ import "./globals.css";

export const metadata: Metadata = {
description: "VLLNT UI Component Registry",
metadataBase: new URL(
process.env.NEXT_PUBLIC_SITE_URL ?? "https://ui.vllnt.com",
),
title: "VLLNT UI - Component Registry",
};

Expand Down
Loading
Loading