forked from nexu-io/open-design
-
Notifications
You must be signed in to change notification settings - Fork 0
Expand file tree
/
Copy pathindex.tsx
More file actions
258 lines (233 loc) · 8.56 KB
/
Copy pathindex.tsx
File metadata and controls
258 lines (233 loc) · 8.56 KB
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
'use client';
import {
createContext,
useCallback,
useContext,
useEffect,
useMemo,
useState,
type ReactNode,
} from 'react';
import { de } from './locales/de';
import { en } from './locales/en';
import { id } from './locales/id';
import { esES } from './locales/es-ES';
import { fa } from './locales/fa';
import { ar } from './locales/ar';
import { ja } from './locales/ja';
import { ko } from './locales/ko';
import { ptBR } from './locales/pt-BR';
import { ru } from './locales/ru';
import { zhCN } from './locales/zh-CN';
import { zhTW } from './locales/zh-TW';
import { pl } from './locales/pl';
import { hu } from './locales/hu';
import { fr } from './locales/fr';
import { uk } from './locales/uk';
import { tr } from './locales/tr';
import { th } from './locales/th';
import { it } from './locales/it';
import { getOpenDesignHost } from '@open-design/host';
import { LOCALES, type Dict, type Locale } from './types';
export { LOCALES, LOCALE_LABEL } from './types';
export type { Locale } from './types';
type DictKey = keyof Dict;
const DICTS: Record<Locale, Dict> = {
'en': en,
'id': id,
'de': de,
'zh-CN': zhCN,
'zh-TW': zhTW,
'pt-BR': ptBR,
'es-ES': esES,
'ru': ru,
'fa': fa,
'ar': ar,
'ja': ja,
'ko': ko,
'pl': pl,
'hu': hu,
'fr': fr,
'uk': uk,
'tr': tr,
'th': th,
'it': it,
};
const LS_KEY = 'open-design:locale';
// Marker that says "the value in LS_KEY came from a deliberate user
// action through setLocale, not from some auto-detection path". Only
// values tagged this way win over the desktop host's injected OS
// locale, so a stale auto-detected pick can't pin the app forever once
// the user changes their system language.
const LS_SOURCE_KEY = 'open-design:locale-source';
const MANUAL_LOCALE_SOURCE = 'manual';
export function resolveSystemLocale(languages: readonly string[]): Locale | null {
const supported = LOCALES as readonly string[];
for (const raw of languages) {
const normalized = raw.trim();
if (!normalized) continue;
const exact = LOCALES.find((locale) => locale.toLowerCase() === normalized.toLowerCase());
if (exact) return exact;
const [language, regionOrScript] = normalized.toLowerCase().split('-');
if (language === 'zh') {
if (regionOrScript === 'hant' || regionOrScript === 'tw' || regionOrScript === 'hk' || regionOrScript === 'mo') {
return 'zh-TW';
}
return 'zh-CN';
}
const baseMatch = LOCALES.find((locale) => locale.toLowerCase().split('-')[0] === language);
if (baseMatch && supported.includes(baseMatch)) return baseMatch;
}
return null;
}
/**
* A `t()` bound to an explicit content-language tag rather than the app UI
* locale. Used by the question-form card so host-rendered strings inside the
* card (the "Other" chip, custom-answer copy) match the language the model
* localized the form into — a Chinese form in an English UI must not mix
* scripts. Returns null when the tag doesn't resolve to a bundled locale;
* callers fall back to the context `t`.
*/
export function tForLanguageTag(
tag: string | undefined,
): ((key: DictKey, vars?: Record<string, string | number>) => string) | null {
if (!tag || !tag.trim()) return null;
const locale = resolveSystemLocale([tag]);
if (!locale) return null;
const dict = DICTS[locale] ?? en;
return (key, vars) => {
const raw = dict[key] ?? en[key] ?? key;
if (!vars) return raw;
return raw.replace(/\{(\w+)\}/g, (_, name: string) => {
const v = vars[name];
return v == null ? `{${name}}` : String(v);
});
};
}
// Read the OS locale the desktop host attached to its client descriptor.
// Packaged desktop builds need this because Chromium otherwise reports
// en-US through navigator.language regardless of the OS setting. We go
// through `getOpenDesignHost` rather than reading the bridge global by
// name so the web/preload boundary stays single-source (see the
// `host bridge boundary` guard test).
function readDesktopHostOsLocale(): string | undefined {
if (typeof window === 'undefined') return undefined;
const host = getOpenDesignHost();
const value = host?.client?.osLocale;
return typeof value === 'string' && value.length > 0 ? value : undefined;
}
// First-run defaults to the user's OS / browser language when possible.
// Priority: explicit user pick saved to localStorage (only when tagged
// as manual) > OS locale that the desktop host injected (packaged
// Electron) > navigator.languages > 'en'. The source tag matters
// because untagged localStorage values are treated as legacy /
// auto-detected — they don't override a fresh OS locale read.
// Exported so tests can pin the priority chain without spinning up the
// full I18nProvider.
export function detectInitialLocale(): Locale {
if (typeof window === 'undefined') return 'en';
let storedLocale: string | null = null;
let storedSource: string | null = null;
try {
storedLocale = window.localStorage.getItem(LS_KEY);
storedSource = window.localStorage.getItem(LS_SOURCE_KEY);
} catch {
/* ignore */
}
if (
storedSource === MANUAL_LOCALE_SOURCE &&
storedLocale &&
(LOCALES as string[]).includes(storedLocale)
) {
return storedLocale as Locale;
}
const hostOsLocale = readDesktopHostOsLocale();
if (hostOsLocale) {
const fromHost = resolveSystemLocale([hostOsLocale]);
if (fromHost) return fromHost;
}
const detected = resolveSystemLocale(
navigator.languages?.length ? navigator.languages : [navigator.language],
);
return detected ?? 'en';
}
interface I18nContextValue {
locale: Locale;
setLocale: (next: Locale) => void;
t: (key: DictKey, vars?: Record<string, string | number>) => string;
}
const I18nContext = createContext<I18nContextValue | null>(null);
// Stand-alone English translator used when no provider is mounted (e.g. an
// isolated test). It MUST be a module-level singleton, not rebuilt per render:
// components legitimately list `t` in effect dependency arrays, and inside the
// provider `t` is identity-stable (useCallback on [locale]). A fresh closure
// here would break that contract only on the provider-less path, turning any
// such effect into an infinite render loop that spins instead of failing —
// which reads as a hung test suite rather than a bug.
const FALLBACK_I18N: I18nContextValue = {
locale: 'en',
setLocale: () => { },
t: (key, vars) => {
const raw = en[key] ?? key;
if (!vars) return raw;
return raw.replace(/\{(\w+)\}/g, (_, n: string) => {
const v = vars[n];
return v == null ? `{${n}}` : String(v);
});
},
};
interface ProviderProps {
initial?: Locale;
children: ReactNode;
}
const RTL_LOCALES: Locale[] = ['ar', 'fa'];
export function I18nProvider({ initial, children }: ProviderProps) {
const [locale, setLocaleState] = useState<Locale>(() => initial ?? detectInitialLocale());
// Keep <html lang="…" dir="…"> in sync so screen readers and CSS hooks
// pick the right language token and direction without each component
// having to set it itself.
useEffect(() => {
if (typeof document !== 'undefined') {
const dir = RTL_LOCALES.includes(locale) ? 'rtl' : 'ltr';
document.documentElement.setAttribute('lang', locale);
document.documentElement.setAttribute('dir', dir);
}
}, [locale]);
const setLocale = useCallback((next: Locale) => {
setLocaleState(next);
try {
window.localStorage.setItem(LS_KEY, next);
// Marker so detectInitialLocale knows this came from a deliberate
// user action and should beat the desktop host's OS locale.
window.localStorage.setItem(LS_SOURCE_KEY, MANUAL_LOCALE_SOURCE);
} catch {
/* ignore */
}
}, []);
const t = useCallback(
(key: DictKey, vars?: Record<string, string | number>): string => {
const dict = DICTS[locale] ?? en;
const raw = dict[key] ?? en[key] ?? key;
if (!vars) return raw;
return raw.replace(/\{(\w+)\}/g, (_, name: string) => {
const v = vars[name];
return v == null ? `{${name}}` : String(v);
});
},
[locale],
);
const value = useMemo<I18nContextValue>(
() => ({ locale, setLocale, t }),
[locale, setLocale, t],
);
return <I18nContext.Provider value={value}>{children}</I18nContext.Provider>;
}
export function useI18n(): I18nContextValue {
// Falling back keeps the API safe to call without requiring every callsite
// to wrap in a provider. See FALLBACK_I18N on why it is a shared singleton.
return useContext(I18nContext) ?? FALLBACK_I18N;
}
// Convenience for components that only need the translator function.
export function useT(): I18nContextValue['t'] {
return useI18n().t;
}