返回 CodeWhale
page-meta.ts
根目录 / web / lib / page-meta.ts
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
130 lines TYPESCRIPT