| 1 | import type { Metadata } from "next"; |
| 2 | import { canonicalLocaleForPath, contentLocalesForPath } from "./i18n/content-locales"; |
| 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 | /** |
| 10 | * The project's public mailboxes, in one copy: the footer renders them and |
| 11 | * the trust page links the security one, so the two surfaces cannot drift. |
| 12 | */ |
| 13 | export const SITE_CONTACT_EMAIL = "help@codewhale.net"; |
| 14 | export const SITE_SECURITY_EMAIL = "hunter@codewhale.net"; |
| 15 | |
| 16 | /** The one-line product identity, used as the default OG image alt text. */ |
| 17 | export const IDENTITY_PHRASE = "Codewhale — build and automate with the models you choose."; |
| 18 | |
| 19 | /** Accessible text for the shared Open Graph card. */ |
| 20 | export const OG_ALT = IDENTITY_PHRASE; |
| 21 | |
| 22 | /** Shared OG card rendered by app/opengraph-image.tsx (1200×630 PNG). */ |
| 23 | const OG_IMAGE = { |
| 24 | url: `${SITE_URL}/opengraph-image`, |
| 25 | width: 1200, |
| 26 | height: 630, |
| 27 | alt: OG_ALT, |
| 28 | }; |
| 29 | |
| 30 | /** Open Graph locale codes per routed locale (BCP 47 with underscore). */ |
| 31 | const OG_LOCALE: Record<string, string> = { |
| 32 | en: "en_US", |
| 33 | zh: "zh_CN", |
| 34 | ja: "ja_JP", |
| 35 | vi: "vi_VN", |
| 36 | ko: "ko_KR", |
| 37 | ru: "ru_RU", |
| 38 | uk: "uk_UA", |
| 39 | es: "es_ES", |
| 40 | fr: "fr_FR", |
| 41 | de: "de_DE", |
| 42 | ca: "ca_ES", |
| 43 | hi: "hi_IN", |
| 44 | tr: "tr_TR", |
| 45 | it: "it_IT", |
| 46 | pl: "pl_PL", |
| 47 | ar: "ar_AR", |
| 48 | "pt-BR": "pt_BR", |
| 49 | id: "id_ID", |
| 50 | }; |
| 51 | |
| 52 | /** |
| 53 | * buildPageMetadata — per-page SEO metadata for the localized site. |
| 54 | * |
| 55 | * Produces a canonical URL for the page body's translated locale, hreflang |
| 56 | * alternates only for genuine translations (plus `x-default` pointing at the |
| 57 | * English page), and matching Open Graph / Twitter card fields wired to the |
| 58 | * shared OG image. Routed partial-locale fallbacks stay accessible, but their |
| 59 | * canonical points to the English source instead of claiming a translation. |
| 60 | * |
| 61 | * @param path Route path WITHOUT the locale prefix, with a leading |
| 62 | * slash: "/" for the homepage, "/install", "/docs", … |
| 63 | * @param locale Locale of the page being rendered (a routed locale). |
| 64 | * @param title Localized page <title> (full string; no template is applied). |
| 65 | * @param description Localized meta description, same locale as `title`. |
| 66 | * |
| 67 | * Usage in a page or layout: |
| 68 | * ```ts |
| 69 | * export async function generateMetadata({ params }) { |
| 70 | * const { locale } = await params; |
| 71 | * const isZh = locale === "zh"; |
| 72 | * return buildPageMetadata({ |
| 73 | * path: "/install", |
| 74 | * locale, |
| 75 | * title: isZh ? "安装 · Codewhale" : "Install · Codewhale", |
| 76 | * description: isZh ? "…" : "…", |
| 77 | * }); |
| 78 | * } |
| 79 | * ``` |
| 80 | */ |
| 81 | export function buildPageMetadata({ |
| 82 | path, |
| 83 | locale, |
| 84 | title, |
| 85 | description, |
| 86 | }: { |
| 87 | path: string; |
| 88 | locale: string; |
| 89 | title: string; |
| 90 | description: string; |
| 91 | }): Metadata { |
| 92 | // "/" → "" so the homepage canonical is /en, not /en/. |
| 93 | const suffix = path === "/" ? "" : path.replace(/\/+$/, ""); |
| 94 | const canonicalLocale = canonicalLocaleForPath(path, locale); |
| 95 | const canonical = `${SITE_URL}/${canonicalLocale}${suffix}`; |
| 96 | |
| 97 | // Only advertise locales with a genuine page-body translation. Partial |
| 98 | // locale routes remain usable, but do not become duplicate index entries. |
| 99 | const languages: Record<string, string> = {}; |
| 100 | for (const l of contentLocalesForPath(path)) { |
| 101 | languages[l] = `${SITE_URL}/${l}${suffix}`; |
| 102 | } |
| 103 | languages["x-default"] = `${SITE_URL}/en${suffix}`; |
| 104 | |
| 105 | return { |
| 106 | metadataBase: new URL(SITE_URL), |
| 107 | title, |
| 108 | description, |
| 109 | alternates: { |
| 110 | canonical, |
| 111 | languages, |
| 112 | }, |
| 113 | openGraph: { |
| 114 | title, |
| 115 | description, |
| 116 | url: canonical, |
| 117 | siteName: SITE_NAME, |
| 118 | type: "website", |
| 119 | locale: OG_LOCALE[canonicalLocale] ?? "en_US", |
| 120 | images: [OG_IMAGE], |
| 121 | }, |
| 122 | twitter: { |
| 123 | card: "summary_large_image", |
| 124 | title, |
| 125 | description, |
| 126 | images: [{ url: OG_IMAGE.url, alt: OG_ALT }], |
| 127 | }, |
| 128 | }; |
| 129 | } |
| 130 |