From 30ebf3ee60502ca8e0bac7aef47f7d3b890bbb6a Mon Sep 17 00:00:00 2001 From: Lstarsky0 <59827030+Lstarsky0@users.noreply.github.com> Date: Wed, 23 Sep 2026 12:03:11 +0800 Subject: [PATCH] feat(web): move legal/terms and legal/privacy onto the dictionary spine (#5337) Copy moved verbatim; the binding English terms stay in lib/legal-copy. Rendered body, title and meta are unchanged across all 18 locales, and CEILING drops to the new count of 15. check-locales now covers both new dictionaries, plus docs-work.ts, which the docs/work migration left out. Signed-off-by: Lstarsky0 <59827030+Lstarsky0@users.noreply.github.com> --- web/app/[locale]/legal/privacy/page.tsx | 25 +++++++--------- web/app/[locale]/legal/terms/page.tsx | 24 +++++++-------- web/lib/i18n/dictionaries.test.ts | 16 ++++++++++ web/lib/i18n/dictionaries/en/legal-privacy.ts | 17 +++++++++++ web/lib/i18n/dictionaries/en/legal-terms.ts | 17 +++++++++++ web/lib/i18n/dictionaries/index.ts | 30 +++++++++++++++++-- web/lib/i18n/dictionaries/types.ts | 22 ++++++++++++++ web/lib/i18n/dictionaries/zh/legal-privacy.ts | 21 +++++++++++++ web/lib/i18n/dictionaries/zh/legal-terms.ts | 21 +++++++++++++ web/lib/i18n/iszh-ceiling.test.ts | 2 +- web/scripts/check-locales.mjs | 3 ++ 11 files changed, 165 insertions(+), 33 deletions(-) create mode 100644 web/lib/i18n/dictionaries/en/legal-privacy.ts create mode 100644 web/lib/i18n/dictionaries/en/legal-terms.ts create mode 100644 web/lib/i18n/dictionaries/zh/legal-privacy.ts create mode 100644 web/lib/i18n/dictionaries/zh/legal-terms.ts diff --git a/web/app/[locale]/legal/privacy/page.tsx b/web/app/[locale]/legal/privacy/page.tsx index 01929eac99..e7f35f9b75 100644 --- a/web/app/[locale]/legal/privacy/page.tsx +++ b/web/app/[locale]/legal/privacy/page.tsx @@ -2,35 +2,30 @@ import Link from "next/link"; import { UsagePreferenceControl } from "@/components/usage-counting"; import { USAGE_COUNTING_COPY } from "@/lib/content/usage-counting"; import { BUILD_FACTS } from "@/lib/facts"; -import { pickText } from "@/lib/i18n/dictionaries"; +import { fill, getLegalPrivacy, pickText } from "@/lib/i18n/dictionaries"; import { buildPageMetadata } from "@/lib/page-meta"; import { LEGAL_UPDATED, PRIVACY_SECTIONS } from "@/lib/legal-copy"; export async function generateMetadata({ params }: { params: Promise<{ locale: string }> }) { const { locale } = await params; - const isZh = locale === "zh"; + const t = getLegalPrivacy(locale); return buildPageMetadata({ path: "/legal/privacy", locale, - title: isZh ? "隐私政策 · Codewhale" : "Privacy policy · Codewhale", - description: isZh - ? "Shannon Labs 如何在你使用 Codewhale 时处理信息。" - : "How Shannon Labs handles information when you use Codewhale.", + title: t.metaTitle, + description: t.metaDescription, }); } export default async function PrivacyPage({ params }: { params: Promise<{ locale: string }> }) { const { locale } = await params; - const isZh = locale === "zh"; + const t = getLegalPrivacy(locale); return (
-

{isZh ? "法律" : "Legal"}

-

{isZh ? "隐私政策" : "Privacy policy"}

-

- {isZh ? "生效并最近更新于" : "Effective and last updated"} {LEGAL_UPDATED} - {isZh ? "。以下为具有约束力的英文文本。" : "."} -

+

{t.kicker}

+

{t.title}

+

{fill(t.updated, { date: LEGAL_UPDATED })}

This policy explains how Shannon Labs handles information when you use Codewhale.

{PRIVACY_SECTIONS.map((section) => (
@@ -43,8 +38,8 @@ export default async function PrivacyPage({ params }: { params: Promise<{ locale

- {isZh ? "服务条款" : "Terms of service"} - {isZh ? "返回首页" : "Back home"} + {t.termsLink} + {t.homeLink}

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: ))}

- {isZh ? "隐私政策" : "Privacy policy"} - {isZh ? "返回首页" : "Back home"} + {t.privacyLink} + {t.homeLink}

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", ];