@object-ui/i18n 17.6.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.
- package/CHANGELOG.md +4320 -0
- package/README.md +192 -13
- package/dist/builtinAggregateLabels.d.ts +40 -0
- package/dist/builtinAggregateLabels.js +23 -0
- package/dist/currency.d.ts +27 -3
- package/dist/currency.js +28 -4
- package/dist/fallbackInterpolation.d.ts +79 -0
- package/dist/fallbackInterpolation.js +124 -0
- package/dist/i18n.d.ts +27 -1
- package/dist/i18n.js +99 -16
- package/dist/index.d.ts +7 -13
- package/dist/index.js +41 -17
- package/dist/locales/ar.d.ts +946 -113
- package/dist/locales/ar.js +1095 -161
- package/dist/locales/de.d.ts +618 -113
- package/dist/locales/de.js +715 -134
- package/dist/locales/en.d.ts +632 -127
- package/dist/locales/en.js +1044 -162
- package/dist/locales/es.d.ts +700 -113
- package/dist/locales/es.js +794 -130
- package/dist/locales/fr.d.ts +700 -113
- package/dist/locales/fr.js +795 -132
- package/dist/locales/index.d.ts +6990 -1169
- package/dist/locales/index.js +48 -9
- package/dist/locales/ja.d.ts +618 -113
- package/dist/locales/ja.js +708 -128
- package/dist/locales/ko.d.ts +618 -113
- package/dist/locales/ko.js +712 -132
- package/dist/locales/pt.d.ts +700 -113
- package/dist/locales/pt.js +794 -130
- package/dist/locales/registry.d.ts +102 -0
- package/dist/locales/registry.js +220 -0
- package/dist/locales/rtl.d.ts +24 -0
- package/dist/locales/rtl.js +26 -0
- package/dist/locales/ru.d.ts +782 -113
- package/dist/locales/ru.js +910 -152
- package/dist/locales/zh.d.ts +635 -130
- package/dist/locales/zh.js +725 -131
- package/dist/pickLocalized.d.ts +30 -0
- package/dist/pickLocalized.js +39 -0
- package/dist/provider.d.ts +101 -12
- package/dist/provider.js +335 -50
- package/dist/translateFn.d.ts +29 -0
- package/dist/translateFn.js +8 -0
- package/dist/useDisplayLocale.d.ts +11 -0
- package/dist/useDisplayLocale.js +11 -0
- package/dist/useObjectLabel.d.ts +40 -18
- package/dist/useObjectLabel.js +46 -14
- package/dist/useSafeTranslation.js +40 -42
- package/dist/utils/formatting.d.ts +16 -0
- package/dist/utils/formatting.js +23 -3
- package/dist/utils/spec-formatters.d.ts +13 -23
- package/dist/utils/spec-formatters.js +41 -2
- package/dist/utils/spec-translations.d.ts +16 -3
- package/dist/utils/spec-translations.js +52 -13
- package/package.json +12 -6
package/README.md
CHANGED
|
@@ -1,15 +1,16 @@
|
|
|
1
1
|
# @object-ui/i18n
|
|
2
2
|
|
|
3
|
-
Internationalization for Object UI —
|
|
3
|
+
Internationalization for Object UI — 10 built-in locales, RTL support, and date/currency formatting.
|
|
4
4
|
|
|
5
5
|
## Features
|
|
6
6
|
|
|
7
|
-
- 🌍 **
|
|
7
|
+
- 🌍 **10 Built-in Locales** - English, Chinese, Japanese, Korean, German, French, Spanish, Portuguese, Russian, Arabic
|
|
8
8
|
- 🔄 **RTL Support** - Automatic right-to-left layout for Arabic and other RTL languages
|
|
9
9
|
- 📅 **Date Formatting** - Locale-aware date, datetime, and relative time formatting
|
|
10
10
|
- 💰 **Currency & Number Formatting** - Locale-aware currency and number formatting
|
|
11
11
|
- 🎣 **React Hooks** - `useObjectTranslation` for translations, language switching, and direction
|
|
12
12
|
- 🏗️ **I18nProvider** - Context provider for internationalized applications
|
|
13
|
+
- 🪶 **Lazy catalogues** - only the active locale's catalogue is fetched; the other nine are separate chunks
|
|
13
14
|
- 🔌 **Extensible** - Add custom locales and translation keys
|
|
14
15
|
- 🎯 **Type-Safe** - Full TypeScript support with exported types
|
|
15
16
|
|
|
@@ -55,9 +56,15 @@ function MyComponent() {
|
|
|
55
56
|
Wraps your application with i18n context:
|
|
56
57
|
|
|
57
58
|
```tsx
|
|
59
|
+
import type { ReactNode } from 'react';
|
|
60
|
+
import { I18nProvider } from '@object-ui/i18n';
|
|
61
|
+
|
|
62
|
+
// Your app supplies the tree being wrapped.
|
|
63
|
+
declare const App: () => ReactNode;
|
|
64
|
+
|
|
58
65
|
<I18nProvider config={{ defaultLanguage: 'en', fallbackLanguage: 'en' }}>
|
|
59
66
|
<App />
|
|
60
|
-
</I18nProvider
|
|
67
|
+
</I18nProvider>;
|
|
61
68
|
```
|
|
62
69
|
|
|
63
70
|
#### Language persistence
|
|
@@ -72,14 +79,21 @@ An explicit choice outranks browser detection — otherwise a user who picked
|
|
|
72
79
|
on a `ja` browser would be handed `ja` back on every reload. A stored value the
|
|
73
80
|
app no longer offers (not a built-in pack, not in `config.resources`) is ignored
|
|
74
81
|
*and purged*, so a stale entry can never lock the UI to a locale with no
|
|
75
|
-
translations.
|
|
82
|
+
translations. A region-tagged choice such as `zh-CN` is served by its base
|
|
83
|
+
language's built-in pack (`zh`), so it is kept, and kept as written: the
|
|
84
|
+
provider's `language`, `<html lang>` and the stored value all stay `zh-CN`.
|
|
76
85
|
|
|
77
86
|
```tsx
|
|
87
|
+
import type { ReactNode } from 'react';
|
|
88
|
+
import { I18nProvider } from '@object-ui/i18n';
|
|
89
|
+
|
|
90
|
+
declare const Preview: () => ReactNode;
|
|
91
|
+
|
|
78
92
|
// Fixed-language surfaces (previews, demos, screenshot harnesses) opt out —
|
|
79
93
|
// they neither restore nor write the preference.
|
|
80
94
|
<I18nProvider config={{ defaultLanguage: 'en' }} persistLanguage={false}>
|
|
81
95
|
<Preview />
|
|
82
|
-
</I18nProvider
|
|
96
|
+
</I18nProvider>;
|
|
83
97
|
```
|
|
84
98
|
|
|
85
99
|
Bringing your own `instance`? Then its bootstrap language is yours to choose —
|
|
@@ -91,9 +105,47 @@ Switching through such an instance is still persisted.
|
|
|
91
105
|
Hook for translations and language management:
|
|
92
106
|
|
|
93
107
|
```tsx
|
|
94
|
-
|
|
108
|
+
import { useObjectTranslation } from '@object-ui/i18n';
|
|
109
|
+
|
|
110
|
+
function LanguageBar() {
|
|
111
|
+
const { t, language, changeLanguage, direction } = useObjectTranslation();
|
|
112
|
+
|
|
113
|
+
return (
|
|
114
|
+
<div dir={direction}>
|
|
115
|
+
{t('common.save')} — {language}
|
|
116
|
+
<button onClick={() => changeLanguage('zh')}>中文</button>
|
|
117
|
+
</div>
|
|
118
|
+
);
|
|
119
|
+
}
|
|
120
|
+
```
|
|
121
|
+
|
|
122
|
+
#### Count labels are i18next count families
|
|
123
|
+
|
|
124
|
+
A label a word must agree with — `3 replies`, `Approve 3 requests?` — is one
|
|
125
|
+
i18next count family: pass the number as `count` and let i18next pick the slot
|
|
126
|
+
`Intl.PluralRules` selects for the language. Every pack spells out each
|
|
127
|
+
category its language has (`en` `_one`/`_other`; `ru` adds `_few`/`_many`; `ar`
|
|
128
|
+
has all six), plus a count-invariant base key for a call made without a count.
|
|
129
|
+
|
|
130
|
+
```tsx
|
|
131
|
+
import { useObjectTranslation } from '@object-ui/i18n';
|
|
132
|
+
|
|
133
|
+
function ReplyCount({ replies }: { replies: readonly string[] }) {
|
|
134
|
+
const { t } = useObjectTranslation();
|
|
135
|
+
|
|
136
|
+
// "1 reply", "3 replies"; Russian «3 ответа», «5 ответов»
|
|
137
|
+
return <span>{t('detail.replyCount', { count: replies.length })}</span>;
|
|
138
|
+
}
|
|
95
139
|
```
|
|
96
140
|
|
|
141
|
+
Pass `count` as a **number** — i18next selects a slot for nothing else — and
|
|
142
|
+
never choose the key yourself (`count === 1 ? 'xOne' : 'x'`): two keys give a
|
|
143
|
+
language two forms, and Russian needs three, Arabic five. A
|
|
144
|
+
`createSafeTranslation` defaults table answers the same way on a provider-less
|
|
145
|
+
host: it reads a family's `_one` / `_other` row for a numeric `count`, then the
|
|
146
|
+
base row. `count-families-11445.test.ts` holds every `{{count}}` value in the
|
|
147
|
+
ten packs to this rule.
|
|
148
|
+
|
|
97
149
|
### createI18n
|
|
98
150
|
|
|
99
151
|
Factory for creating an i18n instance outside React:
|
|
@@ -105,27 +157,128 @@ const i18n = createI18n({ defaultLanguage: 'de' });
|
|
|
105
157
|
i18n.t('common.cancel'); // "Abbrechen"
|
|
106
158
|
```
|
|
107
159
|
|
|
160
|
+
### TranslateFn — typing a `t` you are handed
|
|
161
|
+
|
|
162
|
+
A helper that receives `t` from its caller, rather than calling a hook itself,
|
|
163
|
+
types that parameter with `TranslateFn`: i18next's `t` narrowed to
|
|
164
|
+
`(key: string, options?: Record<string, unknown>) => string`. This package is
|
|
165
|
+
the one authority for that type — import it rather than declaring a local
|
|
166
|
+
copy:
|
|
167
|
+
|
|
168
|
+
```ts
|
|
169
|
+
import type { TranslateFn } from '@object-ui/i18n';
|
|
170
|
+
|
|
171
|
+
export function saveLabel(t: TranslateFn): string {
|
|
172
|
+
return t('common.save');
|
|
173
|
+
}
|
|
174
|
+
```
|
|
175
|
+
|
|
108
176
|
### Formatting Utilities
|
|
109
177
|
|
|
110
178
|
Locale-aware formatting functions:
|
|
111
179
|
|
|
180
|
+
Each formatter takes the value first and an **options object** second; the
|
|
181
|
+
locale is a field on that object (`DateFormatOptions`, `CurrencyFormatOptions`,
|
|
182
|
+
`NumberFormatOptions`), never a positional argument. `formatRelativeTime` is the
|
|
183
|
+
one exception: it takes the locale directly, and derives the unit from how far
|
|
184
|
+
the date is from now.
|
|
185
|
+
|
|
112
186
|
```tsx
|
|
113
187
|
import { formatDate, formatCurrency, formatNumber, formatRelativeTime } from '@object-ui/i18n';
|
|
114
188
|
|
|
115
|
-
formatDate(new Date(), 'en');
|
|
116
|
-
formatCurrency(99.99, 'USD', 'en');
|
|
117
|
-
formatNumber(1234567, 'de');
|
|
118
|
-
formatRelativeTime(-3
|
|
189
|
+
formatDate(new Date(2025, 0, 1), { locale: 'en' }); // "Jan 1, 2025"
|
|
190
|
+
formatCurrency(99.99, { currency: 'USD', locale: 'en' }); // "$99.99"
|
|
191
|
+
formatNumber(1234567, { locale: 'de' }); // "1.234.567"
|
|
192
|
+
formatRelativeTime(Date.now() - 3 * 86_400_000, 'en'); // "3 days ago"
|
|
119
193
|
```
|
|
120
194
|
|
|
121
|
-
|
|
195
|
+
A date-only string such as `'2026-09-01'` names a calendar day: every date
|
|
196
|
+
helper reads it as that day in every viewer's time zone (`formatDateTime` shows
|
|
197
|
+
its midnight, and `formatRelativeTime` counts to the start of it). A string
|
|
198
|
+
with a time part is an instant, read in the viewer's zone. `formatDateSpec`
|
|
199
|
+
applies its `timeZone` to an instant only.
|
|
122
200
|
|
|
123
|
-
|
|
201
|
+
### Built-in locales — one is resident, nine are fetched on demand
|
|
202
|
+
|
|
203
|
+
The package entry re-exports **`en` only**. It is `fallbackLng`, it is the
|
|
204
|
+
source of the `TranslationKeys` type, and it is the synchronous dictionary the
|
|
205
|
+
app-shell splash renders from before i18n is usable, so it has to be there
|
|
206
|
+
without awaiting anything. The other nine catalogues are separate chunks your
|
|
207
|
+
bundler emits once each and the browser fetches only when that locale is
|
|
208
|
+
actually used — roughly 400 KB gzipped that a page load no longer pays
|
|
209
|
+
(objectui#7479).
|
|
124
210
|
|
|
125
211
|
```tsx
|
|
126
|
-
import {
|
|
212
|
+
import {
|
|
213
|
+
en, // resident
|
|
214
|
+
BUILT_IN_LANGUAGE_CODES, // all ten codes, no catalogue payload
|
|
215
|
+
isBuiltInLanguage,
|
|
216
|
+
loadBuiltInLocale, // fetch one: Promise<catalogue | null>
|
|
217
|
+
getLoadedBuiltInLocales, // synchronous snapshot of what is resident
|
|
218
|
+
} from '@object-ui/i18n';
|
|
219
|
+
|
|
220
|
+
BUILT_IN_LANGUAGE_CODES; // ['en','zh','ja','ko','de','fr','es','pt','ru','ar']
|
|
221
|
+
await loadBuiltInLocale('zh'); // the zh catalogue, fetched once and memoised
|
|
222
|
+
await loadBuiltInLocale('zh-CN'); // the same zh catalogue — a region tag is served by its base
|
|
223
|
+
await loadBuiltInLocale('tlh'); // null — not a code this package ships
|
|
224
|
+
isBuiltInLanguage('zh-CN'); // true
|
|
225
|
+
isBuiltInLanguage('xx-YY'); // false — no catalogue for the tag or its base
|
|
127
226
|
```
|
|
128
227
|
|
|
228
|
+
A code is looked up **exact tag first, then its base language**: `zh-CN` is
|
|
229
|
+
served by the `zh` catalogue, while a catalogue keyed by a full tag would win
|
|
230
|
+
over its base. `isBuiltInLanguage` and `isBuiltInLocaleLoaded` answer by the same
|
|
231
|
+
rule as `loadBuiltInLocale`, and a catalogue is memoised under its own code, so
|
|
232
|
+
`zh` and `zh-CN` share one fetch.
|
|
233
|
+
|
|
234
|
+
`I18nProvider` does this for you: it fetches the catalogue for whatever language
|
|
235
|
+
it boots into, and `changeLanguage()` awaits the new catalogue before switching,
|
|
236
|
+
so a switcher needs no extra wiring.
|
|
237
|
+
|
|
238
|
+
#### Resolving before the first render
|
|
239
|
+
|
|
240
|
+
Without a resolve, a provider booting into `zh` paints once through the `en`
|
|
241
|
+
fallback — correct strings, never a raw key — and re-renders in Chinese when the
|
|
242
|
+
catalogue lands. If your app already awaits something before mounting, spend
|
|
243
|
+
that await here instead and the first paint is already right:
|
|
244
|
+
|
|
245
|
+
```tsx
|
|
246
|
+
import { I18nProvider, preloadBootstrapLocale, resolveBootstrapLanguage } from '@object-ui/i18n';
|
|
247
|
+
import { createRoot } from 'react-dom/client';
|
|
248
|
+
import type { FC } from 'react';
|
|
249
|
+
|
|
250
|
+
declare const App: FC;
|
|
251
|
+
declare const container: HTMLElement;
|
|
252
|
+
declare const loadLanguage: (lang: string) => Promise<Record<string, unknown>>;
|
|
253
|
+
|
|
254
|
+
// Pass the SAME options you will pass to <I18nProvider>, or the two will
|
|
255
|
+
// disagree about which language this boot is in.
|
|
256
|
+
await preloadBootstrapLocale({ hasLoader: true });
|
|
257
|
+
createRoot(container).render(
|
|
258
|
+
<I18nProvider loadLanguage={loadLanguage}>
|
|
259
|
+
<App />
|
|
260
|
+
</I18nProvider>,
|
|
261
|
+
);
|
|
262
|
+
|
|
263
|
+
resolveBootstrapLanguage({ hasLoader: true }); // 'zh' — the same answer, no fetch
|
|
264
|
+
```
|
|
265
|
+
|
|
266
|
+
`preloadBootstrapLocale` never rejects: a catalogue that will not download must
|
|
267
|
+
not take the boot down, and the provider retries on mount.
|
|
268
|
+
`apps/console/src/main.tsx` is the worked example.
|
|
269
|
+
|
|
270
|
+
#### When you really do want all ten
|
|
271
|
+
|
|
272
|
+
The parity suites do, and so does an app that ships every language resident. The
|
|
273
|
+
door is a separate specifier, so the cost is visible at the import site:
|
|
274
|
+
|
|
275
|
+
```tsx
|
|
276
|
+
import { builtInLocales, zh, ru } from '@object-ui/i18n/locales';
|
|
277
|
+
```
|
|
278
|
+
|
|
279
|
+
⚠️ That module statically imports every catalogue. Never reach for it on a
|
|
280
|
+
page-load path — that is exactly the eager payload the split removed.
|
|
281
|
+
|
|
129
282
|
### RTL Helpers
|
|
130
283
|
|
|
131
284
|
```tsx
|
|
@@ -135,6 +288,32 @@ isRTL('ar'); // true
|
|
|
135
288
|
isRTL('en'); // false
|
|
136
289
|
```
|
|
137
290
|
|
|
291
|
+
### Localized value helpers — reading and editing an `I18nLabel`
|
|
292
|
+
|
|
293
|
+
Server-driven metadata types a label as a plain string **or** an inline
|
|
294
|
+
per-locale map (`{ en: 'Pricing', 'zh-CN': '定价' }`). An authoring surface that
|
|
295
|
+
shows one locale in one text input has to answer three separate questions, so
|
|
296
|
+
there are three functions and each answers exactly one:
|
|
297
|
+
|
|
298
|
+
```tsx
|
|
299
|
+
import { pickLocalized, setLocalized, clearLocalized } from '@object-ui/i18n';
|
|
300
|
+
|
|
301
|
+
const stored = { en: 'Pricing', 'zh-CN': '定价' };
|
|
302
|
+
|
|
303
|
+
pickLocalized(stored, 'en-US'); // 'Pricing' — which entry to DISPLAY
|
|
304
|
+
setLocalized(stored, 'en-US', 'Plans'); // { en: 'Plans', 'zh-CN': '定价' }
|
|
305
|
+
clearLocalized(stored, 'en-US'); // { 'zh-CN': '定价' }
|
|
306
|
+
```
|
|
307
|
+
|
|
308
|
+
⛔ Never write the input's string back as the whole value — that replaces the
|
|
309
|
+
map and every locale the author was not looking at is gone, silently, on the
|
|
310
|
+
first keystroke. `setLocalized` replaces **one** entry (adding it when the
|
|
311
|
+
active locale has none) and `clearLocalized` removes **one** entry (returning
|
|
312
|
+
`undefined` once nothing localized is left). Both choose that entry with the
|
|
313
|
+
same limbs, and deliberately stop short of `pickLocalized`'s display-only
|
|
314
|
+
fallbacks, so an author editing in `fr` while shown the `en` string can neither
|
|
315
|
+
overwrite nor delete English.
|
|
316
|
+
|
|
138
317
|
## Scope — the `engine.*` carve-out (metadata-admin / Studio)
|
|
139
318
|
|
|
140
319
|
Not every user-facing string in this repository resolves through these packs.
|
|
@@ -0,0 +1,40 @@
|
|
|
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
|
+
* objectui#7258 — the locale labels for the analytics service's BUILT-IN
|
|
10
|
+
* default measures, keyed by the wire discriminator a result field carries as
|
|
11
|
+
* `builtinAggregate` (producer half: objectstack#14492).
|
|
12
|
+
*
|
|
13
|
+
* `@object-ui/core`'s `buildChartSeries` cannot read the locale bundle (that
|
|
14
|
+
* package is React-free and i18n-free by design), so this is the seam: the
|
|
15
|
+
* renderer that holds the provider resolves the six strings ONCE through its
|
|
16
|
+
* `useSafeTranslate()` and hands the map to core as
|
|
17
|
+
* `ChartSeriesOptions.builtinAggregateLabels` — the same division of labour
|
|
18
|
+
* `chart.nullCategory` already makes for the null bucket's label.
|
|
19
|
+
*
|
|
20
|
+
* The keys are the `report.aggregate.*` family the matrix report and the
|
|
21
|
+
* dashboard's aggregate charts already read, not a second namespace: the
|
|
22
|
+
* vocabulary (Count / Sum / Average / …) is the same on every surface, so ten
|
|
23
|
+
* packs already carry it and a new key would only add drift surface. The only
|
|
24
|
+
* mapping done here is spelling — the wire enum says `count_distinct`, the
|
|
25
|
+
* bundle key says `countDistinct`.
|
|
26
|
+
*
|
|
27
|
+
* The English fallbacks are byte-equal to the `en` pack's values, so a
|
|
28
|
+
* provider-less host renders exactly what an `en` session renders (pinned in
|
|
29
|
+
* `__tests__/builtinAggregateLabels-locale-parity-7258.test.ts`).
|
|
30
|
+
*/
|
|
31
|
+
import type { BuiltinAggregate } from '@object-ui/core';
|
|
32
|
+
/** The per-call translate shape `useSafeTranslate()` returns: first real translation, else the English fallback. */
|
|
33
|
+
export type SafeTranslate = (key: string, fallback: string) => string;
|
|
34
|
+
/**
|
|
35
|
+
* Resolve the display label of every built-in aggregate through the locale
|
|
36
|
+
* bundle. Typed as the FULL record on purpose: a member added to core's
|
|
37
|
+
* `BUILTIN_AGGREGATES` without a line here is a compile error, not a legend
|
|
38
|
+
* that silently falls back to the server's English.
|
|
39
|
+
*/
|
|
40
|
+
export declare function builtinAggregateLabels(tt: SafeTranslate): Record<BuiltinAggregate, string>;
|
|
@@ -0,0 +1,23 @@
|
|
|
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
|
+
* Resolve the display label of every built-in aggregate through the locale
|
|
10
|
+
* bundle. Typed as the FULL record on purpose: a member added to core's
|
|
11
|
+
* `BUILTIN_AGGREGATES` without a line here is a compile error, not a legend
|
|
12
|
+
* that silently falls back to the server's English.
|
|
13
|
+
*/
|
|
14
|
+
export function builtinAggregateLabels(tt) {
|
|
15
|
+
return {
|
|
16
|
+
count: tt('report.aggregate.count', 'Count'),
|
|
17
|
+
count_distinct: tt('report.aggregate.countDistinct', 'Distinct Count'),
|
|
18
|
+
sum: tt('report.aggregate.sum', 'Sum'),
|
|
19
|
+
avg: tt('report.aggregate.avg', 'Average'),
|
|
20
|
+
min: tt('report.aggregate.min', 'Min'),
|
|
21
|
+
max: tt('report.aggregate.max', 'Max'),
|
|
22
|
+
};
|
|
23
|
+
}
|
package/dist/currency.d.ts
CHANGED
|
@@ -7,12 +7,35 @@
|
|
|
7
7
|
*/
|
|
8
8
|
/**
|
|
9
9
|
* Resolve the display currency code for a currency-typed field, in precedence
|
|
10
|
-
* order:
|
|
11
|
-
*
|
|
12
|
-
*
|
|
10
|
+
* order:
|
|
11
|
+
*
|
|
12
|
+
* 1. the field's explicit `currency` — a per-node display code. The spec
|
|
13
|
+
* declares it on presentation surfaces (the metric widget's `currency`, a
|
|
14
|
+
* dataset measure's `currency`) and looser grid/column configs carry it;
|
|
15
|
+
* `FieldSchema` itself refuses it as a field key;
|
|
16
|
+
* 2. `currencyConfig.defaultCurrency`, ONLY when `currencyConfig.currencyMode`
|
|
17
|
+
* is `'fixed'` — the spec's one fixed-currency declaration;
|
|
18
|
+
* 3. a legacy top-level `defaultCurrency` — a pre-spec spelling that
|
|
19
|
+
* `FieldSchema` refuses as an unrecognized key, kept in place so configs
|
|
20
|
+
* written against it still read their code;
|
|
21
|
+
* 4. the tenant default (`localization.currency`, ADR-0053, surfaced via
|
|
22
|
+
* {@link useLocalization}).
|
|
23
|
+
*
|
|
13
24
|
* Returns `undefined` when none is known, so the renderer shows a plain number
|
|
14
25
|
* rather than guessing a symbol.
|
|
15
26
|
*
|
|
27
|
+
* `currencyMode: 'dynamic'` — and a `currencyConfig` with no `currencyMode` at
|
|
28
|
+
* all, which `@objectstack/spec` defaults to `'dynamic'` — skips step 2
|
|
29
|
+
* (objectui#10422). The spec describes that mode as "user selectable", and its
|
|
30
|
+
* `FieldSchema` guidance says a field without a fixed currency "uses the tenant
|
|
31
|
+
* default at runtime". No value carries a user-selected code today (a currency
|
|
32
|
+
* value is a bare number), so a dynamic field reads the tenant default, never
|
|
33
|
+
* its `defaultCurrency`. That matters beyond authored dynamic configs: the
|
|
34
|
+
* spec parses an EMPTY `currencyConfig` to
|
|
35
|
+
* `{ currencyMode: 'dynamic', defaultCurrency: 'CNY' }`, which must not render
|
|
36
|
+
* as yuan. If a value ever carries its own selected code, that code is the one
|
|
37
|
+
* to win, ahead of the tenant default.
|
|
38
|
+
*
|
|
16
39
|
* This is the single resolution every field / measure / cell renderer shares -
|
|
17
40
|
* ending the per-renderer drift where some read only `currency`, others only
|
|
18
41
|
* `defaultCurrency`, and others `currencyConfig`. It lives in `@object-ui/i18n`
|
|
@@ -23,6 +46,7 @@ export declare function resolveFieldCurrency(field: {
|
|
|
23
46
|
currency?: string;
|
|
24
47
|
defaultCurrency?: string;
|
|
25
48
|
currencyConfig?: {
|
|
49
|
+
currencyMode?: 'dynamic' | 'fixed';
|
|
26
50
|
defaultCurrency?: string;
|
|
27
51
|
};
|
|
28
52
|
} | null | undefined, tenantDefault?: string): string | undefined;
|
package/dist/currency.js
CHANGED
|
@@ -7,12 +7,35 @@
|
|
|
7
7
|
*/
|
|
8
8
|
/**
|
|
9
9
|
* Resolve the display currency code for a currency-typed field, in precedence
|
|
10
|
-
* order:
|
|
11
|
-
*
|
|
12
|
-
*
|
|
10
|
+
* order:
|
|
11
|
+
*
|
|
12
|
+
* 1. the field's explicit `currency` — a per-node display code. The spec
|
|
13
|
+
* declares it on presentation surfaces (the metric widget's `currency`, a
|
|
14
|
+
* dataset measure's `currency`) and looser grid/column configs carry it;
|
|
15
|
+
* `FieldSchema` itself refuses it as a field key;
|
|
16
|
+
* 2. `currencyConfig.defaultCurrency`, ONLY when `currencyConfig.currencyMode`
|
|
17
|
+
* is `'fixed'` — the spec's one fixed-currency declaration;
|
|
18
|
+
* 3. a legacy top-level `defaultCurrency` — a pre-spec spelling that
|
|
19
|
+
* `FieldSchema` refuses as an unrecognized key, kept in place so configs
|
|
20
|
+
* written against it still read their code;
|
|
21
|
+
* 4. the tenant default (`localization.currency`, ADR-0053, surfaced via
|
|
22
|
+
* {@link useLocalization}).
|
|
23
|
+
*
|
|
13
24
|
* Returns `undefined` when none is known, so the renderer shows a plain number
|
|
14
25
|
* rather than guessing a symbol.
|
|
15
26
|
*
|
|
27
|
+
* `currencyMode: 'dynamic'` — and a `currencyConfig` with no `currencyMode` at
|
|
28
|
+
* all, which `@objectstack/spec` defaults to `'dynamic'` — skips step 2
|
|
29
|
+
* (objectui#10422). The spec describes that mode as "user selectable", and its
|
|
30
|
+
* `FieldSchema` guidance says a field without a fixed currency "uses the tenant
|
|
31
|
+
* default at runtime". No value carries a user-selected code today (a currency
|
|
32
|
+
* value is a bare number), so a dynamic field reads the tenant default, never
|
|
33
|
+
* its `defaultCurrency`. That matters beyond authored dynamic configs: the
|
|
34
|
+
* spec parses an EMPTY `currencyConfig` to
|
|
35
|
+
* `{ currencyMode: 'dynamic', defaultCurrency: 'CNY' }`, which must not render
|
|
36
|
+
* as yuan. If a value ever carries its own selected code, that code is the one
|
|
37
|
+
* to win, ahead of the tenant default.
|
|
38
|
+
*
|
|
16
39
|
* This is the single resolution every field / measure / cell renderer shares -
|
|
17
40
|
* ending the per-renderer drift where some read only `currency`, others only
|
|
18
41
|
* `defaultCurrency`, and others `currencyConfig`. It lives in `@object-ui/i18n`
|
|
@@ -20,8 +43,9 @@
|
|
|
20
43
|
* concern; `@object-ui/fields` re-exports it for backward compatibility.
|
|
21
44
|
*/
|
|
22
45
|
export function resolveFieldCurrency(field, tenantDefault) {
|
|
46
|
+
const config = field?.currencyConfig;
|
|
23
47
|
return (field?.currency ||
|
|
24
|
-
|
|
48
|
+
(config?.currencyMode === 'fixed' ? config.defaultCurrency : undefined) ||
|
|
25
49
|
field?.defaultCurrency ||
|
|
26
50
|
tenantDefault ||
|
|
27
51
|
undefined);
|
|
@@ -0,0 +1,79 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The ONE interpolator every provider-less translation path in this package
|
|
3
|
+
* runs — extracted so there is exactly one of it (objectui#6219).
|
|
4
|
+
*
|
|
5
|
+
* ## Why this module exists at all
|
|
6
|
+
*
|
|
7
|
+
* Three things can render a translation in this repo, and each has its own
|
|
8
|
+
* answer to "does a `{{hole}}` get filled?":
|
|
9
|
+
*
|
|
10
|
+
* 1. **i18next, with an `I18nProvider` mounted.** It interpolates. This is
|
|
11
|
+
* the reference behaviour every other path has to agree with.
|
|
12
|
+
* 2. **`createSafeTranslation`'s `fallbackT`** (`useSafeTranslation.ts`).
|
|
13
|
+
* It interpolates — with the exact literal needle below.
|
|
14
|
+
* 3. **react-i18next's not-ready `t`**, which is what a bare
|
|
15
|
+
* `useObjectTranslation()` yields when no i18next instance is initialised.
|
|
16
|
+
* Measured in `react-i18next@17.0.11/dist/es/useTranslation.js`:
|
|
17
|
+
* `notReadyT` returns `options.defaultValue` VERBATIM and does not
|
|
18
|
+
* interpolate at all.
|
|
19
|
+
*
|
|
20
|
+
* (3) was objectui#6219: 68 inline defaults across 24 files carried a
|
|
21
|
+
* `{{hole}}` that reached the user as literal braces on any host that embeds an
|
|
22
|
+
* ObjectUI component without `I18nProvider` — the configuration
|
|
23
|
+
* `createSafeTranslation` exists for (objectui#3865), so a supported one.
|
|
24
|
+
* `useObjectTranslation` now runs its not-ready result through this function,
|
|
25
|
+
* which makes (3) agree with (2), and (2) already agrees with (1) for the one
|
|
26
|
+
* spelling this repo is allowed to author.
|
|
27
|
+
*
|
|
28
|
+
* ## Why one shared function rather than a second copy
|
|
29
|
+
*
|
|
30
|
+
* The semantics below are pinned in three places at once —
|
|
31
|
+
* `useSafeTranslation.test.tsx` (behaviour), `fallback-placeholder-spelling-3512.test.ts`
|
|
32
|
+
* (the copy is held to what this can resolve) and
|
|
33
|
+
* `scripts/check-i18n-call-site-keys.mjs` class 7 (the same rule over inline
|
|
34
|
+
* defaults). A second interpolator with its own drift would have made all three
|
|
35
|
+
* of those true of one path and quietly false of the other. There is one
|
|
36
|
+
* function, so "the fallback resolves only `{{name}}`" is one fact.
|
|
37
|
+
*
|
|
38
|
+
* ## The exact spelling, and why only that one
|
|
39
|
+
*
|
|
40
|
+
* objectui#3512 measured that i18next additionally accepts `{{ name }}`,
|
|
41
|
+
* `{{count, number}}`, `{{- name}}` and `$t(key)`, and the ruling was
|
|
42
|
+
* deliberately NOT to teach the fallback three more dialects (a second
|
|
43
|
+
* interpolator to keep in step with i18next forever) but to hold the copy to
|
|
44
|
+
* the one spelling both paths agree on — enforced by the two gates named above.
|
|
45
|
+
* This module keeps that ruling: it widens WHICH BINDINGS interpolate, never
|
|
46
|
+
* WHICH SPELLINGS resolve.
|
|
47
|
+
*/
|
|
48
|
+
/**
|
|
49
|
+
* The i18next option that names the string to use when the lookup misses.
|
|
50
|
+
*
|
|
51
|
+
* It is a LOOKUP CONTROL, not interpolation data (objectui#3865): it chooses
|
|
52
|
+
* which string is rendered, so it must never also be spliced into a
|
|
53
|
+
* `{{defaultValue}}` hole in the string it chose — or in any other.
|
|
54
|
+
*/
|
|
55
|
+
export declare const DEFAULT_VALUE_OPTION = "defaultValue";
|
|
56
|
+
/**
|
|
57
|
+
* Fill `{{name}}` holes in `value` from `options`, i18next-compatibly.
|
|
58
|
+
*
|
|
59
|
+
* Returns `value` untouched when there is nothing to fill from, so a caller can
|
|
60
|
+
* hand it every result unconditionally.
|
|
61
|
+
*
|
|
62
|
+
* @param value - The string that was chosen for rendering.
|
|
63
|
+
* @param options - The `t()` call's options object, or `undefined`.
|
|
64
|
+
*/
|
|
65
|
+
export declare function interpolateFallback(value: string, options?: Record<string, unknown>): string;
|
|
66
|
+
/**
|
|
67
|
+
* The options object of a `t()` call, or `undefined` when the call passed none.
|
|
68
|
+
*
|
|
69
|
+
* i18next's `t` accepts both `t(key, options)` and `t(key, defaultValue,
|
|
70
|
+
* options)`, so the options object is the LAST argument when it is an object at
|
|
71
|
+
* all. A string second argument is a default value, not data — there is nothing
|
|
72
|
+
* to interpolate from, which is why `t(key, 'Hi {{name}}')` keeps its braces on
|
|
73
|
+
* both paths.
|
|
74
|
+
*
|
|
75
|
+
* Arrays are excluded deliberately: `t(key, [...])` is not an i18next options
|
|
76
|
+
* shape, and `Object.entries` over one would mint `{{0}}`-style needles that no
|
|
77
|
+
* copy in this repo spells.
|
|
78
|
+
*/
|
|
79
|
+
export declare function optionsOf(args: readonly unknown[]): Record<string, unknown> | undefined;
|
|
@@ -0,0 +1,124 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The ONE interpolator every provider-less translation path in this package
|
|
3
|
+
* runs — extracted so there is exactly one of it (objectui#6219).
|
|
4
|
+
*
|
|
5
|
+
* ## Why this module exists at all
|
|
6
|
+
*
|
|
7
|
+
* Three things can render a translation in this repo, and each has its own
|
|
8
|
+
* answer to "does a `{{hole}}` get filled?":
|
|
9
|
+
*
|
|
10
|
+
* 1. **i18next, with an `I18nProvider` mounted.** It interpolates. This is
|
|
11
|
+
* the reference behaviour every other path has to agree with.
|
|
12
|
+
* 2. **`createSafeTranslation`'s `fallbackT`** (`useSafeTranslation.ts`).
|
|
13
|
+
* It interpolates — with the exact literal needle below.
|
|
14
|
+
* 3. **react-i18next's not-ready `t`**, which is what a bare
|
|
15
|
+
* `useObjectTranslation()` yields when no i18next instance is initialised.
|
|
16
|
+
* Measured in `react-i18next@17.0.11/dist/es/useTranslation.js`:
|
|
17
|
+
* `notReadyT` returns `options.defaultValue` VERBATIM and does not
|
|
18
|
+
* interpolate at all.
|
|
19
|
+
*
|
|
20
|
+
* (3) was objectui#6219: 68 inline defaults across 24 files carried a
|
|
21
|
+
* `{{hole}}` that reached the user as literal braces on any host that embeds an
|
|
22
|
+
* ObjectUI component without `I18nProvider` — the configuration
|
|
23
|
+
* `createSafeTranslation` exists for (objectui#3865), so a supported one.
|
|
24
|
+
* `useObjectTranslation` now runs its not-ready result through this function,
|
|
25
|
+
* which makes (3) agree with (2), and (2) already agrees with (1) for the one
|
|
26
|
+
* spelling this repo is allowed to author.
|
|
27
|
+
*
|
|
28
|
+
* ## Why one shared function rather than a second copy
|
|
29
|
+
*
|
|
30
|
+
* The semantics below are pinned in three places at once —
|
|
31
|
+
* `useSafeTranslation.test.tsx` (behaviour), `fallback-placeholder-spelling-3512.test.ts`
|
|
32
|
+
* (the copy is held to what this can resolve) and
|
|
33
|
+
* `scripts/check-i18n-call-site-keys.mjs` class 7 (the same rule over inline
|
|
34
|
+
* defaults). A second interpolator with its own drift would have made all three
|
|
35
|
+
* of those true of one path and quietly false of the other. There is one
|
|
36
|
+
* function, so "the fallback resolves only `{{name}}`" is one fact.
|
|
37
|
+
*
|
|
38
|
+
* ## The exact spelling, and why only that one
|
|
39
|
+
*
|
|
40
|
+
* objectui#3512 measured that i18next additionally accepts `{{ name }}`,
|
|
41
|
+
* `{{count, number}}`, `{{- name}}` and `$t(key)`, and the ruling was
|
|
42
|
+
* deliberately NOT to teach the fallback three more dialects (a second
|
|
43
|
+
* interpolator to keep in step with i18next forever) but to hold the copy to
|
|
44
|
+
* the one spelling both paths agree on — enforced by the two gates named above.
|
|
45
|
+
* This module keeps that ruling: it widens WHICH BINDINGS interpolate, never
|
|
46
|
+
* WHICH SPELLINGS resolve.
|
|
47
|
+
*/
|
|
48
|
+
/**
|
|
49
|
+
* The i18next option that names the string to use when the lookup misses.
|
|
50
|
+
*
|
|
51
|
+
* It is a LOOKUP CONTROL, not interpolation data (objectui#3865): it chooses
|
|
52
|
+
* which string is rendered, so it must never also be spliced into a
|
|
53
|
+
* `{{defaultValue}}` hole in the string it chose — or in any other.
|
|
54
|
+
*/
|
|
55
|
+
export const DEFAULT_VALUE_OPTION = 'defaultValue';
|
|
56
|
+
/**
|
|
57
|
+
* Fill `{{name}}` holes in `value` from `options`, i18next-compatibly.
|
|
58
|
+
*
|
|
59
|
+
* Returns `value` untouched when there is nothing to fill from, so a caller can
|
|
60
|
+
* hand it every result unconditionally.
|
|
61
|
+
*
|
|
62
|
+
* @param value - The string that was chosen for rendering.
|
|
63
|
+
* @param options - The `t()` call's options object, or `undefined`.
|
|
64
|
+
*/
|
|
65
|
+
export function interpolateFallback(value, options) {
|
|
66
|
+
if (!options)
|
|
67
|
+
return value;
|
|
68
|
+
let out = value;
|
|
69
|
+
for (const [k, v] of Object.entries(options)) {
|
|
70
|
+
// Reserved: see DEFAULT_VALUE_OPTION. Skipped whatever its type, so an
|
|
71
|
+
// ignored non-string default cannot re-enter through this loop.
|
|
72
|
+
//
|
|
73
|
+
// This is the one deliberate divergence from i18next on this path, and it
|
|
74
|
+
// is unreachable with today's strings: i18next passes the whole options
|
|
75
|
+
// object to its interpolator, so it WOULD render
|
|
76
|
+
// `'Fallback: {{defaultValue}}'` as `'Fallback: INLINE'` (measured on
|
|
77
|
+
// 26.3.6) — but no value in this repo spells that hole (zero hits for
|
|
78
|
+
// `{{defaultValue` across packages/apps/examples), and splicing a fallback
|
|
79
|
+
// string into a hole named after itself has no sensible reading. The
|
|
80
|
+
// alternative — letting a call site's fallback text leak into an unrelated
|
|
81
|
+
// table value — is the worse of the two.
|
|
82
|
+
if (k === DEFAULT_VALUE_OPTION)
|
|
83
|
+
continue;
|
|
84
|
+
// `split(needle).join(value)` — deliberately not `replace`, and not
|
|
85
|
+
// `replaceAll` either (objectui#3418). This path must agree with i18next,
|
|
86
|
+
// which serves the *provider* path; any divergence is a silent fork that
|
|
87
|
+
// only shows up on provider-less hosts, where we are least likely to see
|
|
88
|
+
// it:
|
|
89
|
+
// 1. `replace` with a string needle substitutes only the FIRST
|
|
90
|
+
// occurrence. i18next substitutes every one, so a sentence that
|
|
91
|
+
// repeats a placeholder ("Selected {{count}} of {{count}} items" —
|
|
92
|
+
// natural in many locales, and often required by RTL / agglutinative
|
|
93
|
+
// word order) leaked literal braces to users.
|
|
94
|
+
// 2. `replace` AND `replaceAll` both interpret `$&`, `` $` ``, `$'` and
|
|
95
|
+
// `$$` in the *replacement* string. i18next does not. Values here are
|
|
96
|
+
// runtime data — record labels, search terms — so this one is
|
|
97
|
+
// reachable today, unlike (1).
|
|
98
|
+
// split/join is literal on both sides, which is exactly i18next's
|
|
99
|
+
// behaviour, and needs no regex escaping of the placeholder name.
|
|
100
|
+
out = out.split(`{{${k}}}`).join(String(v));
|
|
101
|
+
}
|
|
102
|
+
return out;
|
|
103
|
+
}
|
|
104
|
+
/**
|
|
105
|
+
* The options object of a `t()` call, or `undefined` when the call passed none.
|
|
106
|
+
*
|
|
107
|
+
* i18next's `t` accepts both `t(key, options)` and `t(key, defaultValue,
|
|
108
|
+
* options)`, so the options object is the LAST argument when it is an object at
|
|
109
|
+
* all. A string second argument is a default value, not data — there is nothing
|
|
110
|
+
* to interpolate from, which is why `t(key, 'Hi {{name}}')` keeps its braces on
|
|
111
|
+
* both paths.
|
|
112
|
+
*
|
|
113
|
+
* Arrays are excluded deliberately: `t(key, [...])` is not an i18next options
|
|
114
|
+
* shape, and `Object.entries` over one would mint `{{0}}`-style needles that no
|
|
115
|
+
* copy in this repo spells.
|
|
116
|
+
*/
|
|
117
|
+
export function optionsOf(args) {
|
|
118
|
+
if (args.length < 2)
|
|
119
|
+
return undefined;
|
|
120
|
+
const last = args[args.length - 1];
|
|
121
|
+
if (typeof last !== 'object' || last === null || Array.isArray(last))
|
|
122
|
+
return undefined;
|
|
123
|
+
return last;
|
|
124
|
+
}
|