diff --git a/.babelize/local/babelize-lock.json b/.babelize/local/babelize-lock.json index 3a31b8a..0df9d56 100644 --- a/.babelize/local/babelize-lock.json +++ b/.babelize/local/babelize-lock.json @@ -3310,6 +3310,66 @@ "manual": true } } + }, + "meta.title": { + "sourceHash": "dca9a93e7b6b3390aa7884b3271b21ceba70bdfbd164fbe534a35c8b82a519ef", + "translatedAt": "2026-11-01T20:14:34.000Z", + "languages": { + "es": "Babelize Elements: componentes de UI de localización de código abierto", + "ja": "Babelize Elements - オープンソースのローカライゼーション UI コンポーネント", + "ar": "Babelize Elements - مكوّنات واجهة التوطين مفتوحة المصدر" + }, + "metadata": { + "es": { + "manual": true + }, + "ja": { + "manual": true + }, + "ar": { + "manual": true + } + } + }, + "meta.description": { + "sourceHash": "8b336f7877abdd6c8eebe6c6da6068ace722e50247487348346348977232f1a9", + "translatedAt": "2026-11-01T20:14:34.000Z", + "languages": { + "es": "Componentes de UI de localización de código abierto para React y Tailwind CSS. Selectores de idioma, selectores de configuración regional, widgets de traducción y más, creados por la comunidad.", + "ja": "React と Tailwind CSS 向けのオープンソースのローカライゼーション UI コンポーネント。言語スイッチャー、ロケールピッカー、翻訳ウィジェットなどを、コミュニティと一緒に開発しています。", + "ar": "مكوّنات واجهة التوطين مفتوحة المصدر لـ React و Tailwind CSS: مبدّلات اللغة ومنتقيات الإعدادات المحلية وأدوات الترجمة وغير ذلك، من تطوير المجتمع." + }, + "metadata": { + "es": { + "manual": true + }, + "ja": { + "manual": true + }, + "ar": { + "manual": true + } + } + }, + "meta.ogDescription": { + "sourceHash": "c4c92a08345545e848ae0782628d285f03cdb804cf85b42f0bb27af252927961", + "translatedAt": "2026-11-01T20:14:34.000Z", + "languages": { + "es": "Componentes de UI de localización de código abierto para React y Tailwind CSS. Creados por la comunidad.", + "ja": "React と Tailwind CSS 向けのオープンソースのローカライゼーション UI コンポーネント。コミュニティによる開発。", + "ar": "مكوّنات واجهة التوطين مفتوحة المصدر لـ React و Tailwind CSS، من تطوير المجتمع." + }, + "metadata": { + "es": { + "manual": true + }, + "ja": { + "manual": true + }, + "ar": { + "manual": true + } + } } } } diff --git a/public/locales/ar.json b/public/locales/ar.json index dece718..a9faab2 100644 --- a/public/locales/ar.json +++ b/public/locales/ar.json @@ -157,6 +157,11 @@ "current": "اللغة الحالية: {name}", "listbox": "اختيار اللغة" }, + "meta": { + "description": "مكوّنات واجهة التوطين مفتوحة المصدر لـ React و Tailwind CSS: مبدّلات اللغة ومنتقيات الإعدادات المحلية وأدوات الترجمة وغير ذلك، من تطوير المجتمع.", + "ogDescription": "مكوّنات واجهة التوطين مفتوحة المصدر لـ React و Tailwind CSS، من تطوير المجتمع.", + "title": "Babelize Elements - مكوّنات واجهة التوطين مفتوحة المصدر" + }, "navbar": { "babelizeElements": "Babelize Elements", "components": "مكوّنات", diff --git a/public/locales/en.json b/public/locales/en.json index 4be2987..690497e 100644 --- a/public/locales/en.json +++ b/public/locales/en.json @@ -157,6 +157,11 @@ "current": "Current language: {name}", "listbox": "Select language" }, + "meta": { + "description": "Open-source Localization UI components for React and Tailwind CSS. Language switchers, locale pickers, translation widgets, and more — built by the community.", + "ogDescription": "Open-source Localization UI components for React and Tailwind CSS. Built by the community.", + "title": "Babelize Elements - Open Source Localization UI Components" + }, "navbar": { "babelizeElements": "Babelize Elements", "components": "Components", diff --git a/public/locales/es.json b/public/locales/es.json index 3a6931b..3aecd6f 100644 --- a/public/locales/es.json +++ b/public/locales/es.json @@ -157,6 +157,11 @@ "current": "Idioma actual: {name}", "listbox": "Seleccionar idioma" }, + "meta": { + "description": "Componentes de UI de localización de código abierto para React y Tailwind CSS. Selectores de idioma, selectores de configuración regional, widgets de traducción y más, creados por la comunidad.", + "ogDescription": "Componentes de UI de localización de código abierto para React y Tailwind CSS. Creados por la comunidad.", + "title": "Babelize Elements: componentes de UI de localización de código abierto" + }, "navbar": { "babelizeElements": "Babelize Elements", "components": "Componentes", diff --git a/public/locales/ja.json b/public/locales/ja.json index e38dcb6..e11ced9 100644 --- a/public/locales/ja.json +++ b/public/locales/ja.json @@ -157,6 +157,11 @@ "current": "現在の言語: {name}", "listbox": "言語を選択" }, + "meta": { + "description": "React と Tailwind CSS 向けのオープンソースのローカライゼーション UI コンポーネント。言語スイッチャー、ロケールピッカー、翻訳ウィジェットなどを、コミュニティと一緒に開発しています。", + "ogDescription": "React と Tailwind CSS 向けのオープンソースのローカライゼーション UI コンポーネント。コミュニティによる開発。", + "title": "Babelize Elements - オープンソースのローカライゼーション UI コンポーネント" + }, "navbar": { "babelizeElements": "Babelize Elements", "components": "コンポーネント", diff --git a/src/app/docs/[[...slug]]/page.tsx b/src/app/docs/[[...slug]]/page.tsx index 1e3a612..d71dad2 100644 --- a/src/app/docs/[[...slug]]/page.tsx +++ b/src/app/docs/[[...slug]]/page.tsx @@ -4,6 +4,8 @@ import { getLocale } from "next-intl/server"; import defaultMdxComponents from "fumadocs-ui/mdx"; import { source } from "@/lib/source"; import { localizedHref } from "@/lib/localized-href"; +import { baseOpenGraph, languageAlternates } from "@/lib/locale-path"; +import { locales } from "@/i18n/locales"; import type { ComponentProps } from "react"; import { LanguageSwitcherDemo } from "@/components/docs/language-switcher-demo"; import { Flow, RulebookDownload, Takeaway } from "@/components/docs/rulebook"; @@ -66,13 +68,27 @@ export async function generateStaticParams() { export async function generateMetadata(props: { params: Promise<{ slug?: string[] }> }) { const params = await props.params; - const page = source.getPage(params.slug, await getLocale()); + const locale = await getLocale(); + const page = source.getPage(params.slug, locale); if (!page) notFound(); return { + openGraph: { + ...baseOpenGraph(locale), + url: page.url, + title: page.data.title, + description: page.data.description, + }, title: page.data.title, description: page.data.description, // Self-referencing canonical; resolves against metadataBase in the root layout. - alternates: { canonical: page.url }, + // hreflang only for the languages this page is translated into. + alternates: { + canonical: page.url, + languages: languageAlternates( + page.url, + locales.filter((l) => source.getPage(params.slug, l)?.locale === l), + ), + }, }; } diff --git a/src/app/layout.tsx b/src/app/layout.tsx index 1b74bd8..6d29b66 100644 --- a/src/app/layout.tsx +++ b/src/app/layout.tsx @@ -4,7 +4,9 @@ import { RootProvider } from "fumadocs-ui/provider/next"; import { ThemeProvider } from "next-themes"; import "./globals.css"; import { NextIntlClientProvider, useLocale } from "next-intl"; +import { getLocale, getTranslations } from "next-intl/server"; import { localeDirection } from "../i18n/locales"; +import { baseOpenGraph } from "@/lib/locale-path"; const geistSans = Geist({ variable: "--font-geist-sans", @@ -16,42 +18,41 @@ const geistMono = Geist_Mono({ subsets: ["latin"], }); -export const metadata: Metadata = { - metadataBase: new URL("https://elements.babelize.co/"), - title: { - default: "Babelize Elements - Open Source Localization UI Components", - template: "%s | Babelize Elements", - }, - description: - "Open-source Localization UI components for React and Tailwind CSS. Language switchers, locale pickers, translation widgets, and more — built by the community.", - keywords: [ - "localization", - "i18n", - "internationalization", - "UI components", - "React components", - "Tailwind CSS", - "language switcher", - "locale picker", - "Babelize", - "open source", - ], - openGraph: { - type: "website", - locale: "en_US", - url: "https://elements.babelize.co", - siteName: "Babelize Elements", - title: "Babelize Elements - Open Source Localization UI Components", - description: - "Open-source Localization UI components for React and Tailwind CSS. Built by the community.", - }, - twitter: { - card: "summary_large_image", - title: "Babelize Elements - Open Source Localization UI Components", - description: - "Open-source Localization UI components for React and Tailwind CSS. Built by the community.", - }, -}; +export async function generateMetadata(): Promise { + const locale = await getLocale(); + const t = await getTranslations("meta"); + return { + metadataBase: new URL("https://elements.babelize.co/"), + title: { + default: t("title"), + template: "%s | Babelize Elements", + }, + description: t("description"), + keywords: [ + "localization", + "i18n", + "internationalization", + "UI components", + "React components", + "Tailwind CSS", + "language switcher", + "locale picker", + "Babelize", + "open source", + ], + // Pages set og:url (and docs their own title); a page's openGraph replaces this one. + openGraph: { + ...baseOpenGraph(locale), + title: t("title"), + description: t("ogDescription"), + }, + twitter: { + card: "summary_large_image", + title: t("title"), + description: t("ogDescription"), + }, + }; +} export default function RootLayout({ children }: { children: React.ReactNode }) { const locale = useLocale(); diff --git a/src/app/page.tsx b/src/app/page.tsx index df6cd39..1d4aeff 100644 --- a/src/app/page.tsx +++ b/src/app/page.tsx @@ -7,10 +7,23 @@ import { Faq } from "@/components/landing/faq"; import { Footer } from "@/components/landing/footer"; import { getGitHubStats } from "@/lib/github"; +import { getLocale, getTranslations } from "next-intl/server"; +import { baseOpenGraph, languageAlternates, pathInLocale } from "@/lib/locale-path"; -export const metadata: Metadata = { - alternates: { canonical: "/" }, -}; +// Each language's own URL is canonical (/ja, not /), with the others as hreflang alternates. +export async function generateMetadata(): Promise { + const [locale, t] = await Promise.all([getLocale(), getTranslations("meta")]); + const url = pathInLocale("/", locale); + return { + alternates: { canonical: url, languages: languageAlternates("/") }, + openGraph: { + ...baseOpenGraph(locale), + url, + title: t("title"), + description: t("ogDescription"), + }, + }; +} export default async function Home() { const stats = await getGitHubStats(); diff --git a/src/app/sitemap.ts b/src/app/sitemap.ts index 7bd093a..ca45db6 100644 --- a/src/app/sitemap.ts +++ b/src/app/sitemap.ts @@ -1,18 +1,36 @@ import type { MetadataRoute } from "next"; import { source } from "@/lib/source"; +import { locales } from "@/i18n/locales"; +import { languageAlternates, pathInLocale } from "@/lib/locale-path"; const siteUrl = "https://elements.babelize.co"; +/** Absolute URL as the pages' canonicals write it (the home page without a trailing slash). */ +const absolute = (path: string) => (path === "/" ? siteUrl : `${siteUrl}${path}`); +const absoluteAll = (languages: Record) => + Object.fromEntries(Object.entries(languages).map(([l, path]) => [l, absolute(path)])); + +/** Every page in every language it exists in, each listing the others as alternates. */ export default function sitemap(): MetadataRoute.Sitemap { - return [ - { url: `${siteUrl}/`, changeFrequency: "weekly", priority: 1 }, - ...source - .getLanguages() - .flatMap(({ pages }) => pages) - .map((page) => ({ - url: `${siteUrl}${page.url}`, - changeFrequency: "weekly" as const, + const landing = languageAlternates("/"); + const entries: MetadataRoute.Sitemap = locales.map((locale) => ({ + url: absolute(pathInLocale("/", locale)), + changeFrequency: "weekly", + priority: 1, + alternates: { languages: absoluteAll(landing) }, + })); + + for (const page of source.getPages()) { + const available = locales.filter((l) => source.getPage(page.slugs, l)?.locale === l); + const languages = absoluteAll(languageAlternates(page.url, available)); + for (const locale of available) { + entries.push({ + url: absolute(pathInLocale(page.url, locale)), + changeFrequency: "weekly", priority: 0.8, - })), - ]; + alternates: { languages }, + }); + } + } + return entries; } diff --git a/src/components/site-language-switcher.tsx b/src/components/site-language-switcher.tsx index 25583e7..93a018b 100644 --- a/src/components/site-language-switcher.tsx +++ b/src/components/site-language-switcher.tsx @@ -2,19 +2,12 @@ import { useLocale, useTranslations } from "next-intl"; import { LanguageSwitcher } from "@/registry/components/language-switcher"; -import { locales, sourceLocale } from "@/i18n/locales"; +import { locales } from "@/i18n/locales"; +import { pathInLocale } from "@/lib/locale-path"; const ONE_YEAR = 60 * 60 * 24 * 365; const siteLocales = locales.map((code) => ({ code })); -/** `pathname` in `locale`: `/docs/x` → `/ja/docs/x`; English has no prefix. */ -export function pathInLocale(pathname: string, locale: string): string { - const [, first = "", ...rest] = pathname.split("/"); - const bare = locales.includes(first) && first !== sourceLocale ? `/${rest.join("/")}` : pathname; - if (locale === sourceLocale) return bare; - return bare === "/" ? `/${locale}` : `/${locale}${bare}`; -} - /** * The site's language dropdown, built on our own LanguageSwitcher. Remembers * the choice in NEXT_LOCALE and reloads the same page in that language. diff --git a/src/lib/locale-path.ts b/src/lib/locale-path.ts new file mode 100644 index 0000000..3fb7167 --- /dev/null +++ b/src/lib/locale-path.ts @@ -0,0 +1,43 @@ +import { locales, sourceLocale } from "@/i18n/locales"; + +/** `pathname` in `locale`: `/docs/x` → `/ja/docs/x`; English has no prefix. */ +export function pathInLocale(pathname: string, locale: string): string { + const [, first = "", ...rest] = pathname.split("/"); + const bare = locales.includes(first) && first !== sourceLocale ? `/${rest.join("/")}` : pathname; + if (locale === sourceLocale) return bare; + return bare === "/" ? `/${locale}` : `/${locale}${bare}`; +} + +/** Open Graph `og:locale` per site locale. */ +export const OG_LOCALE: Record = { + en: "en_US", + es: "es_ES", + ja: "ja_JP", + ar: "ar_AR", +}; + +/** The Open Graph fields every page shares, for `locale`; pages add their own url, title and description. */ +export function baseOpenGraph(locale: string) { + return { + type: "website" as const, + siteName: "Babelize Elements", + locale: OG_LOCALE[locale] ?? OG_LOCALE.en, + alternateLocale: Object.entries(OG_LOCALE) + .filter(([code]) => code !== locale) + .map(([, og]) => og), + }; +} + +/** + * hreflang alternates for a page that exists in `available` locales (default: + * all), English as `x-default`. Paths are relative to metadataBase. + */ +export function languageAlternates( + pathname: string, + available: readonly string[] = locales, +): Record { + const languages: Record = {}; + for (const locale of available) languages[locale] = pathInLocale(pathname, locale); + languages["x-default"] = pathInLocale(pathname, sourceLocale); + return languages; +} diff --git a/test/site-language-switcher.test.ts b/test/locale-path.test.ts similarity index 53% rename from test/site-language-switcher.test.ts rename to test/locale-path.test.ts index 40fc37c..021aae9 100644 --- a/test/site-language-switcher.test.ts +++ b/test/locale-path.test.ts @@ -1,5 +1,5 @@ import { describe, expect, it } from "vitest"; -import { pathInLocale } from "@/components/site-language-switcher"; +import { languageAlternates, pathInLocale } from "@/lib/locale-path"; describe("pathInLocale", () => { it("adds the language prefix, keeping the page", () => { @@ -19,3 +19,23 @@ describe("pathInLocale", () => { expect(pathInLocale("/docs/es-module", "ja")).toBe("/ja/docs/es-module"); }); }); + +describe("languageAlternates", () => { + it("lists every language and English as x-default", () => { + expect(languageAlternates("/docs/components")).toEqual({ + en: "/docs/components", + es: "/es/docs/components", + ja: "/ja/docs/components", + ar: "/ar/docs/components", + "x-default": "/docs/components", + }); + }); + + it("lists only the languages a page exists in, from any language's URL", () => { + expect(languageAlternates("/ja", ["en", "ja"])).toEqual({ + en: "/", + ja: "/ja", + "x-default": "/", + }); + }); +});