@object-ui/i18n 17.3.0 → 17.6.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +1929 -1
- package/README.md +41 -12
- package/dist/i18n.d.ts +1 -1
- package/dist/i18n.js +1 -2
- package/dist/index.d.ts +23 -21
- package/dist/index.js +31 -21
- package/dist/locales/ar.d.ts +638 -369
- package/dist/locales/ar.js +744 -424
- package/dist/locales/de.d.ts +638 -369
- package/dist/locales/de.js +759 -446
- package/dist/locales/en.d.ts +673 -404
- package/dist/locales/en.js +899 -406
- package/dist/locales/es.d.ts +638 -369
- package/dist/locales/es.js +739 -427
- package/dist/locales/fr.d.ts +638 -369
- package/dist/locales/fr.js +738 -423
- package/dist/locales/index.d.ts +6476 -3786
- package/dist/locales/index.js +20 -20
- package/dist/locales/ja.d.ts +638 -369
- package/dist/locales/ja.js +732 -417
- package/dist/locales/ko.d.ts +638 -369
- package/dist/locales/ko.js +736 -424
- package/dist/locales/pt.d.ts +638 -369
- package/dist/locales/pt.js +737 -425
- package/dist/locales/ru.d.ts +638 -369
- package/dist/locales/ru.js +750 -424
- package/dist/locales/zh.d.ts +672 -403
- package/dist/locales/zh.js +720 -403
- package/dist/pickLocalized.d.ts +58 -0
- package/dist/pickLocalized.js +115 -7
- package/dist/provider.d.ts +84 -2
- package/dist/provider.js +305 -31
- package/dist/useDisplayLocale.d.ts +49 -0
- package/dist/useDisplayLocale.js +55 -0
- package/dist/useObjectLabel.d.ts +94 -14
- package/dist/useObjectLabel.js +134 -21
- package/dist/useSafeTranslation.js +51 -3
- package/dist/utils/index.d.ts +4 -3
- package/dist/utils/index.js +4 -3
- package/dist/utils/number-display.d.ts +32 -0
- package/dist/utils/number-display.js +32 -0
- package/dist/utils/spec-formatters.d.ts +39 -13
- package/dist/utils/spec-formatters.js +3 -3
- package/package.json +4 -3
package/dist/provider.js
CHANGED
|
@@ -5,8 +5,8 @@
|
|
|
5
5
|
*/
|
|
6
6
|
import React, { createContext, useContext, useEffect, useMemo, useRef, useState } from 'react';
|
|
7
7
|
import { I18nextProvider, useTranslation } from 'react-i18next';
|
|
8
|
-
import { createI18n, getDirection } from './i18n';
|
|
9
|
-
import { builtInLocales } from './locales/index';
|
|
8
|
+
import { createI18n, getDirection } from './i18n.js';
|
|
9
|
+
import { builtInLocales } from './locales/index.js';
|
|
10
10
|
/**
|
|
11
11
|
* `localStorage` key holding the user's explicit language choice.
|
|
12
12
|
*
|
|
@@ -59,46 +59,228 @@ function clearStoredLanguage() {
|
|
|
59
59
|
}
|
|
60
60
|
}
|
|
61
61
|
/**
|
|
62
|
-
*
|
|
63
|
-
*
|
|
62
|
+
* `localStorage` key caching the TENANT's server-side locale — a *seed*, not a
|
|
63
|
+
* choice (objectui#4035).
|
|
64
|
+
*
|
|
65
|
+
* Deliberately a separate slot from {@link LOCALE_STORAGE_KEY}, and that
|
|
66
|
+
* separation is the whole point rather than a tidiness preference. The two
|
|
67
|
+
* values have different provenance and therefore different rights:
|
|
68
|
+
*
|
|
69
|
+
* - {@link LOCALE_STORAGE_KEY} is what the *user* picked. It outranks
|
|
70
|
+
* everything and must survive a tenant reconfiguration.
|
|
71
|
+
* - This slot is what the *admin* configured, cached so the next boot can apply
|
|
72
|
+
* it synchronously. It outranks only the environment (browser language).
|
|
73
|
+
*
|
|
74
|
+
* Writing the server seed into the explicit-choice slot would erase that
|
|
75
|
+
* difference permanently: the seed would then be indistinguishable from a
|
|
76
|
+
* deliberate user choice, so it would (a) outrank a later tenant change and pin
|
|
77
|
+
* the device to a stale locale forever, and (b) suppress browser detection for
|
|
78
|
+
* a user who never expressed a preference. Only a real switch promotes a
|
|
79
|
+
* language to the explicit slot — see the `languageChanged` choke point.
|
|
80
|
+
*/
|
|
81
|
+
export const LOCALE_SEED_STORAGE_KEY = 'objectui-locale-seed';
|
|
82
|
+
/**
|
|
83
|
+
* Read the cached tenant locale seed, or `null` when nothing is cached.
|
|
84
|
+
*
|
|
85
|
+
* Same defensive posture as {@link readStoredLanguage}: `localStorage` is
|
|
86
|
+
* absent under SSR and *throws* in Safari private mode / partitioned iframes,
|
|
87
|
+
* and a language seed is never worth taking the app down for.
|
|
88
|
+
*/
|
|
89
|
+
export function readCachedLanguageSeed() {
|
|
90
|
+
if (typeof window === 'undefined')
|
|
91
|
+
return null;
|
|
92
|
+
try {
|
|
93
|
+
return window.localStorage.getItem(LOCALE_SEED_STORAGE_KEY);
|
|
94
|
+
}
|
|
95
|
+
catch {
|
|
96
|
+
return null;
|
|
97
|
+
}
|
|
98
|
+
}
|
|
99
|
+
/**
|
|
100
|
+
* Cache the tenant locale the server just answered with — the "revalidate" half
|
|
101
|
+
* of the stale-while-revalidate contract (objectstack#5419, ruling point 2).
|
|
102
|
+
*
|
|
103
|
+
* Call this with whatever `/auth/me/localization` actually returned for an
|
|
104
|
+
* authenticated caller. A `null`/`undefined` locale CLEARS the cache rather
|
|
105
|
+
* than leaving the old value: a tenant that unsets its locale must reach
|
|
106
|
+
* choice-less devices on their next boot, and "keep the last good seed forever"
|
|
107
|
+
* is precisely the being-pinned-by-a-stale-seed failure the ruling forbids.
|
|
108
|
+
*
|
|
109
|
+
* A *failed* fetch must simply not call this — the cache is then left intact,
|
|
110
|
+
* which is the correct stale-while-revalidate behaviour when revalidation
|
|
111
|
+
* cannot complete.
|
|
112
|
+
*/
|
|
113
|
+
export function cacheLanguageSeed(locale) {
|
|
114
|
+
if (typeof window === 'undefined')
|
|
115
|
+
return;
|
|
116
|
+
try {
|
|
117
|
+
if (typeof locale === 'string' && locale.length > 0) {
|
|
118
|
+
window.localStorage.setItem(LOCALE_SEED_STORAGE_KEY, locale);
|
|
119
|
+
}
|
|
120
|
+
else {
|
|
121
|
+
window.localStorage.removeItem(LOCALE_SEED_STORAGE_KEY);
|
|
122
|
+
}
|
|
123
|
+
}
|
|
124
|
+
catch {
|
|
125
|
+
// Storage blocked or full — this boot simply goes unseeded.
|
|
126
|
+
}
|
|
127
|
+
}
|
|
128
|
+
/**
|
|
129
|
+
* Languages `createI18n(config)` will know about *synchronously*: the built-in
|
|
130
|
+
* packs plus any extra `config.resources`.
|
|
64
131
|
*
|
|
65
132
|
* `hasOwnProperty` rather than `in`: a junk stored value like `constructor`
|
|
66
133
|
* would pass an `in` check against the locale map's prototype.
|
|
67
134
|
*/
|
|
68
|
-
function
|
|
135
|
+
function isStaticallyKnownLanguage(lang, config) {
|
|
69
136
|
const own = Object.prototype.hasOwnProperty;
|
|
70
137
|
return own.call(builtInLocales, lang) || Boolean(config?.resources && own.call(config.resources, lang));
|
|
71
138
|
}
|
|
139
|
+
/** The built-in packs' codes, as a stable identity for consumers' dep arrays. */
|
|
140
|
+
const BUILT_IN_LANGUAGE_CODES = Object.freeze(Object.keys(builtInLocales));
|
|
141
|
+
/**
|
|
142
|
+
* Every language this renderer can produce *without asking the app*: the
|
|
143
|
+
* built-in packs plus `config.resources`.
|
|
144
|
+
*
|
|
145
|
+
* This is the offline/no-backend answer — the set the switcher offers when
|
|
146
|
+
* there is no `loadLocales` wiring, or when the app's locale endpoint cannot
|
|
147
|
+
* be reached (objectui#4039).
|
|
148
|
+
*/
|
|
149
|
+
function resolvableLanguages(config) {
|
|
150
|
+
const extra = Object.keys(config?.resources ?? {}).filter((code) => !BUILT_IN_LANGUAGE_CODES.includes(code));
|
|
151
|
+
return [...BUILT_IN_LANGUAGE_CODES, ...extra];
|
|
152
|
+
}
|
|
72
153
|
/**
|
|
73
|
-
*
|
|
74
|
-
*
|
|
75
|
-
*
|
|
76
|
-
*
|
|
77
|
-
* `
|
|
78
|
-
*
|
|
79
|
-
*
|
|
80
|
-
* first wave of server-resolved metadata labels in the wrong language and then
|
|
81
|
-
* have to remount to fix it.
|
|
82
|
-
*
|
|
83
|
-
* A stored choice outranks browser detection (which itself outranks
|
|
84
|
-
* `defaultLanguage` in `createI18n`) — otherwise a user who picked 中文 on a
|
|
85
|
-
* `ja` browser would be handed `ja` back on every reload, and the preference
|
|
86
|
-
* would be unusable for exactly the users who need it.
|
|
87
|
-
*
|
|
88
|
-
* A stored value the app no longer offers is dropped *and purged*, so one stale
|
|
89
|
-
* entry can never lock the UI to a locale that has no translations.
|
|
154
|
+
* Whether `tag` is a well-formed BCP-47 language tag.
|
|
155
|
+
*
|
|
156
|
+
* `Intl.getCanonicalLocales` is the standard's own parser, so this rejects
|
|
157
|
+
* exactly what a locale code cannot be — `constructor`, `__proto__`, `en_US`,
|
|
158
|
+
* `''` all throw `RangeError` — while accepting codes no pack exists for yet
|
|
159
|
+
* (`th`, `pt-BR`). Guarded for runtimes that ship no `Intl`: a language
|
|
160
|
+
* preference is never worth taking the app down for.
|
|
90
161
|
*/
|
|
91
|
-
function
|
|
162
|
+
function isWellFormedLanguageTag(tag) {
|
|
163
|
+
if (typeof Intl === 'undefined' || typeof Intl.getCanonicalLocales !== 'function') {
|
|
164
|
+
return /^[A-Za-z]{2,8}(-[A-Za-z0-9]{1,8})*$/.test(tag);
|
|
165
|
+
}
|
|
166
|
+
try {
|
|
167
|
+
return Intl.getCanonicalLocales(tag).length === 1;
|
|
168
|
+
}
|
|
169
|
+
catch {
|
|
170
|
+
return false;
|
|
171
|
+
}
|
|
172
|
+
}
|
|
173
|
+
/**
|
|
174
|
+
* Whether this provider can end up rendering `lang` at all.
|
|
175
|
+
*
|
|
176
|
+
* Wider than {@link isStaticallyKnownLanguage} by exactly one case: when a
|
|
177
|
+
* dynamic {@link I18nProviderProps.loadLanguage} loader is wired, that loader
|
|
178
|
+
* *is* the mechanism that produces app-shipped locales, and nothing can
|
|
179
|
+
* enumerate what it will answer for synchronously — the app's pack for `th`
|
|
180
|
+
* lives behind `GET /api/v1/i18n/translations/th`, not in `config.resources`.
|
|
181
|
+
*
|
|
182
|
+
* This is the lockstep half of objectui#4039. The switcher now offers the
|
|
183
|
+
* app's own locales, so the restore validation had to grow with it or a
|
|
184
|
+
* user-picked app locale would be purged on the next reload — the exact bug
|
|
185
|
+
* objectstack#5418 predicted this fix would otherwise mint. The bound stays
|
|
186
|
+
* honest rather than absent: only well-formed tags qualify, and the app's own
|
|
187
|
+
* locale list adjudicates the choice for real once it lands (see the
|
|
188
|
+
* `provisional` self-heal in {@link I18nProvider}).
|
|
189
|
+
*/
|
|
190
|
+
function canResolveLanguage(lang, config, hasLoader = false) {
|
|
191
|
+
if (isStaticallyKnownLanguage(lang, config))
|
|
192
|
+
return true;
|
|
193
|
+
return hasLoader && isWellFormedLanguageTag(lang);
|
|
194
|
+
}
|
|
195
|
+
/**
|
|
196
|
+
* Adjudicate a tenant locale seed into a language this renderer can actually
|
|
197
|
+
* boot in, or `null` to fall through to the next tier (objectui#4035).
|
|
198
|
+
*
|
|
199
|
+
* {@link canResolveLanguage} is the verdict — the same predicate the language
|
|
200
|
+
* menu uses, deliberately not a second "do we have this locale" check
|
|
201
|
+
* (objectstack#5418 / objectui#4039). Two things are layered on top of it, both
|
|
202
|
+
* of which are about *which question to ask it*, not about second-guessing the
|
|
203
|
+
* answer:
|
|
204
|
+
*
|
|
205
|
+
* **1. Region subtags are normalised away as a fallback.** A tenant locale is a
|
|
206
|
+
* full BCP-47 tag — the platform answers `zh-CN`, not `zh` (see
|
|
207
|
+
* `LocalizationFetchProvider`'s fixtures) — while the packs are keyed by base
|
|
208
|
+
* language. Asking only about `zh-CN` would reject the single most common
|
|
209
|
+
* tenant configuration there is. The exact tag is tried first so a genuine
|
|
210
|
+
* `pt-BR` pack still wins over `pt`; this mirrors `createI18n`'s own browser
|
|
211
|
+
* detection (`navigator.language.split('-')[0]`) and `pickLocalized`'s
|
|
212
|
+
* documented exact-then-base order, so it is this codebase's existing
|
|
213
|
+
* convention rather than a new rule.
|
|
214
|
+
*
|
|
215
|
+
* **2. The seed does NOT get the dynamic loader's credit** (`hasLoader` is left
|
|
216
|
+
* at its default `false`). That credit exists for a *user-picked* value: the
|
|
217
|
+
* user chose it from a menu built out of the app's real locale list, and a
|
|
218
|
+
* stored choice that turns out unshippable is adjudicated afterwards by the
|
|
219
|
+
* `provisional` self-heal. A tenant seed has neither property — it is an
|
|
220
|
+
* arbitrary admin-authored string that passed through no menu, and there is no
|
|
221
|
+
* self-heal behind it. Extending the credit would mean booting into a locale we
|
|
222
|
+
* cannot confirm we ship and then retracting it, i.e. manufacturing exactly the
|
|
223
|
+
* flash that ruling point 3 bounds, on the very first-visit path it bounds it
|
|
224
|
+
* on. Falling through instead is what ruling point 4 asks for, and it is
|
|
225
|
+
* self-correcting in one step: the user switches once, and that switch is a
|
|
226
|
+
* real explicit choice which outranks the seed from then on.
|
|
227
|
+
*/
|
|
228
|
+
function resolveSeedLanguage(seed, config) {
|
|
229
|
+
if (canResolveLanguage(seed, config))
|
|
230
|
+
return seed;
|
|
231
|
+
const base = seed.split('-')[0];
|
|
232
|
+
if (base && base !== seed && canResolveLanguage(base, config))
|
|
233
|
+
return base;
|
|
234
|
+
return null;
|
|
235
|
+
}
|
|
236
|
+
/**
|
|
237
|
+
* The tenant tier: a cached server seed, applied only when the user has
|
|
238
|
+
* expressed no choice of their own (objectui#4035).
|
|
239
|
+
*
|
|
240
|
+
* Returns the bootstrap resolution for the seed, or `null` when there is no
|
|
241
|
+
* usable seed and the caller should fall through to browser detection.
|
|
242
|
+
*
|
|
243
|
+
* `provisional` is deliberately always `null` here: that flag marks a value
|
|
244
|
+
* accepted on the loader's optimistic credit so the app's locale list can
|
|
245
|
+
* adjudicate it later, and {@link resolveSeedLanguage} never extends that
|
|
246
|
+
* credit — a seed is either resolvable right now or it falls through.
|
|
247
|
+
*/
|
|
248
|
+
function resolveSeedBootstrap(config) {
|
|
249
|
+
const seed = readCachedLanguageSeed();
|
|
250
|
+
if (!seed)
|
|
251
|
+
return null;
|
|
252
|
+
const resolved = resolveSeedLanguage(seed, config);
|
|
253
|
+
if (!resolved)
|
|
254
|
+
return null;
|
|
255
|
+
return {
|
|
256
|
+
// `detectBrowserLanguage: false` is what makes the tenant tier outrank the
|
|
257
|
+
// environment tier. The admin's deliberate configuration beats the
|
|
258
|
+
// browser's incidental one — ruling point 1.
|
|
259
|
+
config: { ...config, defaultLanguage: resolved, detectBrowserLanguage: false },
|
|
260
|
+
provisional: null,
|
|
261
|
+
};
|
|
262
|
+
}
|
|
263
|
+
function resolveBootstrapConfig(config, persist, hasLoader) {
|
|
264
|
+
// `persistLanguage: false` surfaces (previews, demos, screenshot harnesses)
|
|
265
|
+
// must stay on a fixed language regardless of what is on this origin — that
|
|
266
|
+
// covers the tenant seed too, not just the user's choice.
|
|
92
267
|
if (!persist)
|
|
93
|
-
return config;
|
|
268
|
+
return { config, provisional: null };
|
|
94
269
|
const stored = readStoredLanguage();
|
|
270
|
+
// No explicit choice at all → the tenant seed gets its turn.
|
|
95
271
|
if (!stored)
|
|
96
|
-
return config;
|
|
97
|
-
if (!
|
|
272
|
+
return resolveSeedBootstrap(config) ?? { config, provisional: null };
|
|
273
|
+
if (!canResolveLanguage(stored, config, hasLoader)) {
|
|
98
274
|
clearStoredLanguage();
|
|
99
|
-
|
|
275
|
+
// A purged choice is no choice, so this falls to the same tenant tier —
|
|
276
|
+
// otherwise dropping one unshippable stored value would skip the seed and
|
|
277
|
+
// land straight on the browser language.
|
|
278
|
+
return resolveSeedBootstrap(config) ?? { config, provisional: null };
|
|
100
279
|
}
|
|
101
|
-
return {
|
|
280
|
+
return {
|
|
281
|
+
config: { ...config, defaultLanguage: stored, detectBrowserLanguage: false },
|
|
282
|
+
provisional: isStaticallyKnownLanguage(stored, config) ? null : stored,
|
|
283
|
+
};
|
|
102
284
|
}
|
|
103
285
|
const ObjectI18nContext = createContext(null);
|
|
104
286
|
/**
|
|
@@ -111,8 +293,19 @@ const ObjectI18nContext = createContext(null);
|
|
|
111
293
|
* </I18nProvider>
|
|
112
294
|
* ```
|
|
113
295
|
*/
|
|
114
|
-
export function I18nProvider({ config, instance: externalInstance, loadLanguage, persistLanguage = true, children, }) {
|
|
115
|
-
|
|
296
|
+
export function I18nProvider({ config, instance: externalInstance, loadLanguage, loadLocales, persistLanguage = true, children, }) {
|
|
297
|
+
// A boolean, not `loadLanguage` itself: an inline arrow would change identity
|
|
298
|
+
// every render and rebuild the i18next instance (and with it the language)
|
|
299
|
+
// on each one. What the bootstrap needs to know is only whether a loader
|
|
300
|
+
// exists at all.
|
|
301
|
+
const hasLoader = Boolean(loadLanguage);
|
|
302
|
+
const bootstrap = useMemo(() => {
|
|
303
|
+
if (externalInstance)
|
|
304
|
+
return { instance: externalInstance, provisional: null };
|
|
305
|
+
const resolved = resolveBootstrapConfig(config, persistLanguage, hasLoader);
|
|
306
|
+
return { instance: createI18n(resolved.config), provisional: resolved.provisional };
|
|
307
|
+
}, [externalInstance, config, persistLanguage, hasLoader]);
|
|
308
|
+
const i18nInstance = bootstrap.instance;
|
|
116
309
|
const [language, setLanguage] = useState(i18nInstance.language || 'en');
|
|
117
310
|
const direction = getDirection(language);
|
|
118
311
|
// Track which languages have had app-specific translations loaded
|
|
@@ -172,6 +365,79 @@ export function I18nProvider({ config, instance: externalInstance, loadLanguage,
|
|
|
172
365
|
console.warn(`[i18n] Failed to load app translations for '${currentLang}':`, err);
|
|
173
366
|
});
|
|
174
367
|
}, [i18nInstance, loadLanguage]);
|
|
368
|
+
// The app's own locale list (objectui#4039). `null` = not answered yet; `[]`
|
|
369
|
+
// = asked and got nothing usable, which is "I don't know", not "I ship no
|
|
370
|
+
// locales" — both degrade to the offline fallback below.
|
|
371
|
+
const [appLocales, setAppLocales] = useState(null);
|
|
372
|
+
// Asked exactly once per provider, guarded by a ref — the same shape as the
|
|
373
|
+
// `loadedAppLangs` guard on `loadLanguage` above, and for two reasons at
|
|
374
|
+
// once. An inline `loadLocales={() => …}` changes identity every render, so
|
|
375
|
+
// without the guard the answer would set state, the re-render would mint a
|
|
376
|
+
// new arrow, and the new arrow would re-run the effect: a fetch loop. And
|
|
377
|
+
// StrictMode's double-invoked effect must not fire a second request.
|
|
378
|
+
//
|
|
379
|
+
// Deliberately no `cancelled` flag: under StrictMode the cleanup would
|
|
380
|
+
// cancel the only in-flight request while the re-run declines to start
|
|
381
|
+
// another, leaving the list permanently unanswered and the switcher
|
|
382
|
+
// permanently blank. Settling state after unmount is a no-op in React 18 —
|
|
383
|
+
// which is exactly why the `loadLanguage` effect above does the same.
|
|
384
|
+
const hasLocaleLoader = Boolean(loadLocales);
|
|
385
|
+
const askedForLocales = useRef(false);
|
|
386
|
+
useEffect(() => {
|
|
387
|
+
if (!loadLocales || askedForLocales.current)
|
|
388
|
+
return;
|
|
389
|
+
askedForLocales.current = true;
|
|
390
|
+
loadLocales()
|
|
391
|
+
.then((codes) => setAppLocales(Array.isArray(codes) ? codes : []))
|
|
392
|
+
.catch((err) => {
|
|
393
|
+
// A language menu is not worth an unhandled rejection: fall back to the
|
|
394
|
+
// locales this renderer can produce on its own.
|
|
395
|
+
setAppLocales([]);
|
|
396
|
+
console.warn('[i18n] Failed to load the app locale list:', err);
|
|
397
|
+
});
|
|
398
|
+
}, [loadLocales]);
|
|
399
|
+
const offerableLanguages = useMemo(() => {
|
|
400
|
+
const fallback = resolvableLanguages(config);
|
|
401
|
+
// No wiring at all: the built-in packs are the whole truth, as before.
|
|
402
|
+
if (!hasLocaleLoader)
|
|
403
|
+
return fallback;
|
|
404
|
+
if (appLocales === null)
|
|
405
|
+
return null;
|
|
406
|
+
const offerable = appLocales.filter((code) => canResolveLanguage(code, config, hasLoader));
|
|
407
|
+
// An app that answers with nothing this renderer can produce leaves the
|
|
408
|
+
// user with no way to switch at all — strictly worse than the offline
|
|
409
|
+
// fallback, so the fallback wins.
|
|
410
|
+
return offerable.length > 0 ? offerable : fallback;
|
|
411
|
+
}, [config, hasLocaleLoader, appLocales, hasLoader]);
|
|
412
|
+
// Self-heal: a stored language accepted on the loader's credit alone is
|
|
413
|
+
// adjudicated by the app's real list once it lands. Without this, widening
|
|
414
|
+
// the restore validation would reintroduce exactly what it was written to
|
|
415
|
+
// stop — one stale entry locking the UI to a locale with no translations.
|
|
416
|
+
// Scoped to our own optimism: only the bootstrap language, only while the
|
|
417
|
+
// user has not moved on, and never on an empty/failed answer.
|
|
418
|
+
useEffect(() => {
|
|
419
|
+
const provisional = bootstrap.provisional;
|
|
420
|
+
if (!provisional || !appLocales || appLocales.length === 0)
|
|
421
|
+
return;
|
|
422
|
+
if (appLocales.includes(provisional))
|
|
423
|
+
return;
|
|
424
|
+
if ((i18nInstance.language || '') !== provisional)
|
|
425
|
+
return;
|
|
426
|
+
const revertTo = config?.defaultLanguage || config?.fallbackLanguage || 'en';
|
|
427
|
+
void (async () => {
|
|
428
|
+
await i18nInstance.changeLanguage(revertTo);
|
|
429
|
+
// Purge AFTER the switch, not before: every language change persists
|
|
430
|
+
// itself (that is the point of the single choke point in the
|
|
431
|
+
// `languageChanged` handler), so clearing first would just be overwritten
|
|
432
|
+
// with `revertTo` — recording a preference the user never expressed and
|
|
433
|
+
// suppressing browser detection from then on. Ending with nothing stored
|
|
434
|
+
// is what the bootstrap purge has always left behind.
|
|
435
|
+
// Guarded: if the user picked something in the meantime, that choice is
|
|
436
|
+
// theirs to keep.
|
|
437
|
+
if ((i18nInstance.language || '') === revertTo)
|
|
438
|
+
clearStoredLanguage();
|
|
439
|
+
})();
|
|
440
|
+
}, [bootstrap, appLocales, i18nInstance, config]);
|
|
175
441
|
const contextValue = useMemo(() => ({
|
|
176
442
|
language,
|
|
177
443
|
changeLanguage: async (lang) => {
|
|
@@ -191,7 +457,8 @@ export function I18nProvider({ config, instance: externalInstance, loadLanguage,
|
|
|
191
457
|
},
|
|
192
458
|
direction,
|
|
193
459
|
i18n: i18nInstance,
|
|
194
|
-
|
|
460
|
+
offerableLanguages,
|
|
461
|
+
}), [language, direction, i18nInstance, loadLanguage, offerableLanguages]);
|
|
195
462
|
return React.createElement(ObjectI18nContext.Provider, { value: contextValue }, React.createElement(I18nextProvider, { i18n: i18nInstance }, children));
|
|
196
463
|
}
|
|
197
464
|
/**
|
|
@@ -219,6 +486,13 @@ export function useObjectTranslation(ns) {
|
|
|
219
486
|
direction: context?.direction || 'ltr',
|
|
220
487
|
/** The underlying i18next instance */
|
|
221
488
|
i18n,
|
|
489
|
+
/**
|
|
490
|
+
* Languages a switcher may offer (objectui#4039). `null` while the app's
|
|
491
|
+
* locale list is in flight. Outside a provider there is nobody to ask, so
|
|
492
|
+
* the built-in packs are the answer — not `null`, which would leave a
|
|
493
|
+
* standalone switcher permanently blank.
|
|
494
|
+
*/
|
|
495
|
+
offerableLanguages: context ? context.offerableLanguages : BUILT_IN_LANGUAGE_CODES,
|
|
222
496
|
};
|
|
223
497
|
}
|
|
224
498
|
/**
|
|
@@ -0,0 +1,49 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* ObjectUI
|
|
3
|
+
* Copyright (c) 2024-present ObjectStack Inc.
|
|
4
|
+
*
|
|
5
|
+
* This source code is licensed under the MIT license found in the
|
|
6
|
+
* LICENSE file in the root directory of this source tree.
|
|
7
|
+
*/
|
|
8
|
+
/**
|
|
9
|
+
* `useDisplayLocale` — the BCP-47 tag that field / cell / metric renderers
|
|
10
|
+
* format numbers and other `Intl` values with.
|
|
11
|
+
*
|
|
12
|
+
* This composes the two locale channels this repo already has; it does not add
|
|
13
|
+
* a third. The precedence, highest first:
|
|
14
|
+
*
|
|
15
|
+
* 1. **`useLocalization().locale`** — the tenant's resolved regional default
|
|
16
|
+
* (ADR-0053, served by `GET /api/v1/auth/me/localization`). An org that has
|
|
17
|
+
* explicitly configured a display locale means it, so it outranks the UI
|
|
18
|
+
* language. Frequently `undefined`: the endpoint is
|
|
19
|
+
* cosmetic and non-blocking, so an unconfigured or still-loading tenant
|
|
20
|
+
* simply has no answer here (the state objectui#4033 was measured in — a
|
|
21
|
+
* fresh database, console running `zh-CN`).
|
|
22
|
+
*
|
|
23
|
+
* 2. **`useObjectTranslation().language`** — the ACTIVE UI language, i.e. what
|
|
24
|
+
* the user picked in the language switcher. This is the channel that makes
|
|
25
|
+
* grouping and decimal marks follow the user's language when the tenant has
|
|
26
|
+
* stated no regional preference.
|
|
27
|
+
*
|
|
28
|
+
* 3. **`'en'`** — a concrete last resort rather than `undefined`. `undefined`
|
|
29
|
+
* would hand `Intl` the *machine's* locale, which is invisible in review and
|
|
30
|
+
* non-deterministic in CI; `'en'` at least fails the same way everywhere.
|
|
31
|
+
* Note this is `'en'`, not `'en-US'`: the point of objectui#4033 is that
|
|
32
|
+
* `en-US` is not the world's default. In practice step 2 already answers,
|
|
33
|
+
* since `useObjectTranslation` falls back to the react-i18next global
|
|
34
|
+
* instance and ultimately reports `'en'` itself.
|
|
35
|
+
*
|
|
36
|
+
* Provider-safe at every step: `useLocalization` returns `{}` outside its
|
|
37
|
+
* provider and `useObjectTranslation` reads an optional context, so a renderer
|
|
38
|
+
* mounted standalone (tests, docs demos) degrades instead of throwing.
|
|
39
|
+
*
|
|
40
|
+
* **This composition is also the whole `'zh'` ↔ BCP-47 story** (objectui#4468).
|
|
41
|
+
* There is no mapping table anywhere and none is needed: the UI language codes
|
|
42
|
+
* this renderer ships (`'zh'`, `'ja'`, `'de'`, …) are already well-formed
|
|
43
|
+
* BCP-47 language subtags, so `Intl` accepts them verbatim. The one thing a
|
|
44
|
+
* caller must not do is reach past this hook for the raw tenant locale and hand
|
|
45
|
+
* `Intl` the `undefined` it gets on an unconfigured workspace — `undefined`
|
|
46
|
+
* means "the MACHINE's locale", which is neither channel. Every date, number
|
|
47
|
+
* and currency renderer goes through here for exactly that reason.
|
|
48
|
+
*/
|
|
49
|
+
export declare function useDisplayLocale(): string;
|
|
@@ -0,0 +1,55 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* ObjectUI
|
|
3
|
+
* Copyright (c) 2024-present ObjectStack Inc.
|
|
4
|
+
*
|
|
5
|
+
* This source code is licensed under the MIT license found in the
|
|
6
|
+
* LICENSE file in the root directory of this source tree.
|
|
7
|
+
*/
|
|
8
|
+
import { useLocalization } from './LocalizationContext.js';
|
|
9
|
+
import { useObjectTranslation } from './provider.js';
|
|
10
|
+
/**
|
|
11
|
+
* `useDisplayLocale` — the BCP-47 tag that field / cell / metric renderers
|
|
12
|
+
* format numbers and other `Intl` values with.
|
|
13
|
+
*
|
|
14
|
+
* This composes the two locale channels this repo already has; it does not add
|
|
15
|
+
* a third. The precedence, highest first:
|
|
16
|
+
*
|
|
17
|
+
* 1. **`useLocalization().locale`** — the tenant's resolved regional default
|
|
18
|
+
* (ADR-0053, served by `GET /api/v1/auth/me/localization`). An org that has
|
|
19
|
+
* explicitly configured a display locale means it, so it outranks the UI
|
|
20
|
+
* language. Frequently `undefined`: the endpoint is
|
|
21
|
+
* cosmetic and non-blocking, so an unconfigured or still-loading tenant
|
|
22
|
+
* simply has no answer here (the state objectui#4033 was measured in — a
|
|
23
|
+
* fresh database, console running `zh-CN`).
|
|
24
|
+
*
|
|
25
|
+
* 2. **`useObjectTranslation().language`** — the ACTIVE UI language, i.e. what
|
|
26
|
+
* the user picked in the language switcher. This is the channel that makes
|
|
27
|
+
* grouping and decimal marks follow the user's language when the tenant has
|
|
28
|
+
* stated no regional preference.
|
|
29
|
+
*
|
|
30
|
+
* 3. **`'en'`** — a concrete last resort rather than `undefined`. `undefined`
|
|
31
|
+
* would hand `Intl` the *machine's* locale, which is invisible in review and
|
|
32
|
+
* non-deterministic in CI; `'en'` at least fails the same way everywhere.
|
|
33
|
+
* Note this is `'en'`, not `'en-US'`: the point of objectui#4033 is that
|
|
34
|
+
* `en-US` is not the world's default. In practice step 2 already answers,
|
|
35
|
+
* since `useObjectTranslation` falls back to the react-i18next global
|
|
36
|
+
* instance and ultimately reports `'en'` itself.
|
|
37
|
+
*
|
|
38
|
+
* Provider-safe at every step: `useLocalization` returns `{}` outside its
|
|
39
|
+
* provider and `useObjectTranslation` reads an optional context, so a renderer
|
|
40
|
+
* mounted standalone (tests, docs demos) degrades instead of throwing.
|
|
41
|
+
*
|
|
42
|
+
* **This composition is also the whole `'zh'` ↔ BCP-47 story** (objectui#4468).
|
|
43
|
+
* There is no mapping table anywhere and none is needed: the UI language codes
|
|
44
|
+
* this renderer ships (`'zh'`, `'ja'`, `'de'`, …) are already well-formed
|
|
45
|
+
* BCP-47 language subtags, so `Intl` accepts them verbatim. The one thing a
|
|
46
|
+
* caller must not do is reach past this hook for the raw tenant locale and hand
|
|
47
|
+
* `Intl` the `undefined` it gets on an unconfigured workspace — `undefined`
|
|
48
|
+
* means "the MACHINE's locale", which is neither channel. Every date, number
|
|
49
|
+
* and currency renderer goes through here for exactly that reason.
|
|
50
|
+
*/
|
|
51
|
+
export function useDisplayLocale() {
|
|
52
|
+
const { locale } = useLocalization();
|
|
53
|
+
const { language } = useObjectTranslation();
|
|
54
|
+
return locale || language || 'en';
|
|
55
|
+
}
|
package/dist/useObjectLabel.d.ts
CHANGED
|
@@ -88,13 +88,27 @@ export declare function useObjectLabel(): {
|
|
|
88
88
|
description?: string;
|
|
89
89
|
}) => string | undefined;
|
|
90
90
|
/**
|
|
91
|
-
*
|
|
92
|
-
*
|
|
91
|
+
* Read `{ns}.apps.{appName}.navigation.{groupId}.label` out of the loaded
|
|
92
|
+
* CLIENT translation resources, falling back to `fallback`.
|
|
93
93
|
*
|
|
94
|
-
*
|
|
95
|
-
*
|
|
96
|
-
*
|
|
97
|
-
*
|
|
94
|
+
* ⚠️ This does NOT localize the sidebar, and adding a translation pack
|
|
95
|
+
* keyed this way will not change a single rendered nav label.
|
|
96
|
+
* **App-navigation localization is owned solely by the server-side
|
|
97
|
+
* `/meta` boundary**: `translateApp` in `@objectstack/spec`
|
|
98
|
+
* (`src/system/i18n-resolver.ts`) rewrites every navigation node's
|
|
99
|
+
* `label` by id, and `@objectstack/rest` applies it before the metadata
|
|
100
|
+
* reaches this client — so nav labels arrive already localized. One
|
|
101
|
+
* owner, not two. To translate a sidebar group, translate it there.
|
|
102
|
+
*
|
|
103
|
+
* History (objectui#5197): until then this docstring promised
|
|
104
|
+
* `"Sales" → "销售"` for sidebar groups, and `NavigationRenderer`
|
|
105
|
+
* accepted `resolveGroupLabel`/`resolveItemLabel` to wire it up. Those
|
|
106
|
+
* props could never fire — the renderer's `isCustomized` guard compared a
|
|
107
|
+
* node's authored label against its own `id` (`Workspace` vs
|
|
108
|
+
* `grp_workspace`), which never match, so the guard was true for every
|
|
109
|
+
* real entry. The props are gone; the promise was false, not merely
|
|
110
|
+
* unused. This helper is kept as a plain key reader for a consumer that
|
|
111
|
+
* renders navigation itself — it has no first-party caller.
|
|
98
112
|
*/
|
|
99
113
|
navGroupLabel: (appName: string, groupId: string, fallback: string) => string;
|
|
100
114
|
/**
|
|
@@ -133,6 +147,29 @@ export declare function useObjectLabel(): {
|
|
|
133
147
|
* Returns undefined when neither metadata nor translation provides one.
|
|
134
148
|
*/
|
|
135
149
|
widgetDescription: (dashboardName: string, widgetId: string, fallback?: string) => string | undefined;
|
|
150
|
+
/**
|
|
151
|
+
* Resolve a translated metric-widget SUB-CAPTION within a dashboard.
|
|
152
|
+
* Convention: `{ns}.dashboards.{dashboardName}.widgets.{widgetId}.subCaption`.
|
|
153
|
+
* Returns undefined when neither metadata nor translation provides one.
|
|
154
|
+
*
|
|
155
|
+
* Deliberately its OWN key, not a second reader of `widgets.{id}.description`.
|
|
156
|
+
* The KPI card renders two authored strings from two different fields — the
|
|
157
|
+
* shared card header's `widget.description`, and the sub-caption under the
|
|
158
|
+
* value, which is authored as `widget.options.description` — and the
|
|
159
|
+
* objectstack#5428 item-4 ruling (2026-08-06) settled that they get two
|
|
160
|
+
* keys, not one: 「两个作者字段两个 key」. Collapsing them would make one
|
|
161
|
+
* translation entry silently retarget the other field on any widget type
|
|
162
|
+
* that renders both at once (`kpi`, `gauge`, `bullet` — every metric-family
|
|
163
|
+
* type except the self-contained `metric`).
|
|
164
|
+
*
|
|
165
|
+
* `subCaption` is the member objectstack#8056 added to the widget
|
|
166
|
+
* translation node for exactly this, shipped in `@objectstack/spec@17.0.0`.
|
|
167
|
+
* The server-side resolver reads the SAME key and overlays it onto
|
|
168
|
+
* `options.description` (`translateDashboard`), so a document served
|
|
169
|
+
* through `/meta` and a document translated here land on the same string —
|
|
170
|
+
* this is the client half of one convention, not a second dialect.
|
|
171
|
+
*/
|
|
172
|
+
widgetSubCaption: (dashboardName: string, widgetId: string, fallback?: string) => string | undefined;
|
|
136
173
|
/**
|
|
137
174
|
* Resolve translated page label, falling back to pageDef.label.
|
|
138
175
|
* Convention: `{ns}.pages.{pageName}.label`.
|
|
@@ -151,7 +188,10 @@ export declare function useObjectLabel(): {
|
|
|
151
188
|
}) => string;
|
|
152
189
|
/**
|
|
153
190
|
* Resolve translated list-view label.
|
|
154
|
-
* Convention (per @objectstack/spec): `{ns}.objects.{objectName}._views.{viewName}.label
|
|
191
|
+
* Convention (per @objectstack/spec): `{ns}.objects.{objectName}._views.{viewName}.label`,
|
|
192
|
+
* where `{viewName}` is the **bare** view name — pass either the bare name or
|
|
193
|
+
* the qualified runtime id, the object prefix is stripped either way. A key
|
|
194
|
+
* that spells the prefix out does not resolve (see `viewSuffixes`).
|
|
155
195
|
*/
|
|
156
196
|
viewLabel: (objectName: string, viewName: string, fallback: string) => string;
|
|
157
197
|
/**
|
|
@@ -308,13 +348,27 @@ export declare function useSafeFieldLabel(): {
|
|
|
308
348
|
description?: string;
|
|
309
349
|
}) => string | undefined;
|
|
310
350
|
/**
|
|
311
|
-
*
|
|
312
|
-
*
|
|
351
|
+
* Read `{ns}.apps.{appName}.navigation.{groupId}.label` out of the loaded
|
|
352
|
+
* CLIENT translation resources, falling back to `fallback`.
|
|
313
353
|
*
|
|
314
|
-
*
|
|
315
|
-
*
|
|
316
|
-
*
|
|
317
|
-
*
|
|
354
|
+
* ⚠️ This does NOT localize the sidebar, and adding a translation pack
|
|
355
|
+
* keyed this way will not change a single rendered nav label.
|
|
356
|
+
* **App-navigation localization is owned solely by the server-side
|
|
357
|
+
* `/meta` boundary**: `translateApp` in `@objectstack/spec`
|
|
358
|
+
* (`src/system/i18n-resolver.ts`) rewrites every navigation node's
|
|
359
|
+
* `label` by id, and `@objectstack/rest` applies it before the metadata
|
|
360
|
+
* reaches this client — so nav labels arrive already localized. One
|
|
361
|
+
* owner, not two. To translate a sidebar group, translate it there.
|
|
362
|
+
*
|
|
363
|
+
* History (objectui#5197): until then this docstring promised
|
|
364
|
+
* `"Sales" → "销售"` for sidebar groups, and `NavigationRenderer`
|
|
365
|
+
* accepted `resolveGroupLabel`/`resolveItemLabel` to wire it up. Those
|
|
366
|
+
* props could never fire — the renderer's `isCustomized` guard compared a
|
|
367
|
+
* node's authored label against its own `id` (`Workspace` vs
|
|
368
|
+
* `grp_workspace`), which never match, so the guard was true for every
|
|
369
|
+
* real entry. The props are gone; the promise was false, not merely
|
|
370
|
+
* unused. This helper is kept as a plain key reader for a consumer that
|
|
371
|
+
* renders navigation itself — it has no first-party caller.
|
|
318
372
|
*/
|
|
319
373
|
navGroupLabel: (appName: string, groupId: string, fallback: string) => string;
|
|
320
374
|
/**
|
|
@@ -353,6 +407,29 @@ export declare function useSafeFieldLabel(): {
|
|
|
353
407
|
* Returns undefined when neither metadata nor translation provides one.
|
|
354
408
|
*/
|
|
355
409
|
widgetDescription: (dashboardName: string, widgetId: string, fallback?: string) => string | undefined;
|
|
410
|
+
/**
|
|
411
|
+
* Resolve a translated metric-widget SUB-CAPTION within a dashboard.
|
|
412
|
+
* Convention: `{ns}.dashboards.{dashboardName}.widgets.{widgetId}.subCaption`.
|
|
413
|
+
* Returns undefined when neither metadata nor translation provides one.
|
|
414
|
+
*
|
|
415
|
+
* Deliberately its OWN key, not a second reader of `widgets.{id}.description`.
|
|
416
|
+
* The KPI card renders two authored strings from two different fields — the
|
|
417
|
+
* shared card header's `widget.description`, and the sub-caption under the
|
|
418
|
+
* value, which is authored as `widget.options.description` — and the
|
|
419
|
+
* objectstack#5428 item-4 ruling (2026-08-06) settled that they get two
|
|
420
|
+
* keys, not one: 「两个作者字段两个 key」. Collapsing them would make one
|
|
421
|
+
* translation entry silently retarget the other field on any widget type
|
|
422
|
+
* that renders both at once (`kpi`, `gauge`, `bullet` — every metric-family
|
|
423
|
+
* type except the self-contained `metric`).
|
|
424
|
+
*
|
|
425
|
+
* `subCaption` is the member objectstack#8056 added to the widget
|
|
426
|
+
* translation node for exactly this, shipped in `@objectstack/spec@17.0.0`.
|
|
427
|
+
* The server-side resolver reads the SAME key and overlays it onto
|
|
428
|
+
* `options.description` (`translateDashboard`), so a document served
|
|
429
|
+
* through `/meta` and a document translated here land on the same string —
|
|
430
|
+
* this is the client half of one convention, not a second dialect.
|
|
431
|
+
*/
|
|
432
|
+
widgetSubCaption: (dashboardName: string, widgetId: string, fallback?: string) => string | undefined;
|
|
356
433
|
/**
|
|
357
434
|
* Resolve translated page label, falling back to pageDef.label.
|
|
358
435
|
* Convention: `{ns}.pages.{pageName}.label`.
|
|
@@ -371,7 +448,10 @@ export declare function useSafeFieldLabel(): {
|
|
|
371
448
|
}) => string;
|
|
372
449
|
/**
|
|
373
450
|
* Resolve translated list-view label.
|
|
374
|
-
* Convention (per @objectstack/spec): `{ns}.objects.{objectName}._views.{viewName}.label
|
|
451
|
+
* Convention (per @objectstack/spec): `{ns}.objects.{objectName}._views.{viewName}.label`,
|
|
452
|
+
* where `{viewName}` is the **bare** view name — pass either the bare name or
|
|
453
|
+
* the qualified runtime id, the object prefix is stripped either way. A key
|
|
454
|
+
* that spells the prefix out does not resolve (see `viewSuffixes`).
|
|
375
455
|
*/
|
|
376
456
|
viewLabel: (objectName: string, viewName: string, fallback: string) => string;
|
|
377
457
|
/**
|