返回 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 * @param robots Optional robots directives, e.g. `{ index: false, follow: true }`
67 * for draft, auth, or flag-gated pages that must stay out of
68 * search indexes. Omitted → no robots field (site default).
69 *
70 * Usage in a page or layout:
71 * ```ts
72 * export async function generateMetadata({ params }) {
73 * const { locale } = await params;
74 * const t = getFaq(locale);
75 * return buildPageMetadata({
76 * path: "/faq",
77 * locale,
78 * title: t.metaTitle,
79 * description: t.metaDescription,
80 * });
81 * }
82 * ```
83 */
84 export function buildPageMetadata({
85 path,
86 locale,
87 title,
88 description,
89 robots,
90 }: {
91 path: string;
92 locale: string;
93 title: string;
94 description: string;
95 robots?: Metadata["robots"];
96 }): Metadata {
97 // "/" → "" so the homepage canonical is /en, not /en/.
98 const suffix = path === "/" ? "" : path.replace(/\/+$/, "");
99 const canonicalLocale = canonicalLocaleForPath(path, locale);
100 const canonical = `${SITE_URL}/${canonicalLocale}${suffix}`;
101
102 // Only advertise locales with a genuine page-body translation. Partial
103 // locale routes remain usable, but do not become duplicate index entries.
104 const languages: Record<string, string> = {};
105 for (const l of contentLocalesForPath(path)) {
106 languages[l] = `${SITE_URL}/${l}${suffix}`;
107 }
108 languages["x-default"] = `${SITE_URL}/en${suffix}`;
109
110 return {
111 metadataBase: new URL(SITE_URL),
112 title,
113 description,
114 ...(robots === undefined ? {} : { robots }),
115 alternates: {
116 canonical,
117 languages,
118 },
119 openGraph: {
120 title,
121 description,
122 url: canonical,
123 siteName: SITE_NAME,
124 type: "website",
125 locale: OG_LOCALE[canonicalLocale] ?? "en_US",
126 images: [OG_IMAGE],
127 },
128 twitter: {
129 card: "summary_large_image",
130 title,
131 description,
132 images: [{ url: OG_IMAGE.url, alt: OG_ALT }],
133 },
134 };
135 }
136
136 lines TYPESCRIPT