@object-ui/i18n 17.5.0 → 17.7.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.
Files changed (58) hide show
  1. package/CHANGELOG.md +4954 -0
  2. package/README.md +234 -13
  3. package/dist/builtinAggregateLabels.d.ts +40 -0
  4. package/dist/builtinAggregateLabels.js +23 -0
  5. package/dist/currency.d.ts +27 -3
  6. package/dist/currency.js +28 -4
  7. package/dist/fallbackInterpolation.d.ts +79 -0
  8. package/dist/fallbackInterpolation.js +124 -0
  9. package/dist/i18n.d.ts +28 -2
  10. package/dist/i18n.js +99 -17
  11. package/dist/index.d.ts +17 -23
  12. package/dist/index.js +51 -27
  13. package/dist/locales/ar.d.ts +1113 -370
  14. package/dist/locales/ar.js +1276 -428
  15. package/dist/locales/de.d.ts +785 -370
  16. package/dist/locales/de.js +896 -401
  17. package/dist/locales/en.d.ts +800 -385
  18. package/dist/locales/en.js +1261 -419
  19. package/dist/locales/es.d.ts +867 -370
  20. package/dist/locales/es.js +976 -398
  21. package/dist/locales/fr.d.ts +867 -370
  22. package/dist/locales/fr.js +976 -399
  23. package/dist/locales/index.d.ts +8682 -3761
  24. package/dist/locales/index.js +68 -29
  25. package/dist/locales/ja.d.ts +785 -370
  26. package/dist/locales/ja.js +891 -395
  27. package/dist/locales/ko.d.ts +785 -370
  28. package/dist/locales/ko.js +893 -399
  29. package/dist/locales/pt.d.ts +867 -370
  30. package/dist/locales/pt.js +975 -397
  31. package/dist/locales/registry.d.ts +102 -0
  32. package/dist/locales/registry.js +220 -0
  33. package/dist/locales/rtl.d.ts +24 -0
  34. package/dist/locales/rtl.js +26 -0
  35. package/dist/locales/ru.d.ts +949 -370
  36. package/dist/locales/ru.js +1093 -419
  37. package/dist/locales/zh.d.ts +803 -388
  38. package/dist/locales/zh.js +899 -388
  39. package/dist/pickLocalized.d.ts +67 -0
  40. package/dist/pickLocalized.js +114 -0
  41. package/dist/provider.d.ts +102 -13
  42. package/dist/provider.js +335 -50
  43. package/dist/translateFn.d.ts +29 -0
  44. package/dist/translateFn.js +8 -0
  45. package/dist/useDisplayLocale.d.ts +11 -0
  46. package/dist/useDisplayLocale.js +13 -2
  47. package/dist/useObjectLabel.d.ts +118 -22
  48. package/dist/useObjectLabel.js +148 -23
  49. package/dist/useSafeTranslation.js +41 -43
  50. package/dist/utils/formatting.d.ts +16 -0
  51. package/dist/utils/formatting.js +23 -3
  52. package/dist/utils/index.d.ts +4 -4
  53. package/dist/utils/index.js +4 -4
  54. package/dist/utils/spec-formatters.d.ts +13 -23
  55. package/dist/utils/spec-formatters.js +41 -2
  56. package/dist/utils/spec-translations.d.ts +16 -3
  57. package/dist/utils/spec-translations.js +52 -13
  58. package/package.json +12 -6
@@ -34,3 +34,70 @@
34
34
  * `pickLocalized({ en: 'Pricing' }, 'constructor')` is `'Pricing'`, not `''`.
35
35
  */
36
36
  export declare function pickLocalized(value: unknown, language: string | undefined | null): string;
37
+ /**
38
+ * Write `next` back into a possibly-localized value as the `language` entry,
39
+ * preserving every other locale — the write-side inverse of
40
+ * {@link pickLocalized}.
41
+ *
42
+ * An authoring surface that shows one locale in one text input has to answer
43
+ * two questions, and they are not the same question: which entry to DISPLAY
44
+ * (`pickLocalized`) and which entry a save REPLACES. Answering the second with
45
+ * "the whole value" is the data-loss shape this function exists to remove —
46
+ * `onChange(e.target.value)` straight back over `{ en, zh-CN }` destroys every
47
+ * locale the author was not looking at, silently, on the first keystroke.
48
+ *
49
+ * - Plain string / `null` / `undefined` / array input: there is no map to
50
+ * preserve, so the result is `next` itself. A string-valued label keeps
51
+ * behaving exactly as it always has.
52
+ * - Inline locale map: the result is the same map with exactly one entry
53
+ * replaced (or added — see {@link localeWriteKey}). Non-string entries and
54
+ * unrelated locales are carried across untouched.
55
+ *
56
+ * The pairing with `pickLocalized` is the invariant that matters and it is
57
+ * pinned in `__tests__/setLocalized.test.ts`: for any map and any language,
58
+ * `pickLocalized(setLocalized(map, lang, s), lang) === s`. If the two functions
59
+ * ever drift apart, an edit lands in an entry the panel does not display and
60
+ * the "saved" string vanishes — which is why they live in one file.
61
+ *
62
+ * ⚠️ This is the minimal non-destructive write for a SINGLE-locale editor. It
63
+ * is not a multi-locale authoring UI and does not pretend to be: an author can
64
+ * only ever reach the entry for the locale they are in.
65
+ *
66
+ * Authoring every locale from one surface remains an OPEN product question, and
67
+ * is deliberately not deferred to a tracker here: the deferral this replaced
68
+ * named objectui#4163, which closed as completed on 2026-08-15 with the question
69
+ * still unanswered. A comment pointing at a closed card reads as though the
70
+ * question were settled somewhere, which is how the previous stale premise in
71
+ * this area survived unread.
72
+ */
73
+ export declare function setLocalized(value: unknown, language: string | undefined | null, next: string): string | Record<string, unknown>;
74
+ /**
75
+ * Remove the `language` entry from a possibly-localized value — the CLEAR arm
76
+ * of the same editor {@link setLocalized} serves, and the reason it cannot be
77
+ * spelled as `setLocalized(value, language, '')`.
78
+ *
79
+ * A single-line authoring input has three actions, not two: type, retype, and
80
+ * clear. `setLocalized` answers the first two. Answering the third with "write
81
+ * the empty string" stores `{ en: '' }` — an entry that claims "English has a
82
+ * title and it is blank", which then SHADOWS every display fallback: a viewer
83
+ * in `en` sees nothing where the map's other locales would have been offered.
84
+ * Answering it with "drop the whole value" is the objectui#9274 data loss
85
+ * itself, arriving through the clearing door instead of the typing door.
86
+ *
87
+ * So clearing removes exactly one entry — the same entry {@link setLocalized}
88
+ * would have written, chosen by the same {@link localeWriteKey} limbs, which is
89
+ * what keeps the cleared entry the one the author was actually looking at.
90
+ *
91
+ * - Plain string / `null` / `undefined` / array: there is no map, so there is
92
+ * nothing localized left — `undefined`. The caller decides how its own field
93
+ * spells empty (`undefined` for an optional key, `''` for a required one).
94
+ * - Map with the active entry present: that entry is removed and every other
95
+ * locale is carried across untouched. When it was the LAST entry the result
96
+ * is `undefined` rather than `{}`, because an empty map is not a title.
97
+ * - Map WITHOUT an entry for the active locale: returned unchanged. The box was
98
+ * showing a display fallback borrowed from another locale, and clearing a
99
+ * borrowed string must not delete the locale it was borrowed from — the same
100
+ * refusal {@link localeWriteKey} enforces on the write side, which is why
101
+ * both read the same limbs from the same file.
102
+ */
103
+ export declare function clearLocalized(value: unknown, language: string | undefined | null): Record<string, unknown> | undefined;
@@ -69,3 +69,117 @@ export function pickLocalized(value, language) {
69
69
  }
70
70
  return String(value);
71
71
  }
72
+ /**
73
+ * The map key an edit made in `language` must be WRITTEN to — the write-side
74
+ * twin of {@link pickLocalized}'s read limbs.
75
+ *
76
+ * It deliberately implements only the first three limbs (exact tag, base
77
+ * language, region-qualified sibling) and then STOPS. `pickLocalized` continues
78
+ * into `default` -> `en` -> first value, and those last three limbs must never
79
+ * be write targets: they are DISPLAY fallbacks that hand back *another*
80
+ * locale's string. Following them would mean an author editing in `fr`, shown
81
+ * the `en` entry because the map has no French, saves and overwrites English.
82
+ * When no entry for the active locale exists the edit ADDS one under the active
83
+ * tag and every stored entry survives untouched.
84
+ *
85
+ * The first three limbs are followed exactly because they identify the entry
86
+ * the author was actually looking at. Writing `zh` when the map stores `zh-CN`
87
+ * would leave the edited-away `zh-CN` string in place — invisible in the panel
88
+ * that just "changed" it, still live for any consumer that asks for `zh-CN`.
89
+ *
90
+ * Own properties only, `string` values only, for the same reason every
91
+ * `pickLocalized` limb narrowed that way in objectui#3907: a locale tag that
92
+ * names an `Object.prototype` member is not an entry, so it cannot be the entry
93
+ * the author edited.
94
+ */
95
+ function localeWriteKey(o, language) {
96
+ const lang = (language || 'en').trim() || 'en';
97
+ const base = lang.split('-')[0];
98
+ const has = (key) => Object.prototype.hasOwnProperty.call(o, key) && typeof o[key] === 'string';
99
+ if (has(lang))
100
+ return lang;
101
+ if (has(base))
102
+ return base;
103
+ return Object.keys(o).find((k) => k.split('-')[0] === base && has(k)) ?? lang;
104
+ }
105
+ /**
106
+ * Write `next` back into a possibly-localized value as the `language` entry,
107
+ * preserving every other locale — the write-side inverse of
108
+ * {@link pickLocalized}.
109
+ *
110
+ * An authoring surface that shows one locale in one text input has to answer
111
+ * two questions, and they are not the same question: which entry to DISPLAY
112
+ * (`pickLocalized`) and which entry a save REPLACES. Answering the second with
113
+ * "the whole value" is the data-loss shape this function exists to remove —
114
+ * `onChange(e.target.value)` straight back over `{ en, zh-CN }` destroys every
115
+ * locale the author was not looking at, silently, on the first keystroke.
116
+ *
117
+ * - Plain string / `null` / `undefined` / array input: there is no map to
118
+ * preserve, so the result is `next` itself. A string-valued label keeps
119
+ * behaving exactly as it always has.
120
+ * - Inline locale map: the result is the same map with exactly one entry
121
+ * replaced (or added — see {@link localeWriteKey}). Non-string entries and
122
+ * unrelated locales are carried across untouched.
123
+ *
124
+ * The pairing with `pickLocalized` is the invariant that matters and it is
125
+ * pinned in `__tests__/setLocalized.test.ts`: for any map and any language,
126
+ * `pickLocalized(setLocalized(map, lang, s), lang) === s`. If the two functions
127
+ * ever drift apart, an edit lands in an entry the panel does not display and
128
+ * the "saved" string vanishes — which is why they live in one file.
129
+ *
130
+ * ⚠️ This is the minimal non-destructive write for a SINGLE-locale editor. It
131
+ * is not a multi-locale authoring UI and does not pretend to be: an author can
132
+ * only ever reach the entry for the locale they are in.
133
+ *
134
+ * Authoring every locale from one surface remains an OPEN product question, and
135
+ * is deliberately not deferred to a tracker here: the deferral this replaced
136
+ * named objectui#4163, which closed as completed on 2026-08-15 with the question
137
+ * still unanswered. A comment pointing at a closed card reads as though the
138
+ * question were settled somewhere, which is how the previous stale premise in
139
+ * this area survived unread.
140
+ */
141
+ export function setLocalized(value, language, next) {
142
+ if (value == null || typeof value !== 'object' || Array.isArray(value))
143
+ return next;
144
+ const o = value;
145
+ return { ...o, [localeWriteKey(o, language)]: next };
146
+ }
147
+ /**
148
+ * Remove the `language` entry from a possibly-localized value — the CLEAR arm
149
+ * of the same editor {@link setLocalized} serves, and the reason it cannot be
150
+ * spelled as `setLocalized(value, language, '')`.
151
+ *
152
+ * A single-line authoring input has three actions, not two: type, retype, and
153
+ * clear. `setLocalized` answers the first two. Answering the third with "write
154
+ * the empty string" stores `{ en: '' }` — an entry that claims "English has a
155
+ * title and it is blank", which then SHADOWS every display fallback: a viewer
156
+ * in `en` sees nothing where the map's other locales would have been offered.
157
+ * Answering it with "drop the whole value" is the objectui#9274 data loss
158
+ * itself, arriving through the clearing door instead of the typing door.
159
+ *
160
+ * So clearing removes exactly one entry — the same entry {@link setLocalized}
161
+ * would have written, chosen by the same {@link localeWriteKey} limbs, which is
162
+ * what keeps the cleared entry the one the author was actually looking at.
163
+ *
164
+ * - Plain string / `null` / `undefined` / array: there is no map, so there is
165
+ * nothing localized left — `undefined`. The caller decides how its own field
166
+ * spells empty (`undefined` for an optional key, `''` for a required one).
167
+ * - Map with the active entry present: that entry is removed and every other
168
+ * locale is carried across untouched. When it was the LAST entry the result
169
+ * is `undefined` rather than `{}`, because an empty map is not a title.
170
+ * - Map WITHOUT an entry for the active locale: returned unchanged. The box was
171
+ * showing a display fallback borrowed from another locale, and clearing a
172
+ * borrowed string must not delete the locale it was borrowed from — the same
173
+ * refusal {@link localeWriteKey} enforces on the write side, which is why
174
+ * both read the same limbs from the same file.
175
+ */
176
+ export function clearLocalized(value, language) {
177
+ if (value == null || typeof value !== 'object' || Array.isArray(value))
178
+ return undefined;
179
+ const o = value;
180
+ const key = localeWriteKey(o, language);
181
+ if (!Object.prototype.hasOwnProperty.call(o, key))
182
+ return o;
183
+ const rest = Object.fromEntries(Object.entries(o).filter(([k]) => k !== key));
184
+ return Object.keys(rest).length > 0 ? rest : undefined;
185
+ }
@@ -5,7 +5,7 @@
5
5
  */
6
6
  import React from 'react';
7
7
  import type { i18n as I18nInstance } from 'i18next';
8
- import { type I18nConfig } from './i18n';
8
+ import { type I18nConfig } from './i18n.js';
9
9
  /**
10
10
  * `localStorage` key holding the user's explicit language choice.
11
11
  *
@@ -17,6 +17,22 @@ import { type I18nConfig } from './i18n';
17
17
  * Exported so an app that builds its own i18next instance (and therefore owns
18
18
  * its bootstrap language — see {@link I18nProviderProps.instance}) can honour
19
19
  * the same preference, and so a sign-out flow can clear it.
20
+ *
21
+ * ⚠️ **What this slot means changed for a SIGNED-IN user (objectui#10059).**
22
+ * A signed-in user's language has one source of truth and it is not on the
23
+ * device: it is the server column `sys_user.locale`, read back through
24
+ * `GET /auth/me/localization` and written by both the profile page's language
25
+ * card and the console's globe menu. For that user this slot is a CACHE of the
26
+ * column — it still decides the first paint, because the column cannot be read
27
+ * synchronously, and it is then overwritten by whatever the column says. It
28
+ * decides on its own only BEFORE sign-in, which is the case this provider can
29
+ * see and the only one it adjudicates.
30
+ *
31
+ * This provider deliberately does not implement that rule: reading a `sys_user`
32
+ * row needs an authenticated data adapter, which is a host concern and not a
33
+ * dependency a renderer package takes. The host applies the column through
34
+ * {@link I18nContextValue.changeLanguage} like any other switch — in this
35
+ * repository, `@object-ui/app-shell`'s `useUserLocale`.
20
36
  */
21
37
  export declare const LOCALE_STORAGE_KEY = "objectui-locale";
22
38
  /**
@@ -29,28 +45,53 @@ export declare const LOCALE_STORAGE_KEY = "objectui-locale";
29
45
  */
30
46
  export declare function readStoredLanguage(): string | null;
31
47
  /**
32
- * `localStorage` key caching the TENANT's server-side locale — a *seed*, not a
33
- * choice (objectui#4035).
48
+ * `localStorage` key caching the server's resolved locale for the signed-in
49
+ * caller — a *seed*, not a choice (objectui#4035).
50
+ *
51
+ * ⚠️ **The value is per-USER; the slot is per-DEVICE (objectui#10193).**
52
+ * `GET /auth/me/localization` answers `locale` as the caller's own
53
+ * `sys_user.locale` when set, else the request's `Accept-Language`, else the
54
+ * deployment default — and answers no locale at all before sign-in. It is not
55
+ * a tenant setting, whatever earlier prose here said. The slot is deliberately
56
+ * NOT keyed by user: the boot reads it before anyone is signed in, when no
57
+ * user id exists to key by. What bounds it to one owner is
58
+ * `@object-ui/auth`'s change-of-owner purge (objectui#5664), which deletes it
59
+ * when a different user adopts the browser, and which the host answers by
60
+ * re-resolving the live language (`@object-ui/app-shell`'s
61
+ * `useSignedInUserLocale`).
62
+ *
63
+ * **What a visitor who is not signed in reads**, declared as device behaviour:
64
+ * the language the LAST signed-in owner of this browser was served, until
65
+ * someone else signs in. That is the only server answer this device has ever
66
+ * received — the endpoint gives none before sign-in — and the sign-in page is
67
+ * the one surface it paints.
34
68
  *
35
69
  * Deliberately a separate slot from {@link LOCALE_STORAGE_KEY}, and that
36
70
  * separation is the whole point rather than a tidiness preference. The two
37
71
  * values have different provenance and therefore different rights:
38
72
  *
39
- * - {@link LOCALE_STORAGE_KEY} is what the *user* picked. It outranks
40
- * everything and must survive a tenant reconfiguration.
41
- * - This slot is what the *admin* configured, cached so the next boot can apply
42
- * it synchronously. It outranks only the environment (browser language).
73
+ * - {@link LOCALE_STORAGE_KEY} is what the *user* picked on this device. It
74
+ * outranks everything this provider can see and must survive a change in
75
+ * what the server answers for the seed below. ⚠️ It does NOT outrank the signed-in user's own
76
+ * `sys_user.locale`, which no tier here represents — see that key's own
77
+ * docblock (objectui#10059).
78
+ * - This slot is what the server resolved for the signed-in caller, cached so
79
+ * the next boot can apply it synchronously. It outranks only the environment
80
+ * (browser language).
43
81
  *
44
82
  * Writing the server seed into the explicit-choice slot would erase that
45
83
  * difference permanently: the seed would then be indistinguishable from a
46
- * deliberate user choice, so it would (a) outrank a later tenant change and pin
47
- * the device to a stale locale forever, and (b) suppress browser detection for
84
+ * deliberate user choice, so it would (a) outrank a later server-side change
85
+ * and pin the device to a stale locale forever, and (b) suppress browser
86
+ * detection for
48
87
  * a user who never expressed a preference. Only a real switch promotes a
49
88
  * language to the explicit slot — see the `languageChanged` choke point.
50
89
  */
51
90
  export declare const LOCALE_SEED_STORAGE_KEY = "objectui-locale-seed";
52
91
  /**
53
- * Read the cached tenant locale seed, or `null` when nothing is cached.
92
+ * Read the cached locale seed — the server's last resolved locale for this
93
+ * browser's last signed-in caller (see {@link LOCALE_SEED_STORAGE_KEY}) — or
94
+ * `null` when nothing is cached.
54
95
  *
55
96
  * Same defensive posture as {@link readStoredLanguage}: `localStorage` is
56
97
  * absent under SSR and *throws* in Safari private mode / partitioned iframes,
@@ -58,12 +99,13 @@ export declare const LOCALE_SEED_STORAGE_KEY = "objectui-locale-seed";
58
99
  */
59
100
  export declare function readCachedLanguageSeed(): string | null;
60
101
  /**
61
- * Cache the tenant locale the server just answered with — the "revalidate" half
62
- * of the stale-while-revalidate contract (objectstack#5419, ruling point 2).
102
+ * Cache the locale the server just resolved for the signed-in caller — the
103
+ * "revalidate" half of the stale-while-revalidate contract (objectstack#5419,
104
+ * ruling point 2).
63
105
  *
64
106
  * Call this with whatever `/auth/me/localization` actually returned for an
65
107
  * authenticated caller. A `null`/`undefined` locale CLEARS the cache rather
66
- * than leaving the old value: a tenant that unsets its locale must reach
108
+ * than leaving the old value: a server answer that drops the locale must reach
67
109
  * choice-less devices on their next boot, and "keep the last good seed forever"
68
110
  * is precisely the being-pinned-by-a-stale-seed failure the ruling forbids.
69
111
  *
@@ -72,6 +114,53 @@ export declare function readCachedLanguageSeed(): string | null;
72
114
  * cannot complete.
73
115
  */
74
116
  export declare function cacheLanguageSeed(locale: string | null | undefined): void;
117
+ /**
118
+ * Options shared by {@link resolveBootstrapLanguage} and
119
+ * {@link preloadBootstrapLocale}. Each field mirrors the {@link I18nProvider}
120
+ * prop of the same name, because the two must answer identically — a host that
121
+ * preloads one catalogue while the provider boots into another has bought a
122
+ * flash of `en` instead of avoiding one.
123
+ */
124
+ export interface BootstrapLocaleOptions {
125
+ /** The `config` you will pass to {@link I18nProvider}. */
126
+ config?: I18nConfig;
127
+ /** The `persistLanguage` you will pass to {@link I18nProvider}. Default `true`. */
128
+ persistLanguage?: boolean;
129
+ /** Whether you will pass a `loadLanguage` loader. Default `false`. */
130
+ hasLoader?: boolean;
131
+ }
132
+ /**
133
+ * The language {@link I18nProvider} will boot in, computed WITHOUT creating an
134
+ * i18next instance (objectui#7479).
135
+ *
136
+ * The full precedence chain, unchanged: explicit choice → seed →
137
+ * browser language → `defaultLanguage` → `en`.
138
+ */
139
+ export declare function resolveBootstrapLanguage(options?: BootstrapLocaleOptions): string;
140
+ /**
141
+ * Fetch the catalogue {@link I18nProvider} will boot in, BEFORE you render it.
142
+ *
143
+ * ## Why a host would await this
144
+ *
145
+ * Built-in catalogues are lazy since objectui#7479, so the provider can be in
146
+ * one of two states on its first render, and the difference is visible:
147
+ *
148
+ * - **catalogue already resident** — the first paint is in the user's
149
+ * language, exactly as it was when all ten packs shipped eagerly;
150
+ * - **catalogue still in flight** — the first paint renders through
151
+ * `fallbackLng: 'en'` and re-renders in the user's language when the fetch
152
+ * lands. Correct, never blank, and a flash of English for a `zh-CN` viewer.
153
+ *
154
+ * ⇒ any host that owns an await before `createRoot().render()` should spend it
155
+ * here; `apps/console/src/main.tsx` does, alongside the runtime-config and auth
156
+ * preflight round-trips it already waits on. A host that has no such seam gets
157
+ * the documented `en`-while-loading behaviour instead, which is why this is a
158
+ * function a host CALLS rather than a requirement the provider imposes.
159
+ *
160
+ * Never rejects: a catalogue that will not download must not take the boot
161
+ * down, and the provider retries on mount.
162
+ */
163
+ export declare function preloadBootstrapLocale(options?: BootstrapLocaleOptions): Promise<void>;
75
164
  interface I18nContextValue {
76
165
  /** Current language code */
77
166
  language: string;