diff --git a/apps/registry/app/[locale]/changelog/page.tsx b/apps/registry/app/[locale]/changelog/page.tsx
index cfa2fa18..9995646d 100644
--- a/apps/registry/app/[locale]/changelog/page.tsx
+++ b/apps/registry/app/[locale]/changelog/page.tsx
@@ -1,7 +1,8 @@
-import { Badge, Breadcrumb, Button, MDXContent, Sidebar } from "@vllnt/ui";
+import { Badge, Breadcrumb, Button, MDXContent } from "@vllnt/ui";
import type { Metadata } from "next";
import { getTranslations, setRequestLocale } from "next-intl/server";
+import { PlatformSidebar } from "@/components/platform-sidebar";
import { Link, type Locale } from "@/i18n/routing";
import { type ChangelogTypeFilter, getChangelogEntries } from "@/lib/changelog";
import { breadcrumbTrailLd, jsonLdScript } from "@/lib/jsonld";
@@ -182,7 +183,7 @@ export default async function ChangelogPage({
}}
type="application/ld+json"
/>
-
+
;
+ searchParams: Promise;
};
const metadata_map = componentMetadata as Record<
@@ -63,6 +71,7 @@ const metadata_map = componentMetadata as Record<
defaultStoryId: string;
description: string;
name: string;
+ platforms: ("native" | "web")[];
stories: { id: string; name: string }[];
title: string;
}
@@ -84,10 +93,6 @@ export async function generateStaticParams() {
);
}
-function getNpmUrl(packageName: string): string {
- return `https://www.npmjs.com/package/${packageName}`;
-}
-
export async function generateMetadata(props: Props): Promise {
const { locale, slug } = await props.params;
const component = registry.items.find(
@@ -103,7 +108,10 @@ export async function generateMetadata(props: Props): Promise {
const category = getCategoryForComponent(slug);
const aiSeo = getAiSeo(slug);
const componentSeo = getComponentSeo(slug);
- const componentMdx = await getComponentContent(slug, locale);
+ const [componentMdx, t] = await Promise.all([
+ getComponentContent(slug, locale),
+ getTranslations({ locale, namespace: "pages.component" }),
+ ]);
// Hand-written copy (ai-seo / component-seo) is English and outranks the
// templated MDX on the default locale. Other locales must use the localized
// MDX frontmatter, never English copy.
@@ -116,17 +124,18 @@ export async function generateMetadata(props: Props): Promise {
: undefined;
const title =
componentMdx?.frontmatter.title ?? meta?.title ?? component.title;
- const description =
- handWrittenDescription ??
- componentMdx?.frontmatter.description ??
- meta?.description ??
- component.description;
+ const description = component.native
+ ? t("nativeMetaDescription", { title })
+ : (handWrittenDescription ??
+ componentMdx?.frontmatter.description ??
+ meta?.description ??
+ component.description);
const pathname = `/components/${slug}`;
const ogParameters = {
category,
description,
- title,
+ title: component.native ? t("nativeMetaTitle", { title }) : title,
type: "component" as const,
};
@@ -141,13 +150,18 @@ export async function generateMetadata(props: Props): Promise {
description,
keywords: componentMdx?.frontmatter.keywords,
openGraph: generateOGMetadata(ogParameters, { locale, pathname }),
- title: handWrittenTitle ?? `${title} - VLLNT UI`,
+ title: component.native
+ ? t("nativeMetaTitle", { title })
+ : (handWrittenTitle ?? `${title} - VLLNT UI`),
twitter: generateTwitterMetadata(ogParameters),
};
}
export default async function ComponentPage(props: Props) {
- const { locale, slug } = await props.params;
+ const [{ locale, slug }, query] = await Promise.all([
+ props.params,
+ props.searchParams,
+ ]);
setRequestLocale(locale);
const t = await getTranslations("pages.component");
const common = await getTranslations("common");
@@ -178,10 +192,10 @@ export default async function ComponentPage(props: Props) {
meta?.description ??
component.description ??
"";
- const playgroundExample = getPlaygroundExample(component);
- const registryPackageVersion = getRegistryPackageVersion(registry.version);
+ const platform = getPlatform(query.platform, "all");
- // Read component source for code display
+ // The browser preview stays on the Web implementation. Paired Native source
+ // is available from the source selector when this component supports it.
let componentCode = "";
try {
const isChartComponent = ["area-chart", "bar-chart", "line-chart"].includes(
@@ -231,15 +245,53 @@ export default async function ComponentPage(props: Props) {
// Source file not found — skip code section
}
+ let nativeCode = "";
+ if (component.native) {
+ try {
+ nativeCode = await readFile(
+ path.join(
+ process.cwd(),
+ "..",
+ "..",
+ "packages",
+ "ui-native",
+ component.native.source,
+ ),
+ "utf8",
+ );
+ } catch {
+ // Native source file not found — leave the Web source without a platform tab.
+ }
+ }
+
+ const sources: ComponentSource[] = componentCode
+ ? [
+ {
+ code: componentCode,
+ id: "react",
+ label: t("sourceReact"),
+ },
+ ]
+ : [];
+ if (nativeCode) {
+ sources.push({
+ code: nativeCode,
+ id: "react-native",
+ label: t("sourceReactNative"),
+ });
+ }
+
const installCommand = `pnpm dlx shadcn@latest add https://ui.vllnt.com/r/${component.name}.json`;
- const componentMdx = await getComponentContent(slug, locale);
+ const localizedComponent = await getComponentContent(slug, locale);
+ const componentMdx = localizedComponent;
+ const hasSources = sources.length > 0;
const mdxKit = buildComponentMdxKit({
- componentCode,
- componentName: component.name,
- example: playgroundExample,
+ component,
+ hasSources,
installCommand,
- packageVersion: registryPackageVersion,
+ sourceLinkLabel: t("viewSource"),
+ storybookLabel: t("viewInStorybook"),
storyId: meta?.defaultStoryId,
});
@@ -257,31 +309,36 @@ export default async function ComponentPage(props: Props) {
.slice(0, 6);
const relatedComponents = relatedSlugs.filter((name) =>
registry.items.some(
- (item) => item.name === name && item.type === "registry:component",
+ (item) =>
+ item.name === name &&
+ item.type === "registry:component" &&
+ (!platform || item.platforms.includes(platform)),
),
);
const sections = [
...(meta?.defaultStoryId ? [{ id: "preview", title: t("preview") }] : []),
+ { id: "platform-comparison", title: t("platformComparison") },
{ id: "installation", title: t("installation") },
- ...(componentCode ? [{ id: "code", title: t("code") }] : []),
+ ...(hasSources && !meta?.defaultStoryId
+ ? [{ id: "code", title: t("code") }]
+ : []),
...(meta?.defaultStoryId
? [{ id: "storybook", title: t("storybook") }]
: []),
- ...(component.dependencies && component.dependencies.length > 0
- ? [{ id: "dependencies", title: t("dependencies") }]
- : []),
- ...(seoCopy?.faqs.length ? [{ id: "faq", title: t("faq") }] : []),
...(relatedComponents.length > 0
? [{ id: "related", title: t("related") }]
: []),
+ ...(seoCopy?.faqs.length ? [{ id: "faq", title: t("faq") }] : []),
] as { id: string; title: string }[];
const SITE_URL = process.env.NEXT_PUBLIC_SITE_URL ?? "https://ui.vllnt.com";
- const articleTitle = componentMdx?.frontmatter.title ?? displayTitle;
- const articleDescription =
- componentMdx?.frontmatter.description ?? displayDescription;
- const componentUrl = canonical(`/components/${component.name}`, locale);
+ const articleTitle = localizedComponent?.frontmatter.title ?? displayTitle;
+ const articleDescription = component.native
+ ? t("nativeMetaDescription", { title: articleTitle })
+ : (localizedComponent?.frontmatter.description ?? displayDescription);
+ const componentPath = `/components/${component.name}`;
+ const componentUrl = canonical(componentPath, locale);
const ogImage = `${SITE_URL}${generateOGImageURL({
category: componentCategory ?? undefined,
description: articleDescription,
@@ -297,9 +354,10 @@ export default async function ComponentPage(props: Props) {
softwareSourceCodeLd({
description: articleDescription,
image: ogImage,
- keywords: componentMdx?.frontmatter.keywords,
+ keywords: localizedComponent?.frontmatter.keywords,
locale,
name: component.name,
+ platforms: component.platforms,
title: articleTitle,
}),
techArticleLd({
@@ -307,7 +365,7 @@ export default async function ComponentPage(props: Props) {
description: articleDescription,
image: ogImage,
inLanguage: locale,
- keywords: componentMdx?.frontmatter.keywords,
+ keywords: localizedComponent?.frontmatter.keywords,
title: articleTitle,
url: componentUrl,
}),
@@ -316,16 +374,13 @@ export default async function ComponentPage(props: Props) {
locale,
[
{ name: common("components"), path: "/components" },
- {
- name: articleTitle,
- path: `/components/${component.name}`,
- },
+ { name: articleTitle, path: componentPath },
],
common("home"),
),
])}
/>
-
{articleTitle}
-
+
{articleDescription}
+
{t("reportBug")}
@@ -391,7 +466,7 @@ export default async function ComponentPage(props: Props) {
{whenToUse}
{t("browseAiComponents")}
@@ -411,9 +486,10 @@ export default async function ComponentPage(props: Props) {
{familyGroup ? (
{familyGroup.label}
@@ -423,6 +499,28 @@ export default async function ComponentPage(props: Props) {
) : null}
+ {meta?.defaultStoryId ? (
+ 0 ? (
+
+ ) : null
+ }
+ componentName={component.name}
+ storyId={meta.defaultStoryId}
+ />
+ ) : hasSources ? (
+
+
+
+ ) : null}
+
{componentMdx ? (
) : (
<>
- {/* Preview + Playground */}
- {meta?.defaultStoryId ? (
-
- ) : null}
+
- {/* Installation */}
{t("installation")}
@@ -449,7 +538,6 @@ export default async function ComponentPage(props: Props) {
- {/* Storybook link */}
{meta?.defaultStoryId ? (
diff --git a/apps/registry/app/[locale]/components/[slug]/playground/page.tsx b/apps/registry/app/[locale]/components/[slug]/playground/page.tsx
index 9bb92488..a82528e6 100644
--- a/apps/registry/app/[locale]/components/[slug]/playground/page.tsx
+++ b/apps/registry/app/[locale]/components/[slug]/playground/page.tsx
@@ -1,8 +1,9 @@
-import { Breadcrumb, Sidebar } from "@vllnt/ui";
+import { Breadcrumb } from "@vllnt/ui";
import type { Metadata } from "next";
import { notFound } from "next/navigation";
import { getTranslations, setRequestLocale } from "next-intl/server";
+import { PlatformSidebar } from "@/components/platform-sidebar";
import { PlaygroundCodePanel } from "@/components/playground";
import { StorybookEmbed } from "@/components/storybook-embed";
import { Link, type Locale, routing } from "@/i18n/routing";
@@ -125,7 +126,7 @@ export default async function ComponentPlaygroundPage(props: Props) {
]),
)}
/>
- ;
+ readonly searchParams: Promise;
};
-export async function generateMetadata({ params }: Props): Promise {
- const { locale } = await params;
- const { frontmatter } = await getPageContent("components", locale);
+export async function generateMetadata({
+ params,
+ searchParams,
+}: Props): Promise {
+ const [{ locale }, query] = await Promise.all([params, searchParams]);
+ const [{ frontmatter }, t] = await Promise.all([
+ getPageContent("components", locale),
+ getTranslations({ locale, namespace: "pages.components" }),
+ ]);
const og = frontmatter.og;
+ const nativeFilter = getPlatform(query.platform, "all") === "native";
+ const pathname = nativeFilter ? "/components?platform=native" : "/components";
+ const title = nativeFilter ? t("nativeMetaTitle") : frontmatter.title;
+ const description = nativeFilter
+ ? t("nativeMetaDescription")
+ : frontmatter.description;
+ const socialTitle = nativeFilter ? title : (og?.title ?? title);
+ const socialDescription = nativeFilter
+ ? description
+ : (og?.description ?? description);
return {
alternates: {
- canonical: canonical("/components", locale),
- languages: languageAlternates("/components"),
+ canonical: canonical(pathname, locale),
+ languages: languageAlternates(pathname),
},
- description: frontmatter.description,
+ description,
openGraph: generateOGMetadata(
{
- description: og?.description ?? frontmatter.description,
- title: og?.title ?? frontmatter.title,
+ description: socialDescription,
+ title: socialTitle,
type: og?.type ?? frontmatter.type,
},
- { locale, pathname: "/components" },
+ { locale, pathname },
),
- title: frontmatter.title,
+ title,
twitter: generateTwitterMetadata({
- description: og?.description ?? frontmatter.description,
- title: og?.title ?? frontmatter.title,
+ description: socialDescription,
+ title: socialTitle,
type: og?.type ?? frontmatter.type,
}),
};
}
-export default async function ComponentsPage({ params }: Props) {
- const { locale } = await params;
+export default async function ComponentsPage({ params, searchParams }: Props) {
+ const [{ locale }, query] = await Promise.all([params, searchParams]);
setRequestLocale(locale);
const t = await getTranslations("pages.components");
const common = await getTranslations("common");
+ const parsedPlatform = componentPlatformSchema.safeParse(query.platform);
+ const selectedPlatform = parsedPlatform.success
+ ? parsedPlatform.data
+ : undefined;
+ const platformsByName = new Map(
+ registry.items.map((item) => [item.name, item.platforms]),
+ );
+ const visibleGroups = groupedComponents
+ .map((group) => ({
+ ...group,
+ items: selectedPlatform
+ ? group.items.filter((item) =>
+ platformsByName.get(item.name)?.includes(selectedPlatform),
+ )
+ : group.items,
+ }))
+ .filter((group) => group.items.length > 0);
+ const visibleCount = visibleGroups.reduce(
+ (count, group) => count + group.items.length,
+ 0,
+ );
+ const nativeFilter = selectedPlatform === "native";
+ const catalogPathname = nativeFilter
+ ? "/components?platform=native"
+ : "/components";
+ const catalogDescription = t("description", { count: visibleCount });
return (
<>