diff --git a/web/app/[locale]/legal/terms/page.tsx b/web/app/[locale]/legal/terms/page.tsx
index 2968e4db4f..bbe32a7b59 100644
--- a/web/app/[locale]/legal/terms/page.tsx
+++ b/web/app/[locale]/legal/terms/page.tsx
@@ -1,32 +1,28 @@
import Link from "next/link";
+import { fill, getLegalTerms } from "@/lib/i18n/dictionaries";
import { buildPageMetadata } from "@/lib/page-meta";
import { LEGAL_UPDATED, TERMS_SECTIONS } from "@/lib/legal-copy";
export async function generateMetadata({ params }: { params: Promise<{ locale: string }> }) {
const { locale } = await params;
- const isZh = locale === "zh";
+ const t = getLegalTerms(locale);
return buildPageMetadata({
path: "/legal/terms",
locale,
- title: isZh ? "服务条款 · Codewhale" : "Terms of service · Codewhale",
- description: isZh
- ? "Shannon Labs 产品 Codewhale 的服务条款。"
- : "Terms that govern your use of Codewhale, a Shannon Labs product.",
+ title: t.metaTitle,
+ description: t.metaDescription,
});
}
export default async function TermsPage({ params }: { params: Promise<{ locale: string }> }) {
const { locale } = await params;
- const isZh = locale === "zh";
+ const t = getLegalTerms(locale);
return (
-
{isZh ? "法律" : "Legal"}
-
{isZh ? "服务条款" : "Terms of service"}
-
- {isZh ? "生效并最近更新于" : "Effective and last updated"} {LEGAL_UPDATED}
- {isZh ? "。以下为具有约束力的英文文本。" : "."}
-
+
{t.kicker}
+
{t.title}
+
{fill(t.updated, { date: LEGAL_UPDATED })}
These terms govern your use of Codewhale, a Shannon Labs product. By creating an
account or using the service, you agree to them.
@@ -38,8 +34,8 @@ export default async function TermsPage({ params }: { params: Promise<{ locale:
))}
diff --git a/web/lib/i18n/dictionaries.test.ts b/web/lib/i18n/dictionaries.test.ts
index 9eafb358ba..05159be7a1 100644
--- a/web/lib/i18n/dictionaries.test.ts
+++ b/web/lib/i18n/dictionaries.test.ts
@@ -19,6 +19,8 @@ import {
EN_DOCS_SHELL,
EN_DOCS_TROUBLESHOOTING,
EN_HOME,
+ EN_LEGAL_PRIVACY,
+ EN_LEGAL_TERMS,
fill,
getChrome,
getDocsGuide,
@@ -38,6 +40,8 @@ import {
getDocsShell,
getDocsTroubleshooting,
getHome,
+ getLegalPrivacy,
+ getLegalTerms,
pickText,
splitToken,
splitTokens,
@@ -270,6 +274,8 @@ describe("website dictionaries", () => {
["docs-auth", getDocsAuth, EN_DOCS_AUTH],
["docs-trust", getDocsTrust, EN_DOCS_TRUST],
["changelog", getChangelog, EN_CHANGELOG],
+ ["legal-terms", getLegalTerms, EN_LEGAL_TERMS],
+ ["legal-privacy", getLegalPrivacy, EN_LEGAL_PRIVACY],
] as const) {
const enKeys = Object.keys(reference).sort();
for (const locale of [...DICTIONARY_LOCALES, "fr", "und"]) {
@@ -604,6 +610,16 @@ describe("website dictionaries", () => {
}
});
+ it("carries {date} through both legal pages' effective-date line", () => {
+ // The date used to sit in the JSX between two translated fragments. Now
+ // it is a fill() token, so a translation that drops it would ship a legal
+ // page that no longer says when it took effect.
+ for (const locale of ["en", "zh"]) {
+ expect(getLegalTerms(locale).updated, `${locale} legal-terms`).toContain("{date}");
+ expect(getLegalPrivacy(locale).updated, `${locale} legal-privacy`).toContain("{date}");
+ }
+ });
+
it("interpolates templates with fill() and leaves unknown tokens visible", () => {
expect(fill("Latest release {tag}", { tag: "v0.9.2" })).toBe("Latest release v0.9.2");
expect(fill("{count} provider routes", { count: 30 })).toBe("30 provider routes");
diff --git a/web/lib/i18n/dictionaries/en/legal-privacy.ts b/web/lib/i18n/dictionaries/en/legal-privacy.ts
new file mode 100644
index 0000000000..8be47d2f20
--- /dev/null
+++ b/web/lib/i18n/dictionaries/en/legal-privacy.ts
@@ -0,0 +1,17 @@
+import type { LegalPrivacyDict } from "../types";
+
+/**
+ * English reference dictionary for `app/[locale]/legal/privacy/page.tsx`.
+ * Copy moved verbatim from the page's `isZh` ternaries — any wording change
+ * belongs in its own commit, never mixed into a structural move. The binding
+ * legal text itself is English for every locale and stays in `lib/legal-copy`.
+ */
+export const legalPrivacy: LegalPrivacyDict = {
+ metaTitle: "Privacy policy · Codewhale",
+ metaDescription: "How Shannon Labs handles information when you use Codewhale.",
+ kicker: "Legal",
+ title: "Privacy policy",
+ updated: "Effective and last updated {date}.",
+ termsLink: "Terms of service",
+ homeLink: "Back home",
+};
diff --git a/web/lib/i18n/dictionaries/en/legal-terms.ts b/web/lib/i18n/dictionaries/en/legal-terms.ts
new file mode 100644
index 0000000000..126eb1c4bb
--- /dev/null
+++ b/web/lib/i18n/dictionaries/en/legal-terms.ts
@@ -0,0 +1,17 @@
+import type { LegalTermsDict } from "../types";
+
+/**
+ * English reference dictionary for `app/[locale]/legal/terms/page.tsx`.
+ * Copy moved verbatim from the page's `isZh` ternaries — any wording change
+ * belongs in its own commit, never mixed into a structural move. The binding
+ * legal text itself is English for every locale and stays in `lib/legal-copy`.
+ */
+export const legalTerms: LegalTermsDict = {
+ metaTitle: "Terms of service · Codewhale",
+ metaDescription: "Terms that govern your use of Codewhale, a Shannon Labs product.",
+ kicker: "Legal",
+ title: "Terms of service",
+ updated: "Effective and last updated {date}.",
+ privacyLink: "Privacy policy",
+ homeLink: "Back home",
+};
diff --git a/web/lib/i18n/dictionaries/index.ts b/web/lib/i18n/dictionaries/index.ts
index dfdaab6acd..262eb93c90 100644
--- a/web/lib/i18n/dictionaries/index.ts
+++ b/web/lib/i18n/dictionaries/index.ts
@@ -34,6 +34,8 @@ import type {
DocsWebDict,
DocsWorkDict,
HomeDict,
+ LegalPrivacyDict,
+ LegalTermsDict,
StatesDict,
} from "./types";
import { chrome as enChrome } from "./en/chrome";
@@ -94,6 +96,10 @@ import { states as enStates } from "./en/states";
import { states as zhStates } from "./zh/states";
import { changelog as enChangelog } from "./en/changelog";
import { changelog as zhChangelog } from "./zh/changelog";
+import { legalTerms as enLegalTerms } from "./en/legal-terms";
+import { legalTerms as zhLegalTerms } from "./zh/legal-terms";
+import { legalPrivacy as enLegalPrivacy } from "./en/legal-privacy";
+import { legalPrivacy as zhLegalPrivacy } from "./zh/legal-privacy";
import { chrome as zhChrome } from "./zh/chrome";
import { home as zhHome } from "./zh/home";
import { chrome as jaChrome } from "./ja/chrome";
@@ -284,9 +290,9 @@ const DOCS_TRUST: Record = {
};
/**
- * Shared surface states and the changelog page follow the same optional
- * per-locale rule as the docs page dictionaries: English is the reference,
- * every other locale falls back to it at lookup time.
+ * Shared surface states, the changelog page and the two legal pages follow
+ * the same optional per-locale rule as the docs page dictionaries: English is
+ * the reference, every other locale falls back to it at lookup time.
*/
const STATES: Record = {
zh: zhStates,
@@ -296,6 +302,14 @@ const CHANGELOG: Record = {
zh: zhChangelog,
};
+const LEGAL_TERMS: Record = {
+ zh: zhLegalTerms,
+};
+
+const LEGAL_PRIVACY: Record = {
+ zh: zhLegalPrivacy,
+};
+
export function getChrome(locale: string): ChromeDict {
return CHROME[locale] ?? enChrome;
}
@@ -384,6 +398,14 @@ export function getChangelog(locale: string): ChangelogDict {
return CHANGELOG[locale] ?? enChangelog;
}
+export function getLegalTerms(locale: string): LegalTermsDict {
+ return LEGAL_TERMS[locale] ?? enLegalTerms;
+}
+
+export function getLegalPrivacy(locale: string): LegalPrivacyDict {
+ return LEGAL_PRIVACY[locale] ?? enLegalPrivacy;
+}
+
/**
* Select one side of a legacy `{ en, zh }` content pair by locale. This is
* the transitional bridge for `web/lib/content/` modules that still carry
@@ -418,6 +440,8 @@ export const EN_DOCS_TRUST = enDocsTrust;
export const EN_COMPUTER_USE = enComputerUse;
export const EN_STATES = enStates;
export const EN_CHANGELOG = enChangelog;
+export const EN_LEGAL_TERMS = enLegalTerms;
+export const EN_LEGAL_PRIVACY = enLegalPrivacy;
/** Interpolate `{name}` tokens in a dictionary template. Unknown tokens are
* left intact so a template/variable drift is visible in review, not silent. */
diff --git a/web/lib/i18n/dictionaries/types.ts b/web/lib/i18n/dictionaries/types.ts
index b93dfe454a..274b83d688 100644
--- a/web/lib/i18n/dictionaries/types.ts
+++ b/web/lib/i18n/dictionaries/types.ts
@@ -545,6 +545,28 @@ export interface ChangelogDict {
emptyBody: string;
}
+export interface LegalTermsDict {
+ metaTitle: string;
+ metaDescription: string;
+ kicker: string;
+ title: string;
+ /** "Effective and last updated {date}." — zh also says the English text binds. */
+ updated: string;
+ privacyLink: string;
+ homeLink: string;
+}
+
+export interface LegalPrivacyDict {
+ metaTitle: string;
+ metaDescription: string;
+ kicker: string;
+ title: string;
+ /** Same template as `LegalTermsDict.updated`. */
+ updated: string;
+ termsLink: string;
+ homeLink: string;
+}
+
/**
* `app/[locale]/docs/hooks/page.tsx`.
*
diff --git a/web/lib/i18n/dictionaries/zh/legal-privacy.ts b/web/lib/i18n/dictionaries/zh/legal-privacy.ts
new file mode 100644
index 0000000000..3882512fbf
--- /dev/null
+++ b/web/lib/i18n/dictionaries/zh/legal-privacy.ts
@@ -0,0 +1,21 @@
+import type { LegalPrivacyDict } from "../types";
+
+/**
+ * Chinese dictionary for `app/[locale]/legal/privacy/page.tsx`. Only the page
+ * chrome is translated; the terms themselves are binding in English, which is
+ * what `updated` tells the reader.
+ *
+ * Spacing note: the space between `于` and `{date}` came from the literal
+ * space between two JSX expressions, and there is none before `。` because
+ * JSX dropped the newline-only whitespace that separated them. Do not "fix"
+ * either as a typo.
+ */
+export const legalPrivacy: LegalPrivacyDict = {
+ metaTitle: "隐私政策 · Codewhale",
+ metaDescription: "Shannon Labs 如何在你使用 Codewhale 时处理信息。",
+ kicker: "法律",
+ title: "隐私政策",
+ updated: "生效并最近更新于 {date}。以下为具有约束力的英文文本。",
+ termsLink: "服务条款",
+ homeLink: "返回首页",
+};
diff --git a/web/lib/i18n/dictionaries/zh/legal-terms.ts b/web/lib/i18n/dictionaries/zh/legal-terms.ts
new file mode 100644
index 0000000000..d01f3f5608
--- /dev/null
+++ b/web/lib/i18n/dictionaries/zh/legal-terms.ts
@@ -0,0 +1,21 @@
+import type { LegalTermsDict } from "../types";
+
+/**
+ * Chinese dictionary for `app/[locale]/legal/terms/page.tsx`. Only the page
+ * chrome is translated; the terms themselves are binding in English, which is
+ * what `updated` tells the reader.
+ *
+ * Spacing note: the space between `于` and `{date}` came from the literal
+ * space between two JSX expressions, and there is none before `。` because
+ * JSX dropped the newline-only whitespace that separated them. Do not "fix"
+ * either as a typo.
+ */
+export const legalTerms: LegalTermsDict = {
+ metaTitle: "服务条款 · Codewhale",
+ metaDescription: "Shannon Labs 产品 Codewhale 的服务条款。",
+ kicker: "法律",
+ title: "服务条款",
+ updated: "生效并最近更新于 {date}。以下为具有约束力的英文文本。",
+ privacyLink: "隐私政策",
+ homeLink: "返回首页",
+};
diff --git a/web/lib/i18n/iszh-ceiling.test.ts b/web/lib/i18n/iszh-ceiling.test.ts
index 7e2a81f70c..6feb424dcd 100644
--- a/web/lib/i18n/iszh-ceiling.test.ts
+++ b/web/lib/i18n/iszh-ceiling.test.ts
@@ -7,7 +7,7 @@ const MIGRATION_HOME = join("lib", "i18n");
// One-way ceiling (#5519): the isZh migration converges or holds, never
// regresses. To lower the number: migrate branches into lib/i18n, then set
// CEILING to the new count reported by this test.
-const CEILING = 18;
+const CEILING = 15;
function walk(dir: string, out: string[] = []): string[] {
for (const entry of readdirSync(dir)) {
diff --git a/web/scripts/check-locales.mjs b/web/scripts/check-locales.mjs
index d51d7454d1..924fd3c103 100644
--- a/web/scripts/check-locales.mjs
+++ b/web/scripts/check-locales.mjs
@@ -42,8 +42,11 @@ const OPTIONAL_FILES = [
"docs-computers.ts",
"docs-auth.ts",
"docs-trust.ts",
+ "docs-work.ts",
"states.ts",
"changelog.ts",
+ "legal-terms.ts",
+ "legal-privacy.ts",
"computer-use.ts",
];