mirror of
https://github.com/BigBodyCobain/Shadowbroker.git
synced 2026-08-10 20:50:25 +02:00
i18n: add language toggle, neutrality policy, and codeowner gate (#238)
PR #226 landed the i18n infrastructure and Chinese (zh-CN) translations. This follow-up adds the safeguards that make accepting community translations sustainable without exposing the project to subtle state-aligned framing in future translation PRs. Changes: frontend/src/i18n/index.tsx (renamed from .ts) - Add LOCALES registry: a single source of truth for available languages and their NATIVE display names ("English", "中文 (简体)"). Adding a new language is now a one-entry change here plus a JSON file. - Add isLocale() guard so an unknown value in localStorage falls through to navigator.language detection instead of corrupting state. - File renamed to .tsx because it contains JSX. Next.js tolerated JSX in .ts but Vite/Oxc (used by vitest) does not. frontend/src/components/SettingsPanel.tsx Add a UI language picker to the Settings header — a small <select> populated from LOCALES. Users no longer need the dev console to switch languages. Locale change remains 100% client-side (localStorage), no network call, no telemetry. CONTRIBUTING.md (new) Documents the translation-neutrality requirement that applies symmetrically to all source countries: - Translations must be technically faithful to the English source. - Substitutions aligned with state propaganda from ANY country (PRC, Russia, US, EU, etc.) will be rejected. - The test is: "would a translator working strictly from the English source produce this rendering?" Also explains how translation PRs are reviewed and how to add a new language. .github/CODEOWNERS (new) Auto-requests maintainer review on: - /frontend/src/i18n/ (translation safety) - /backend/auth.py, /backend/routers/wormhole.py, /backend/services/mesh/, /backend/services/fetchers/ (the same paths recent security audits flagged as sensitive) - /.github/workflows/, /.gitlab-ci.yml, /docker-compose*.yml, /helm/ (build/deploy) - /CONTRIBUTING.md, /.github/CODEOWNERS (policy itself) frontend/src/__tests__/i18n/i18nProvider.test.tsx (new, 8 tests) Locks in the i18n contract: - LOCALES has both en and zh-CN with non-empty native labels - Default English when navigator is English - Auto-detect zh-CN when navigator language starts with "zh" - localStorage preference overrides auto-detect - setLocale persists to localStorage - Unknown stored locale falls back to auto-detect - Renders a real zh-CN translation (catches large-scale translation removal in future PRs) - Missing key falls back to the key itself Note: i18n/index.tsx, the language toggle UI, the translation policy, and the test suite together form a defense-in-depth setup. The structural safety guarantee (no network calls, static JSON bundled at build) is intact; this PR makes the social contract around translations explicit and enforceable via branch protection on CODEOWNERS-marked paths. Co-authored-by: Claude Opus 4.7 <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Opus 4.7
parent
9ae0b189ba
commit
e3297e9bc0
@@ -1,73 +0,0 @@
|
||||
'use client';
|
||||
|
||||
import { createContext, useContext, useState, useCallback, type ReactNode } from 'react';
|
||||
import en from './translations/en.json';
|
||||
import zhCN from './translations/zh-CN.json';
|
||||
|
||||
export type Locale = 'en' | 'zh-CN';
|
||||
|
||||
const translations: Record<Locale, Record<string, Record<string, string>>> = { en, 'zh-CN': zhCN };
|
||||
|
||||
interface I18nContextValue {
|
||||
locale: Locale;
|
||||
setLocale: (locale: Locale) => void;
|
||||
t: (key: string) => string;
|
||||
}
|
||||
|
||||
const I18nContext = createContext<I18nContextValue>({
|
||||
locale: 'en',
|
||||
setLocale: () => {},
|
||||
t: (key: string) => key,
|
||||
});
|
||||
|
||||
function resolve(obj: Record<string, unknown>, path: string): string {
|
||||
const parts = path.split('.');
|
||||
let current: unknown = obj;
|
||||
for (const part of parts) {
|
||||
if (current && typeof current === 'object' && part in (current as Record<string, unknown>)) {
|
||||
current = (current as Record<string, unknown>)[part];
|
||||
} else {
|
||||
return path; // fallback to key
|
||||
}
|
||||
}
|
||||
return typeof current === 'string' ? current : path;
|
||||
}
|
||||
|
||||
export function I18nProvider({ children }: { children: ReactNode }) {
|
||||
const [locale, setLocale] = useState<Locale>(() => {
|
||||
if (typeof window === 'undefined') return 'en';
|
||||
const saved = localStorage.getItem('sb_locale');
|
||||
if (saved === 'zh-CN' || saved === 'en') return saved;
|
||||
// Auto-detect browser language
|
||||
const browserLang = navigator.language || '';
|
||||
return browserLang.startsWith('zh') ? 'zh-CN' : 'en';
|
||||
});
|
||||
|
||||
const handleSetLocale = useCallback((newLocale: Locale) => {
|
||||
setLocale(newLocale);
|
||||
if (typeof window !== 'undefined') {
|
||||
localStorage.setItem('sb_locale', newLocale);
|
||||
}
|
||||
}, []);
|
||||
|
||||
const t = useCallback(
|
||||
(key: string): string => {
|
||||
const dict = translations[locale] ?? translations.en;
|
||||
const value = resolve(dict as unknown as Record<string, unknown>, key);
|
||||
return value;
|
||||
},
|
||||
[locale],
|
||||
);
|
||||
|
||||
return (
|
||||
<I18nContext.Provider value={{ locale, setLocale: handleSetLocale, t }}>
|
||||
{children}
|
||||
</I18nContext.Provider>
|
||||
);
|
||||
}
|
||||
|
||||
export function useTranslation() {
|
||||
return useContext(I18nContext);
|
||||
}
|
||||
|
||||
export { I18nContext };
|
||||
@@ -0,0 +1,119 @@
|
||||
'use client';
|
||||
|
||||
import { createContext, useContext, useState, useCallback, type ReactNode } from 'react';
|
||||
import en from './translations/en.json';
|
||||
import zhCN from './translations/zh-CN.json';
|
||||
|
||||
export type Locale = 'en' | 'zh-CN';
|
||||
|
||||
/**
|
||||
* Registry of available locales for the UI language toggle.
|
||||
*
|
||||
* `label` is the language's NATIVE display name (always rendered in
|
||||
* itself, regardless of which language the user is currently in) —
|
||||
* this is the standard convention so the user can recognize their
|
||||
* own language even when the rest of the UI is unfamiliar.
|
||||
*
|
||||
* When adding a new locale:
|
||||
* 1. Add the translation JSON under translations/
|
||||
* 2. Import it above and add to `translations` below
|
||||
* 3. Add an entry here
|
||||
* 4. Extend the `Locale` type
|
||||
* 5. Read CONTRIBUTING.md — translations must be technically faithful
|
||||
* to the English source. Politically loaded substitutions or
|
||||
* framing aligned with state propaganda from ANY country will
|
||||
* be rejected.
|
||||
*/
|
||||
export const LOCALES: ReadonlyArray<{ code: Locale; label: string }> = [
|
||||
{ code: 'en', label: 'English' },
|
||||
{ code: 'zh-CN', label: '中文 (简体)' },
|
||||
];
|
||||
|
||||
const translations: Record<Locale, Record<string, Record<string, string>>> = { en, 'zh-CN': zhCN };
|
||||
|
||||
function isLocale(value: unknown): value is Locale {
|
||||
return typeof value === 'string' && LOCALES.some((entry) => entry.code === value);
|
||||
}
|
||||
|
||||
function resolve(obj: Record<string, unknown>, path: string): string {
|
||||
const parts = path.split('.');
|
||||
let current: unknown = obj;
|
||||
for (const part of parts) {
|
||||
if (current && typeof current === 'object' && part in (current as Record<string, unknown>)) {
|
||||
current = (current as Record<string, unknown>)[part];
|
||||
} else {
|
||||
return path; // fallback to key
|
||||
}
|
||||
}
|
||||
return typeof current === 'string' ? current : path;
|
||||
}
|
||||
|
||||
interface I18nContextValue {
|
||||
locale: Locale;
|
||||
setLocale: (locale: Locale) => void;
|
||||
t: (key: string) => string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Default context value when useTranslation() is called outside an
|
||||
* I18nProvider. Resolves keys against the bundled English JSON so
|
||||
* unwrapped components (and tests that render in isolation) still
|
||||
* show real English text instead of raw i18n keys.
|
||||
*
|
||||
* Without this fallback, every test that renders a translated component
|
||||
* would need to wrap it in <I18nProvider> — a real maintenance burden,
|
||||
* and a footgun because tests would silently start matching "key.path"
|
||||
* strings instead of failing loud.
|
||||
*
|
||||
* This does not hide bugs: if a key is missing from en.json, resolve()
|
||||
* still returns the literal key (same behavior as the previous default).
|
||||
*/
|
||||
const I18nContext = createContext<I18nContextValue>({
|
||||
locale: 'en',
|
||||
setLocale: () => {},
|
||||
t: (key: string) => resolve(en as unknown as Record<string, unknown>, key),
|
||||
});
|
||||
|
||||
export function I18nProvider({ children }: { children: ReactNode }) {
|
||||
const [locale, setLocale] = useState<Locale>(() => {
|
||||
if (typeof window === 'undefined') return 'en';
|
||||
const saved = localStorage.getItem('sb_locale');
|
||||
if (isLocale(saved)) return saved;
|
||||
// Auto-detect browser language. Only matches locales we actually
|
||||
// ship — anything else falls through to English.
|
||||
const browserLang = (navigator.language || '').toLowerCase();
|
||||
const match = LOCALES.find((entry) =>
|
||||
entry.code !== 'en' && browserLang.startsWith(entry.code.toLowerCase().split('-')[0]),
|
||||
);
|
||||
return match ? match.code : 'en';
|
||||
});
|
||||
|
||||
const handleSetLocale = useCallback((newLocale: Locale) => {
|
||||
if (!isLocale(newLocale)) return;
|
||||
setLocale(newLocale);
|
||||
if (typeof window !== 'undefined') {
|
||||
localStorage.setItem('sb_locale', newLocale);
|
||||
}
|
||||
}, []);
|
||||
|
||||
const t = useCallback(
|
||||
(key: string): string => {
|
||||
const dict = translations[locale] ?? translations.en;
|
||||
const value = resolve(dict as unknown as Record<string, unknown>, key);
|
||||
return value;
|
||||
},
|
||||
[locale],
|
||||
);
|
||||
|
||||
return (
|
||||
<I18nContext.Provider value={{ locale, setLocale: handleSetLocale, t }}>
|
||||
{children}
|
||||
</I18nContext.Provider>
|
||||
);
|
||||
}
|
||||
|
||||
export function useTranslation() {
|
||||
return useContext(I18nContext);
|
||||
}
|
||||
|
||||
export { I18nContext };
|
||||
Reference in New Issue
Block a user