| 1 | import type { Metadata } from "next"; |
| 2 | import { locales } from "./i18n/config"; |
| 3 | |
| 4 | /** Canonical origin for the production site (no trailing slash). */ |
| 5 | export const SITE_URL = "https://codewhale.net"; |
| 6 | |
| 7 | export const SITE_NAME = "Codewhale"; |
| 8 | |
| 9 | /** The one-line product identity, used as the default OG image alt text. */ |
| 10 | export const IDENTITY_PHRASE = |
| 11 | "Dive into the deep so you don't have to — any model, local-first."; |
| 12 | |
| 13 | /** Shared OG card rendered by app/opengraph-image.tsx (1200×630 PNG). */ |
| 14 | const OG_IMAGE = { |
| 15 | url: `${SITE_URL}/opengraph-image`, |
| 16 | width: 1200, |
| 17 | height: 630, |
| 18 | alt: `${SITE_NAME} — ${IDENTITY_PHRASE}`, |
| 19 | }; |
| 20 | |
| 21 | /** Open Graph locale codes per routed locale (BCP 47 with underscore). */ |
| 22 | const OG_LOCALE: Record<string, string> = { |
| 23 | en: "en_US", |
| 24 | zh: "zh_CN", |
| 25 | ja: "ja_JP", |
| 26 | vi: "vi_VN", |
| 27 | ko: "ko_KR", |
| 28 | ru: "ru_RU", |
| 29 | uk: "uk_UA", |
| 30 | es: "es_ES", |
| 31 | "pt-BR": "pt_BR", |
| 32 | }; |
| 33 | |
| 34 | /** |
| 35 | * buildPageMetadata — per-page SEO metadata for the localized site. |
| 36 | * |
| 37 | * Produces a canonical URL for the rendered locale, hreflang alternates for |
| 38 | * every routed locale (derived from `locales` in lib/i18n/config.ts, plus |
| 39 | * `x-default` pointing at the English page), and matching Open Graph / |
| 40 | * Twitter card fields wired to the shared OG image. |
| 41 | * |
| 42 | * @param path Route path WITHOUT the locale prefix, with a leading |
| 43 | * slash: "/" for the homepage, "/install", "/docs", … |
| 44 | * @param locale Locale of the page being rendered (a routed locale). |
| 45 | * @param title Localized page <title> (full string; no template is applied). |
| 46 | * @param description Localized meta description, same locale as `title`. |
| 47 | * |
| 48 | * Usage in a page or layout: |
| 49 | * ```ts |
| 50 | * export async function generateMetadata({ params }) { |
| 51 | * const { locale } = await params; |
| 52 | * const isZh = locale === "zh"; |
| 53 | * return buildPageMetadata({ |
| 54 | * path: "/install", |
| 55 | * locale, |
| 56 | * title: isZh ? "安装 · Codewhale" : "Install · Codewhale", |
| 57 | * description: isZh ? "…" : "…", |
| 58 | * }); |
| 59 | * } |
| 60 | * ``` |
| 61 | */ |
| 62 | export function buildPageMetadata({ |
| 63 | path, |
| 64 | locale, |
| 65 | title, |
| 66 | description, |
| 67 | }: { |
| 68 | path: string; |
| 69 | locale: string; |
| 70 | title: string; |
| 71 | description: string; |
| 72 | }): Metadata { |
| 73 | // "/" → "" so the homepage canonical is /en, not /en/. |
| 74 | const suffix = path === "/" ? "" : path.replace(/\/+$/, ""); |
| 75 | const canonical = `${SITE_URL}/${locale}${suffix}`; |
| 76 | |
| 77 | // hreflang alternates derive from the canonical locale registry, so a new |
| 78 | // routed locale appears on every page without touching call sites. |
| 79 | const languages: Record<string, string> = {}; |
| 80 | for (const l of locales) { |
| 81 | languages[l] = `${SITE_URL}/${l}${suffix}`; |
| 82 | } |
| 83 | languages["x-default"] = `${SITE_URL}/en${suffix}`; |
| 84 | |
| 85 | return { |
| 86 | metadataBase: new URL(SITE_URL), |
| 87 | title, |
| 88 | description, |
| 89 | alternates: { |
| 90 | canonical, |
| 91 | languages, |
| 92 | }, |
| 93 | openGraph: { |
| 94 | title, |
| 95 | description, |
| 96 | url: canonical, |
| 97 | siteName: SITE_NAME, |
| 98 | type: "website", |
| 99 | locale: OG_LOCALE[locale] ?? "en_US", |
| 100 | images: [OG_IMAGE], |
| 101 | }, |
| 102 | twitter: { |
| 103 | card: "summary_large_image", |
| 104 | title, |
| 105 | description, |
| 106 | images: [OG_IMAGE.url], |
| 107 | }, |
| 108 | }; |
| 109 | } |
| 110 |