mirror of
https://github.com/BigBodyCobain/Shadowbroker.git
synced 2026-08-27 12:53:00 +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
@@ -0,0 +1,32 @@
|
|||||||
|
# CODEOWNERS — assigns required reviewers for sensitive paths.
|
||||||
|
# Format: <path glob> <user-or-team> [<user-or-team> ...]
|
||||||
|
# See https://docs.github.com/en/repositories/managing-your-repositorys-settings-and-features/customizing-your-repository/about-code-owners
|
||||||
|
#
|
||||||
|
# Owners listed here are auto-requested for review when matching files
|
||||||
|
# change in a PR. If branch protection requires CODEOWNERS approval, the
|
||||||
|
# PR cannot be merged until an owner approves.
|
||||||
|
|
||||||
|
# ── Internationalization / translations ──
|
||||||
|
# Translation contributions are held to a stricter neutrality standard
|
||||||
|
# than most code changes — see CONTRIBUTING.md "Translation contributions".
|
||||||
|
# The i18n layer itself (no network calls, no telemetry, static JSON
|
||||||
|
# bundled at build) is the structural guarantee that makes this safe;
|
||||||
|
# changes to it need owner review.
|
||||||
|
/frontend/src/i18n/ @BigBodyCobain
|
||||||
|
|
||||||
|
# ── Security-sensitive code paths ──
|
||||||
|
/backend/auth.py @BigBodyCobain
|
||||||
|
/backend/routers/wormhole.py @BigBodyCobain
|
||||||
|
/backend/services/mesh/ @BigBodyCobain
|
||||||
|
/backend/services/fetchers/ @BigBodyCobain
|
||||||
|
|
||||||
|
# ── CI / build / deploy infra ──
|
||||||
|
/.github/workflows/ @BigBodyCobain
|
||||||
|
/.gitlab-ci.yml @BigBodyCobain
|
||||||
|
/docker-compose.yml @BigBodyCobain
|
||||||
|
/docker-compose.gitlab.yml @BigBodyCobain
|
||||||
|
/helm/ @BigBodyCobain
|
||||||
|
|
||||||
|
# ── This file and policy docs ──
|
||||||
|
/.github/CODEOWNERS @BigBodyCobain
|
||||||
|
/CONTRIBUTING.md @BigBodyCobain
|
||||||
@@ -0,0 +1,75 @@
|
|||||||
|
# Contributing to Shadowbroker
|
||||||
|
|
||||||
|
Thank you for taking the time to contribute. This document covers things specific to this project — for general open-source contribution etiquette, see the GitHub docs.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Code contributions
|
||||||
|
|
||||||
|
1. Fork the repo on GitHub (`bigbodycobain/Shadowbroker`) or GitLab (`bigbodycobain/Shadowbroker` mirror).
|
||||||
|
2. Make your changes on a feature branch.
|
||||||
|
3. Run the local test suite:
|
||||||
|
- Backend: `pytest backend/tests/`
|
||||||
|
- Frontend: `cd frontend && npx vitest run`
|
||||||
|
4. Open a Pull Request against `main`.
|
||||||
|
|
||||||
|
CI runs on every PR. If CI fails, that's blocking — please push fixes rather than asking for it to be merged anyway.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Reporting security issues
|
||||||
|
|
||||||
|
Do **not** file security issues as public GitHub issues. Email the maintainer or use a private security advisory on GitHub. Public disclosure of an exploitable vulnerability without prior coordination will be rejected from the project.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Translation contributions
|
||||||
|
|
||||||
|
Shadowbroker supports UI localization (`frontend/src/i18n/`). Translation contributions are welcome but held to a stricter standard than most code changes, because translations can subtly reshape user perception in ways that are hard to spot during review. Read this section before submitting one.
|
||||||
|
|
||||||
|
### The neutrality requirement
|
||||||
|
|
||||||
|
**Translations must be technically faithful to the English source.** That means:
|
||||||
|
|
||||||
|
- Each `t('key')` entry should mean approximately the same thing in the target language as in English, modulo idiom.
|
||||||
|
- Technical terms with established meanings (e.g. "GPS jamming," "military flight," "Tor," "onion routing," "encryption") should be translated using the corresponding established technical term in the target language — **not** softened, rebranded, or politically reframed.
|
||||||
|
- The set of UI strings should be **the same** between languages. Don't omit features from one locale that are visible in another.
|
||||||
|
|
||||||
|
### What will get a translation PR rejected
|
||||||
|
|
||||||
|
Translation choices that align the project with the framing or terminology of state propaganda — from **any** country — will be rejected. This applies symmetrically:
|
||||||
|
|
||||||
|
| Country / source | Examples of substitutions we will reject |
|
||||||
|
|---|---|
|
||||||
|
| **PRC / CCP** | Calling Taiwan a "province" or "renegade province"; reframing protest layers as "riots"; using softened or euphemistic terms for surveillance, internment, or jamming when the source text is direct |
|
||||||
|
| **Russia** | Calling the Ukraine war a "special military operation"; relabeling occupied territories as Russian; softening sanctions/jamming/disinfo terminology |
|
||||||
|
| **United States / EU** | Reframing adversaries with editorial labels not in the source (e.g. inserting "regime" where the English says "government"); applying labels like "terrorist" or "rogue state" to entities the English source describes neutrally |
|
||||||
|
| **Israel / Palestine / any active conflict** | Substituting one side's preferred terminology when the source uses the other side's or a neutral term |
|
||||||
|
| **Any government** | Adding political slogans, omitting features that government finds inconvenient, or inserting terminology associated with a specific political faction |
|
||||||
|
|
||||||
|
The test is **"would a translator working strictly from the English source produce this rendering?"** If the answer requires assuming a political stance the source does not take, the substitution does not belong in the translation.
|
||||||
|
|
||||||
|
### How translation PRs are reviewed
|
||||||
|
|
||||||
|
Changes to `frontend/src/i18n/**` are owned by the maintainer (see `CODEOWNERS`) and require explicit approval. We will:
|
||||||
|
|
||||||
|
1. Diff the translation against the English source key-by-key.
|
||||||
|
2. Spot-check a sample of entries with a native speaker of the target language when possible.
|
||||||
|
3. Look for the patterns above.
|
||||||
|
4. Look for suspicious additions to the i18n infrastructure itself (e.g. a remote translation fetcher, telemetry on language choice) — the i18n layer is supposed to be 100% client-side static JSON.
|
||||||
|
|
||||||
|
A PR that adds a new language is harder to review than one that fixes typos in an existing language. For new languages, please be patient and expect a real review window. For typo fixes, please describe each change in the PR body so the reviewer can verify intent.
|
||||||
|
|
||||||
|
### What about adding a new language?
|
||||||
|
|
||||||
|
We welcome new languages. The mechanical setup is documented in the header comment of `frontend/src/i18n/index.ts`. Beyond that:
|
||||||
|
|
||||||
|
- We are more likely to merge a new language quickly if at least one reviewer in the maintainer's network speaks it.
|
||||||
|
- If you are the *only* speaker of the target language reading this repo, your translation is welcome but the merge timeline will be longer while a reviewer is found.
|
||||||
|
- Partial translations are fine — the system falls back to English for any missing key.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Anything else
|
||||||
|
|
||||||
|
If you have a question that isn't a security report, opening a GitHub Discussion or a draft PR with a question in the body is the fastest way to get a response. Direct emails are read but not always replied to promptly.
|
||||||
@@ -0,0 +1,131 @@
|
|||||||
|
import React from 'react';
|
||||||
|
import { act, cleanup, render, screen } from '@testing-library/react';
|
||||||
|
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
|
||||||
|
|
||||||
|
import { I18nProvider, LOCALES, useTranslation, type Locale } from '@/i18n';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Renders a tiny consumer so we can drive the I18nContext from tests.
|
||||||
|
*/
|
||||||
|
function Probe({ keyToRender }: { keyToRender: string }) {
|
||||||
|
const { locale, setLocale, t } = useTranslation();
|
||||||
|
return (
|
||||||
|
<div>
|
||||||
|
<span data-testid="locale">{locale}</span>
|
||||||
|
<span data-testid="translated">{t(keyToRender)}</span>
|
||||||
|
<button onClick={() => setLocale('zh-CN')} data-testid="to-zh">go zh</button>
|
||||||
|
<button onClick={() => setLocale('en')} data-testid="to-en">go en</button>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
describe('I18nProvider', () => {
|
||||||
|
beforeEach(() => {
|
||||||
|
localStorage.clear();
|
||||||
|
});
|
||||||
|
|
||||||
|
afterEach(() => {
|
||||||
|
cleanup();
|
||||||
|
localStorage.clear();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('exposes a non-empty LOCALES registry with en and zh-CN', () => {
|
||||||
|
const codes = LOCALES.map((l) => l.code);
|
||||||
|
expect(codes).toContain('en');
|
||||||
|
expect(codes).toContain('zh-CN');
|
||||||
|
// Native labels — used by the language picker. These must be set
|
||||||
|
// so the picker shows the native language name regardless of
|
||||||
|
// current UI locale.
|
||||||
|
for (const entry of LOCALES) {
|
||||||
|
expect(entry.label.length).toBeGreaterThan(0);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
it('defaults to English when no localStorage and English browser', () => {
|
||||||
|
Object.defineProperty(navigator, 'language', { value: 'en-US', configurable: true });
|
||||||
|
render(
|
||||||
|
<I18nProvider>
|
||||||
|
<Probe keyToRender="settings.title" />
|
||||||
|
</I18nProvider>,
|
||||||
|
);
|
||||||
|
expect(screen.getByTestId('locale').textContent).toBe('en');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('auto-detects zh-CN when browser language starts with "zh"', () => {
|
||||||
|
Object.defineProperty(navigator, 'language', { value: 'zh-TW', configurable: true });
|
||||||
|
render(
|
||||||
|
<I18nProvider>
|
||||||
|
<Probe keyToRender="settings.title" />
|
||||||
|
</I18nProvider>,
|
||||||
|
);
|
||||||
|
// "zh-TW" should match the zh prefix and resolve to our zh-CN bundle
|
||||||
|
// (we ship only one Chinese variant for now).
|
||||||
|
expect(screen.getByTestId('locale').textContent).toBe('zh-CN');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('honors a previously saved localStorage choice over auto-detect', () => {
|
||||||
|
Object.defineProperty(navigator, 'language', { value: 'zh-CN', configurable: true });
|
||||||
|
localStorage.setItem('sb_locale', 'en');
|
||||||
|
render(
|
||||||
|
<I18nProvider>
|
||||||
|
<Probe keyToRender="settings.title" />
|
||||||
|
</I18nProvider>,
|
||||||
|
);
|
||||||
|
expect(screen.getByTestId('locale').textContent).toBe('en');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('persists setLocale to localStorage', () => {
|
||||||
|
render(
|
||||||
|
<I18nProvider>
|
||||||
|
<Probe keyToRender="settings.title" />
|
||||||
|
</I18nProvider>,
|
||||||
|
);
|
||||||
|
|
||||||
|
act(() => {
|
||||||
|
screen.getByTestId('to-zh').click();
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(screen.getByTestId('locale').textContent).toBe('zh-CN');
|
||||||
|
expect(localStorage.getItem('sb_locale')).toBe('zh-CN');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('falls back to auto-detect when localStorage holds an unknown locale', () => {
|
||||||
|
// Pre-poison localStorage with a value that isn't in LOCALES. The
|
||||||
|
// isLocale guard at provider init should ignore it and fall through
|
||||||
|
// to navigator.language detection.
|
||||||
|
Object.defineProperty(navigator, 'language', { value: 'en-US', configurable: true });
|
||||||
|
localStorage.setItem('sb_locale', 'klingon' as unknown as Locale);
|
||||||
|
|
||||||
|
render(
|
||||||
|
<I18nProvider>
|
||||||
|
<Probe keyToRender="settings.title" />
|
||||||
|
</I18nProvider>,
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(screen.getByTestId('locale').textContent).toBe('en');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('renders a real translated string from the zh-CN bundle', () => {
|
||||||
|
Object.defineProperty(navigator, 'language', { value: 'zh-CN', configurable: true });
|
||||||
|
render(
|
||||||
|
<I18nProvider>
|
||||||
|
<Probe keyToRender="settings.title" />
|
||||||
|
</I18nProvider>,
|
||||||
|
);
|
||||||
|
// The zh-CN bundle has settings.title = "设置". If this assertion
|
||||||
|
// ever fails after a translation PR, it's a signal that the
|
||||||
|
// translation surface was significantly altered.
|
||||||
|
expect(screen.getByTestId('translated').textContent).toBe('设置');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('falls back to the key when a translation is missing', () => {
|
||||||
|
render(
|
||||||
|
<I18nProvider>
|
||||||
|
<Probe keyToRender="this.key.intentionally.does.not.exist" />
|
||||||
|
</I18nProvider>,
|
||||||
|
);
|
||||||
|
expect(screen.getByTestId('translated').textContent).toBe(
|
||||||
|
'this.key.intentionally.does.not.exist',
|
||||||
|
);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -95,7 +95,7 @@ import {
|
|||||||
setPrivacyStrictPreference,
|
setPrivacyStrictPreference,
|
||||||
setSessionModePreference,
|
setSessionModePreference,
|
||||||
} from '@/lib/privacyBrowserStorage';
|
} from '@/lib/privacyBrowserStorage';
|
||||||
import { useTranslation } from '@/i18n';
|
import { useTranslation, LOCALES, type Locale } from '@/i18n';
|
||||||
|
|
||||||
interface ApiEntry {
|
interface ApiEntry {
|
||||||
id: string;
|
id: string;
|
||||||
@@ -246,7 +246,7 @@ const SettingsPanel = React.memo(function SettingsPanel({
|
|||||||
// settings are authenticated through Rust-side admin-key ownership. The
|
// settings are authenticated through Rust-side admin-key ownership. The
|
||||||
// browser admin-session flow is unnecessary and unavailable in packaged mode.
|
// browser admin-session flow is unnecessary and unavailable in packaged mode.
|
||||||
const nativeProtected = isNativeProtectedSettingsReady();
|
const nativeProtected = isNativeProtectedSettingsReady();
|
||||||
const { t } = useTranslation();
|
const { t, locale, setLocale } = useTranslation();
|
||||||
|
|
||||||
// --- Admin Key (for protected endpoints) ---
|
// --- Admin Key (for protected endpoints) ---
|
||||||
const [adminKey, setAdminKey] = useState('');
|
const [adminKey, setAdminKey] = useState('');
|
||||||
@@ -1136,12 +1136,40 @@ const SettingsPanel = React.memo(function SettingsPanel({
|
|||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<button
|
<div className="flex items-center gap-2">
|
||||||
onClick={onClose}
|
{/*
|
||||||
className="w-8 h-8 border border-[var(--border-primary)] hover:border-red-500/50 flex items-center justify-center text-[var(--text-muted)] hover:text-red-400 transition-all hover:bg-red-950/20"
|
UI language toggle. Locale change is purely client-side
|
||||||
>
|
(persists to localStorage('sb_locale')) — no network call,
|
||||||
<X size={14} />
|
no telemetry. See frontend/src/i18n/index.ts for the list
|
||||||
</button>
|
of available locales and CONTRIBUTING.md for the
|
||||||
|
translation-neutrality policy.
|
||||||
|
*/}
|
||||||
|
<label
|
||||||
|
htmlFor="sb-locale-select"
|
||||||
|
className="text-[11px] tracking-[0.18em] uppercase text-[var(--text-muted)] font-mono"
|
||||||
|
>
|
||||||
|
LANG
|
||||||
|
</label>
|
||||||
|
<select
|
||||||
|
id="sb-locale-select"
|
||||||
|
value={locale}
|
||||||
|
onChange={(e) => setLocale(e.target.value as Locale)}
|
||||||
|
aria-label="UI language"
|
||||||
|
className="h-8 px-2 border border-[var(--border-primary)] bg-[var(--bg-primary)]/60 text-[12px] font-mono text-[var(--text-secondary)] tracking-wider hover:border-cyan-500/50 focus:outline-none focus:border-cyan-500/80 transition-colors"
|
||||||
|
>
|
||||||
|
{LOCALES.map((entry) => (
|
||||||
|
<option key={entry.code} value={entry.code}>
|
||||||
|
{entry.label}
|
||||||
|
</option>
|
||||||
|
))}
|
||||||
|
</select>
|
||||||
|
<button
|
||||||
|
onClick={onClose}
|
||||||
|
className="w-8 h-8 border border-[var(--border-primary)] hover:border-red-500/50 flex items-center justify-center text-[var(--text-muted)] hover:text-red-400 transition-all hover:bg-red-950/20"
|
||||||
|
>
|
||||||
|
<X size={14} />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Operator Tools */}
|
{/* Operator Tools */}
|
||||||
|
|||||||
@@ -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