@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/dist/useObjectLabel.d.ts
CHANGED
|
@@ -100,15 +100,17 @@ export declare function useObjectLabel(): {
|
|
|
100
100
|
* reaches this client — so nav labels arrive already localized. One
|
|
101
101
|
* owner, not two. To translate a sidebar group, translate it there.
|
|
102
102
|
*
|
|
103
|
-
* History (
|
|
103
|
+
* History (`9c60144b5`): until then this docstring promised
|
|
104
104
|
* `"Sales" → "销售"` for sidebar groups, and `NavigationRenderer`
|
|
105
105
|
* accepted `resolveGroupLabel`/`resolveItemLabel` to wire it up. Those
|
|
106
106
|
* props could never fire — the renderer's `isCustomized` guard compared a
|
|
107
107
|
* node's authored label against its own `id` (`Workspace` vs
|
|
108
108
|
* `grp_workspace`), which never match, so the guard was true for every
|
|
109
|
-
* real entry. The props are gone
|
|
110
|
-
*
|
|
111
|
-
*
|
|
109
|
+
* real entry. The props are gone, and so is the guard itself
|
|
110
|
+
* (objectui#11201: the renderer no longer matches a label's text against
|
|
111
|
+
* any name); the promise was false, not merely unused. This helper is kept
|
|
112
|
+
* as a plain key reader for a consumer that renders navigation itself — it
|
|
113
|
+
* has no first-party caller.
|
|
112
114
|
*/
|
|
113
115
|
navGroupLabel: (appName: string, groupId: string, fallback: string) => string;
|
|
114
116
|
/**
|
|
@@ -194,11 +196,6 @@ export declare function useObjectLabel(): {
|
|
|
194
196
|
* that spells the prefix out does not resolve (see `viewSuffixes`).
|
|
195
197
|
*/
|
|
196
198
|
viewLabel: (objectName: string, viewName: string, fallback: string) => string;
|
|
197
|
-
/**
|
|
198
|
-
* Resolve translated list-view description.
|
|
199
|
-
* Convention: `{ns}.objects.{objectName}._views.{viewName}.description`.
|
|
200
|
-
*/
|
|
201
|
-
viewDescription: (objectName: string, viewName: string, fallback?: string) => string | undefined;
|
|
202
199
|
/**
|
|
203
200
|
* Resolve translated list-view emptyState. Returns a {title, message}
|
|
204
201
|
* tuple with each field independently translated. Convention:
|
|
@@ -241,6 +238,20 @@ export declare function useObjectLabel(): {
|
|
|
241
238
|
* falling back to `{ns}.globalActions.{actionName}.successMessage`.
|
|
242
239
|
*/
|
|
243
240
|
actionSuccess: (objectName: string | undefined, actionName: string, fallback?: string) => string | undefined;
|
|
241
|
+
/**
|
|
242
|
+
* Resolve the translated success copy for ONE handler outcome — an entry of
|
|
243
|
+
* the action's `outcomeMessages` (`@objectstack/spec` 17.6.0, objectui#11344).
|
|
244
|
+
* Convention: `{ns}.objects.{objectName}._actions.{actionName}.outcomeMessages.{outcome}`,
|
|
245
|
+
* falling back to `{ns}.globalActions.{actionName}.outcomeMessages.{outcome}`
|
|
246
|
+
* — the address the spec's translation bundle declares for the key.
|
|
247
|
+
* Returns undefined when no translation and no fallback exist.
|
|
248
|
+
*
|
|
249
|
+
* Like `actionSuccess`, the answer is a TEMPLATE: a `${result.*}` token in a
|
|
250
|
+
* bundle entry passes through untouched (i18next interpolates its own
|
|
251
|
+
* `{{…}}` form, never `${…}`), and the action runner fills it in from the
|
|
252
|
+
* handler's answer once the action has run.
|
|
253
|
+
*/
|
|
254
|
+
actionOutcome: (objectName: string | undefined, actionName: string, outcome: string, fallback?: string) => string | undefined;
|
|
244
255
|
/**
|
|
245
256
|
* Resolve translated action description (the explanatory line shown in the
|
|
246
257
|
* action's param dialog / sheet / drawer header).
|
|
@@ -360,15 +371,17 @@ export declare function useSafeFieldLabel(): {
|
|
|
360
371
|
* reaches this client — so nav labels arrive already localized. One
|
|
361
372
|
* owner, not two. To translate a sidebar group, translate it there.
|
|
362
373
|
*
|
|
363
|
-
* History (
|
|
374
|
+
* History (`9c60144b5`): until then this docstring promised
|
|
364
375
|
* `"Sales" → "销售"` for sidebar groups, and `NavigationRenderer`
|
|
365
376
|
* accepted `resolveGroupLabel`/`resolveItemLabel` to wire it up. Those
|
|
366
377
|
* props could never fire — the renderer's `isCustomized` guard compared a
|
|
367
378
|
* node's authored label against its own `id` (`Workspace` vs
|
|
368
379
|
* `grp_workspace`), which never match, so the guard was true for every
|
|
369
|
-
* real entry. The props are gone
|
|
370
|
-
*
|
|
371
|
-
*
|
|
380
|
+
* real entry. The props are gone, and so is the guard itself
|
|
381
|
+
* (objectui#11201: the renderer no longer matches a label's text against
|
|
382
|
+
* any name); the promise was false, not merely unused. This helper is kept
|
|
383
|
+
* as a plain key reader for a consumer that renders navigation itself — it
|
|
384
|
+
* has no first-party caller.
|
|
372
385
|
*/
|
|
373
386
|
navGroupLabel: (appName: string, groupId: string, fallback: string) => string;
|
|
374
387
|
/**
|
|
@@ -454,11 +467,6 @@ export declare function useSafeFieldLabel(): {
|
|
|
454
467
|
* that spells the prefix out does not resolve (see `viewSuffixes`).
|
|
455
468
|
*/
|
|
456
469
|
viewLabel: (objectName: string, viewName: string, fallback: string) => string;
|
|
457
|
-
/**
|
|
458
|
-
* Resolve translated list-view description.
|
|
459
|
-
* Convention: `{ns}.objects.{objectName}._views.{viewName}.description`.
|
|
460
|
-
*/
|
|
461
|
-
viewDescription: (objectName: string, viewName: string, fallback?: string) => string | undefined;
|
|
462
470
|
/**
|
|
463
471
|
* Resolve translated list-view emptyState. Returns a {title, message}
|
|
464
472
|
* tuple with each field independently translated. Convention:
|
|
@@ -501,6 +509,20 @@ export declare function useSafeFieldLabel(): {
|
|
|
501
509
|
* falling back to `{ns}.globalActions.{actionName}.successMessage`.
|
|
502
510
|
*/
|
|
503
511
|
actionSuccess: (objectName: string | undefined, actionName: string, fallback?: string) => string | undefined;
|
|
512
|
+
/**
|
|
513
|
+
* Resolve the translated success copy for ONE handler outcome — an entry of
|
|
514
|
+
* the action's `outcomeMessages` (`@objectstack/spec` 17.6.0, objectui#11344).
|
|
515
|
+
* Convention: `{ns}.objects.{objectName}._actions.{actionName}.outcomeMessages.{outcome}`,
|
|
516
|
+
* falling back to `{ns}.globalActions.{actionName}.outcomeMessages.{outcome}`
|
|
517
|
+
* — the address the spec's translation bundle declares for the key.
|
|
518
|
+
* Returns undefined when no translation and no fallback exist.
|
|
519
|
+
*
|
|
520
|
+
* Like `actionSuccess`, the answer is a TEMPLATE: a `${result.*}` token in a
|
|
521
|
+
* bundle entry passes through untouched (i18next interpolates its own
|
|
522
|
+
* `{{…}}` form, never `${…}`), and the action runner fills it in from the
|
|
523
|
+
* handler's answer once the action has run.
|
|
524
|
+
*/
|
|
525
|
+
actionOutcome: (objectName: string | undefined, actionName: string, outcome: string, fallback?: string) => string | undefined;
|
|
504
526
|
/**
|
|
505
527
|
* Resolve translated action description (the explanatory line shown in the
|
|
506
528
|
* action's param dialog / sheet / drawer header).
|
package/dist/useObjectLabel.js
CHANGED
|
@@ -242,8 +242,8 @@ export function useObjectLabel() {
|
|
|
242
242
|
* name under `_views` — the runtime view identity's own name, stripped of the
|
|
243
243
|
* object prefix. That single spelling is canonical per the objectstack#5164
|
|
244
244
|
* ruling A (2026-08-06): the i18n extractor asks the view composer for the key
|
|
245
|
-
* (objectstack
|
|
246
|
-
* (objectstack
|
|
245
|
+
* (objectstack `b3c1f3cd5`) and `packages/lint` enforces exactly that spelling
|
|
246
|
+
* (objectstack `7618ee814`), so this resolver accepts exactly what those produce.
|
|
247
247
|
*
|
|
248
248
|
* Deliberately NOT a second candidate: the prefixed full name
|
|
249
249
|
* (`_views.<objectName>.<viewName>`). Accepting it made this client more
|
|
@@ -328,15 +328,17 @@ export function useObjectLabel() {
|
|
|
328
328
|
* reaches this client — so nav labels arrive already localized. One
|
|
329
329
|
* owner, not two. To translate a sidebar group, translate it there.
|
|
330
330
|
*
|
|
331
|
-
* History (
|
|
331
|
+
* History (`9c60144b5`): until then this docstring promised
|
|
332
332
|
* `"Sales" → "销售"` for sidebar groups, and `NavigationRenderer`
|
|
333
333
|
* accepted `resolveGroupLabel`/`resolveItemLabel` to wire it up. Those
|
|
334
334
|
* props could never fire — the renderer's `isCustomized` guard compared a
|
|
335
335
|
* node's authored label against its own `id` (`Workspace` vs
|
|
336
336
|
* `grp_workspace`), which never match, so the guard was true for every
|
|
337
|
-
* real entry. The props are gone
|
|
338
|
-
*
|
|
339
|
-
*
|
|
337
|
+
* real entry. The props are gone, and so is the guard itself
|
|
338
|
+
* (objectui#11201: the renderer no longer matches a label's text against
|
|
339
|
+
* any name); the promise was false, not merely unused. This helper is kept
|
|
340
|
+
* as a plain key reader for a consumer that renders navigation itself — it
|
|
341
|
+
* has no first-party caller.
|
|
340
342
|
*/
|
|
341
343
|
navGroupLabel: (appName, groupId, fallback) => resolve(`apps.${appName}.navigation.${groupId}.label`, fallback),
|
|
342
344
|
/**
|
|
@@ -422,15 +424,27 @@ export function useObjectLabel() {
|
|
|
422
424
|
* that spells the prefix out does not resolve (see `viewSuffixes`).
|
|
423
425
|
*/
|
|
424
426
|
viewLabel: (objectName, viewName, fallback) => resolve(viewSuffixes(objectName, viewName, 'label'), fallback),
|
|
425
|
-
|
|
426
|
-
*
|
|
427
|
-
*
|
|
427
|
+
/*
|
|
428
|
+
* There is deliberately NO `viewDescription` member here, and no
|
|
429
|
+
* `{ns}.objects.{objectName}._views.{viewName}.description` convention for
|
|
430
|
+
* it to resolve (objectui#7219, maintainer ruling 2026-09-02, option B).
|
|
431
|
+
*
|
|
432
|
+
* A list view's description has exactly ONE channel: the `I18nLabel` value
|
|
433
|
+
* authored on the view entry -- a string or an inline locale map -- which
|
|
434
|
+
* `ObjectView` relays and the render site resolves with `pickLocalized`
|
|
435
|
+
* (objectui#7199). The catalog key used to be declared and resolved right
|
|
436
|
+
* here, between its two wired-up siblings, but had zero callers and zero
|
|
437
|
+
* in-repo bundle usage: a bundle entry written under it reached no screen.
|
|
438
|
+
*
|
|
439
|
+
* Wiring it in instead was weighed and NOT taken -- two vocabularies for
|
|
440
|
+
* one concept plus a precedence rule is the ambiguity, not the fix. Leaving
|
|
441
|
+
* it declared and unfulfilled was not taken either; the standing rule is
|
|
442
|
+
* remove, not phase out, when a surface has measured zero use.
|
|
443
|
+
*
|
|
444
|
+
* `viewSuffixes` is NOT retired with it: `viewLabel` above and
|
|
445
|
+
* `viewEmptyState` below share that helper and keep resolving. Only the
|
|
446
|
+
* `'description'` tail passed to it is gone.
|
|
428
447
|
*/
|
|
429
|
-
viewDescription: (objectName, viewName, fallback) => {
|
|
430
|
-
const fb = fallback ?? '';
|
|
431
|
-
const resolved = resolve(viewSuffixes(objectName, viewName, 'description'), fb);
|
|
432
|
-
return resolved || undefined;
|
|
433
|
-
},
|
|
434
448
|
/**
|
|
435
449
|
* Resolve translated list-view emptyState. Returns a {title, message}
|
|
436
450
|
* tuple with each field independently translated. Convention:
|
|
@@ -483,6 +497,24 @@ export function useObjectLabel() {
|
|
|
483
497
|
const resolved = resolve(actionSuffixes(objectName, actionName, 'successMessage'), fb);
|
|
484
498
|
return resolved || undefined;
|
|
485
499
|
},
|
|
500
|
+
/**
|
|
501
|
+
* Resolve the translated success copy for ONE handler outcome — an entry of
|
|
502
|
+
* the action's `outcomeMessages` (`@objectstack/spec` 17.6.0, objectui#11344).
|
|
503
|
+
* Convention: `{ns}.objects.{objectName}._actions.{actionName}.outcomeMessages.{outcome}`,
|
|
504
|
+
* falling back to `{ns}.globalActions.{actionName}.outcomeMessages.{outcome}`
|
|
505
|
+
* — the address the spec's translation bundle declares for the key.
|
|
506
|
+
* Returns undefined when no translation and no fallback exist.
|
|
507
|
+
*
|
|
508
|
+
* Like `actionSuccess`, the answer is a TEMPLATE: a `${result.*}` token in a
|
|
509
|
+
* bundle entry passes through untouched (i18next interpolates its own
|
|
510
|
+
* `{{…}}` form, never `${…}`), and the action runner fills it in from the
|
|
511
|
+
* handler's answer once the action has run.
|
|
512
|
+
*/
|
|
513
|
+
actionOutcome: (objectName, actionName, outcome, fallback) => {
|
|
514
|
+
const fb = fallback ?? '';
|
|
515
|
+
const resolved = resolve(actionSuffixes(objectName, actionName, `outcomeMessages.${outcome}`), fb);
|
|
516
|
+
return resolved || undefined;
|
|
517
|
+
},
|
|
486
518
|
/**
|
|
487
519
|
* Resolve translated action description (the explanatory line shown in the
|
|
488
520
|
* action's param dialog / sheet / drawer header).
|
|
@@ -6,21 +6,38 @@
|
|
|
6
6
|
* returning raw i18n keys — and, for a key the `defaults` map does not carry,
|
|
7
7
|
* to the call site's own inline `t(key, { defaultValue })` (objectui#3865).
|
|
8
8
|
* The lookup order is i18next's, so a provider-less host and a provider-mounted
|
|
9
|
-
* one render the same string: `defaults[
|
|
9
|
+
* one render the same string: `defaults[key_<category>]` for a numeric `count`
|
|
10
|
+
* (objectui#11445), then `defaults[key]` (the pack value's stand-in here)
|
|
10
11
|
* -> `defaultValue` -> the key.
|
|
11
12
|
*
|
|
12
13
|
* @param defaults - Fallback English translations keyed by i18n key
|
|
13
14
|
* @param testKey - A key to test if i18n is properly configured (must be in defaults)
|
|
14
15
|
*/
|
|
15
16
|
import { useObjectTranslation } from './provider.js';
|
|
17
|
+
import { DEFAULT_VALUE_OPTION, interpolateFallback } from './fallbackInterpolation.js';
|
|
16
18
|
/**
|
|
17
|
-
* The
|
|
19
|
+
* The plural rules the defaults tables are written in. Every table is English
|
|
20
|
+
* (the `en` pack's stand-in), so English selects the slot.
|
|
21
|
+
*/
|
|
22
|
+
const DEFAULTS_PLURAL_RULES = new Intl.PluralRules('en');
|
|
23
|
+
/**
|
|
24
|
+
* The `defaults` row a count family answers from, the way i18next walks a pack
|
|
25
|
+
* (objectui#11445): `key_<category>` first, then the base `key`.
|
|
18
26
|
*
|
|
19
|
-
*
|
|
20
|
-
*
|
|
21
|
-
*
|
|
27
|
+
* i18next plural-selects only for a NUMERIC `count` (`needsPluralHandling` in
|
|
28
|
+
* i18next 26 is `count !== undefined && !isString(count)`), so a string count
|
|
29
|
+
* reads the base row on both paths. `_zero` is not looked up: no English row
|
|
30
|
+
* spells one, because `en` selects no `zero` category.
|
|
22
31
|
*/
|
|
23
|
-
|
|
32
|
+
function defaultsRowFor(defaults, key, options) {
|
|
33
|
+
const count = options?.count;
|
|
34
|
+
if (typeof count === 'number' && Number.isFinite(count)) {
|
|
35
|
+
const slot = defaults[`${key}_${DEFAULTS_PLURAL_RULES.select(count)}`];
|
|
36
|
+
if (slot)
|
|
37
|
+
return slot;
|
|
38
|
+
}
|
|
39
|
+
return defaults[key];
|
|
40
|
+
}
|
|
24
41
|
export function createSafeTranslation(defaults, testKey) {
|
|
25
42
|
// Factory-level fallback: one stable reference per defaults map, so
|
|
26
43
|
// downstream useMemo/useCallback deps don't invalidate every render in
|
|
@@ -47,43 +64,24 @@ export function createSafeTranslation(defaults, testKey) {
|
|
|
47
64
|
// `string`, and i18next itself declines a `null` default. `||` (not `??`)
|
|
48
65
|
// keeps the whole chain consistent with the pre-existing `defaults[key] ||`
|
|
49
66
|
// step: an empty string at any position falls through to the next.
|
|
67
|
+
//
|
|
68
|
+
// A count family's suffixed rows are reached here too (objectui#11445):
|
|
69
|
+
// `t('detail.replyCount', { count: 3 })` reads `detail.replyCount_other`
|
|
70
|
+
// before the base row, exactly as i18next reads the `en` pack. Before that
|
|
71
|
+
// card a family's `_one` / `_other` rows were unreachable on this path, so
|
|
72
|
+
// the code-selected `xxxCountOne` pairs were the only way a provider-less
|
|
73
|
+
// host rendered "1 reply" — the card retired those pairs.
|
|
50
74
|
const inlineDefault = options?.[DEFAULT_VALUE_OPTION];
|
|
51
|
-
let value = defaults
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
// 26.3.6) — but no value in this repo spells that hole (zero hits for
|
|
62
|
-
// `{{defaultValue` across packages/apps/examples), and splicing a
|
|
63
|
-
// fallback string into a hole named after itself has no sensible
|
|
64
|
-
// reading. The alternative — letting a call site's fallback text leak
|
|
65
|
-
// into an unrelated table value — is the worse of the two.
|
|
66
|
-
if (k === DEFAULT_VALUE_OPTION)
|
|
67
|
-
continue;
|
|
68
|
-
// `split(needle).join(value)` — deliberately not `replace`, and not
|
|
69
|
-
// `replaceAll` either (objectui#3418). This path must agree with
|
|
70
|
-
// i18next, which serves the *provider* path; any divergence is a
|
|
71
|
-
// silent fork that only shows up on provider-less hosts, where we are
|
|
72
|
-
// least likely to see it:
|
|
73
|
-
// 1. `replace` with a string needle substitutes only the FIRST
|
|
74
|
-
// occurrence. i18next substitutes every one, so a sentence that
|
|
75
|
-
// repeats a placeholder ("Selected {{count}} of {{count}} items"
|
|
76
|
-
// — natural in many locales, and often required by RTL /
|
|
77
|
-
// agglutinative word order) leaked literal braces to users.
|
|
78
|
-
// 2. `replace` AND `replaceAll` both interpret `$&`, `` $` ``, `$'`
|
|
79
|
-
// and `$$` in the *replacement* string. i18next does not. Values
|
|
80
|
-
// here are runtime data — record labels, search terms — so this
|
|
81
|
-
// one is reachable today, unlike (1).
|
|
82
|
-
// split/join is literal on both sides, which is exactly i18next's
|
|
83
|
-
// behaviour, and needs no regex escaping of the placeholder name.
|
|
84
|
-
value = value.split(`{{${k}}}`).join(String(v));
|
|
85
|
-
}
|
|
86
|
-
}
|
|
75
|
+
let value = defaultsRowFor(defaults, key, options) ||
|
|
76
|
+
(typeof inlineDefault === 'string' ? inlineDefault : '') ||
|
|
77
|
+
key;
|
|
78
|
+
// The interpolation itself lives in `fallbackInterpolation.ts` — ONE
|
|
79
|
+
// function, shared with `useObjectTranslation`'s not-ready path
|
|
80
|
+
// (objectui#6219). Both provider-less renderers therefore fill exactly
|
|
81
|
+
// the `{{name}}` spelling objectui#3512 holds the copy to, and the
|
|
82
|
+
// reserved-name rule for `defaultValue` (objectui#3865) is stated once.
|
|
83
|
+
// The behaviour pinned by `useSafeTranslation.test.tsx` is unchanged.
|
|
84
|
+
value = interpolateFallback(value, options);
|
|
87
85
|
return value;
|
|
88
86
|
};
|
|
89
87
|
return function useSafeTranslation() {
|
|
@@ -2,6 +2,17 @@
|
|
|
2
2
|
* @object-ui/i18n - Date and currency formatting utilities
|
|
3
3
|
*
|
|
4
4
|
* Uses the native Intl API for locale-aware formatting.
|
|
5
|
+
*
|
|
6
|
+
* Every date helper below reads a string through `toDisplayDate`
|
|
7
|
+
* (`@object-ui/core`), the one parse step behind the date faces
|
|
8
|
+
* (objectui#10110, objectui#10183). The engine's own parse reads a date-only
|
|
9
|
+
* `2026-09-01` as UTC midnight, so each helper named August 31st for every
|
|
10
|
+
* viewer west of UTC (objectui#10866). The shared step tells the two shapes
|
|
11
|
+
* apart by the value: a date-only string is rebuilt at local midnight of the
|
|
12
|
+
* day it names, and a value with a time part keeps its instant. It also
|
|
13
|
+
* refuses a day its month does not have (`2026-02-30`, objectui#10026), which
|
|
14
|
+
* each helper then renders as the raw string, its face for any unparsable
|
|
15
|
+
* value.
|
|
5
16
|
*/
|
|
6
17
|
export interface DateFormatOptions {
|
|
7
18
|
locale?: string;
|
|
@@ -33,6 +44,11 @@ export declare function formatDate(date: Date | string | number, options?: DateF
|
|
|
33
44
|
export declare function formatDateTime(date: Date | string | number, options?: DateFormatOptions): string;
|
|
34
45
|
/**
|
|
35
46
|
* Format a relative time (e.g., "2 days ago", "in 3 hours")
|
|
47
|
+
*
|
|
48
|
+
* A date-only value counts from now to the START of the day it names (local
|
|
49
|
+
* midnight), the same distance in every zone at the same wall-clock time.
|
|
50
|
+
* An unparsable value comes back as its string, as it does from
|
|
51
|
+
* {@link formatDate}; `Intl.RelativeTimeFormat` would throw on it.
|
|
36
52
|
*/
|
|
37
53
|
export declare function formatRelativeTime(date: Date | string | number, locale?: string): string;
|
|
38
54
|
/**
|
package/dist/utils/formatting.js
CHANGED
|
@@ -2,13 +2,25 @@
|
|
|
2
2
|
* @object-ui/i18n - Date and currency formatting utilities
|
|
3
3
|
*
|
|
4
4
|
* Uses the native Intl API for locale-aware formatting.
|
|
5
|
+
*
|
|
6
|
+
* Every date helper below reads a string through `toDisplayDate`
|
|
7
|
+
* (`@object-ui/core`), the one parse step behind the date faces
|
|
8
|
+
* (objectui#10110, objectui#10183). The engine's own parse reads a date-only
|
|
9
|
+
* `2026-09-01` as UTC midnight, so each helper named August 31st for every
|
|
10
|
+
* viewer west of UTC (objectui#10866). The shared step tells the two shapes
|
|
11
|
+
* apart by the value: a date-only string is rebuilt at local midnight of the
|
|
12
|
+
* day it names, and a value with a time part keeps its instant. It also
|
|
13
|
+
* refuses a day its month does not have (`2026-02-30`, objectui#10026), which
|
|
14
|
+
* each helper then renders as the raw string, its face for any unparsable
|
|
15
|
+
* value.
|
|
5
16
|
*/
|
|
17
|
+
import { toDisplayDate } from '@object-ui/core';
|
|
6
18
|
/**
|
|
7
19
|
* Format a date according to locale conventions
|
|
8
20
|
*/
|
|
9
21
|
export function formatDate(date, options = {}) {
|
|
10
22
|
const { locale = 'en', style = 'medium' } = options;
|
|
11
|
-
const d =
|
|
23
|
+
const d = toDisplayDate(date);
|
|
12
24
|
if (isNaN(d.getTime())) {
|
|
13
25
|
return String(date);
|
|
14
26
|
}
|
|
@@ -28,7 +40,7 @@ export function formatDate(date, options = {}) {
|
|
|
28
40
|
*/
|
|
29
41
|
export function formatDateTime(date, options = {}) {
|
|
30
42
|
const { locale = 'en', style = 'medium' } = options;
|
|
31
|
-
const d =
|
|
43
|
+
const d = toDisplayDate(date);
|
|
32
44
|
if (isNaN(d.getTime())) {
|
|
33
45
|
return String(date);
|
|
34
46
|
}
|
|
@@ -42,9 +54,17 @@ export function formatDateTime(date, options = {}) {
|
|
|
42
54
|
}
|
|
43
55
|
/**
|
|
44
56
|
* Format a relative time (e.g., "2 days ago", "in 3 hours")
|
|
57
|
+
*
|
|
58
|
+
* A date-only value counts from now to the START of the day it names (local
|
|
59
|
+
* midnight), the same distance in every zone at the same wall-clock time.
|
|
60
|
+
* An unparsable value comes back as its string, as it does from
|
|
61
|
+
* {@link formatDate}; `Intl.RelativeTimeFormat` would throw on it.
|
|
45
62
|
*/
|
|
46
63
|
export function formatRelativeTime(date, locale = 'en') {
|
|
47
|
-
const d =
|
|
64
|
+
const d = toDisplayDate(date);
|
|
65
|
+
if (isNaN(d.getTime())) {
|
|
66
|
+
return String(date);
|
|
67
|
+
}
|
|
48
68
|
const now = new Date();
|
|
49
69
|
const diffMs = d.getTime() - now.getTime();
|
|
50
70
|
const diffSec = Math.round(diffMs / 1000);
|
|
@@ -5,29 +5,6 @@
|
|
|
5
5
|
* This source code is licensed under the MIT license found in the
|
|
6
6
|
* LICENSE file in the root directory of this source tree.
|
|
7
7
|
*/
|
|
8
|
-
/**
|
|
9
|
-
* @object-ui/i18n - locale formatting helpers
|
|
10
|
-
*
|
|
11
|
-
* ## Provenance of the four shapes below (objectui#4597)
|
|
12
|
-
*
|
|
13
|
-
* They were authored against `@objectstack/spec/ui`, which exported
|
|
14
|
-
* `PluralRuleSchema`, `DateFormatSchema`, `NumberFormatSchema` and
|
|
15
|
-
* `LocaleConfigSchema` up to and including 17.0.0-rc.5. The pinned
|
|
16
|
-
* 17.0.0-rc.6 removed all four under ADR-0049 enforce-or-remove
|
|
17
|
-
* (objectstack#5055, maintainer ruling 2026-08-06): they carried no key on any
|
|
18
|
-
* authorable shape and nothing ever parsed them, so the vocabulary was retired
|
|
19
|
-
* rather than given a door. The key sets here were faithful to the retired
|
|
20
|
-
* schemas — this is a retirement, not a drift.
|
|
21
|
-
*
|
|
22
|
-
* Nothing in the pinned protocol models locale formatting today, so the
|
|
23
|
-
* interfaces below are declarations this package owns outright, not a view onto
|
|
24
|
-
* a protocol type. The retirement note records that localisation returns as
|
|
25
|
-
* authorable metadata through a new ADR — "the formatter that reads a
|
|
26
|
-
* LocaleConfig first, the vocabulary second" — so re-derive these at that point
|
|
27
|
-
* instead of assuming they still track anything upstream.
|
|
28
|
-
*
|
|
29
|
-
* @module spec-formatters
|
|
30
|
-
*/
|
|
31
8
|
/**
|
|
32
9
|
* Plural forms for a single translation key, in CLDR categories.
|
|
33
10
|
*
|
|
@@ -83,6 +60,19 @@ export interface SpecDateFormat {
|
|
|
83
60
|
/**
|
|
84
61
|
* Format a date using a {@link SpecDateFormat} configuration.
|
|
85
62
|
*
|
|
63
|
+
* A string is read through `toDisplayDate` (`@object-ui/core`), the one parse
|
|
64
|
+
* step behind the date faces (objectui#10866): a date-only `2026-09-01` names
|
|
65
|
+
* a calendar day and is rebuilt at local midnight of that day, while a value
|
|
66
|
+
* with a time part keeps its instant. The engine's own parse read the
|
|
67
|
+
* date-only form as UTC midnight, so it named August 31st west of UTC, and
|
|
68
|
+
* for every viewer once `timeZone` was west of UTC.
|
|
69
|
+
*
|
|
70
|
+
* `timeZone` therefore applies to an instant only. A date-only value carries
|
|
71
|
+
* no instant for a zone to place, and the local midnight the shared step
|
|
72
|
+
* built reads back as its day only in the local zone, so it is formatted
|
|
73
|
+
* there. A value the shared step refuses, such as a day its month does not
|
|
74
|
+
* have (objectui#10026), comes back as its string.
|
|
75
|
+
*
|
|
86
76
|
* @example
|
|
87
77
|
* ```ts
|
|
88
78
|
* formatDateSpec(new Date(), {
|
|
@@ -5,6 +5,30 @@
|
|
|
5
5
|
* This source code is licensed under the MIT license found in the
|
|
6
6
|
* LICENSE file in the root directory of this source tree.
|
|
7
7
|
*/
|
|
8
|
+
/**
|
|
9
|
+
* @object-ui/i18n - locale formatting helpers
|
|
10
|
+
*
|
|
11
|
+
* ## Provenance of the four shapes below (objectui#4597)
|
|
12
|
+
*
|
|
13
|
+
* They were authored against `@objectstack/spec/ui`, which exported
|
|
14
|
+
* `PluralRuleSchema`, `DateFormatSchema`, `NumberFormatSchema` and
|
|
15
|
+
* `LocaleConfigSchema` up to and including 17.0.0-rc.5. The pinned
|
|
16
|
+
* 17.0.0-rc.6 removed all four under ADR-0049 enforce-or-remove
|
|
17
|
+
* (objectstack#5055, maintainer ruling 2026-08-06): they carried no key on any
|
|
18
|
+
* authorable shape and nothing ever parsed them, so the vocabulary was retired
|
|
19
|
+
* rather than given a door. The key sets here were faithful to the retired
|
|
20
|
+
* schemas — this is a retirement, not a drift.
|
|
21
|
+
*
|
|
22
|
+
* Nothing in the pinned protocol models locale formatting today, so the
|
|
23
|
+
* interfaces below are declarations this package owns outright, not a view onto
|
|
24
|
+
* a protocol type. The retirement note records that localisation returns as
|
|
25
|
+
* authorable metadata through a new ADR — "the formatter that reads a
|
|
26
|
+
* LocaleConfig first, the vocabulary second" — so re-derive these at that point
|
|
27
|
+
* instead of assuming they still track anything upstream.
|
|
28
|
+
*
|
|
29
|
+
* @module spec-formatters
|
|
30
|
+
*/
|
|
31
|
+
import { isRealCalendarDate, toDisplayDate } from '@object-ui/core';
|
|
8
32
|
/**
|
|
9
33
|
* Resolve a plural form based on count, following CLDR plural rules.
|
|
10
34
|
* Uses the Intl.PluralRules API for correct locale-aware pluralization.
|
|
@@ -38,6 +62,19 @@ export function resolvePlural(rule, count, locale = 'en') {
|
|
|
38
62
|
/**
|
|
39
63
|
* Format a date using a {@link SpecDateFormat} configuration.
|
|
40
64
|
*
|
|
65
|
+
* A string is read through `toDisplayDate` (`@object-ui/core`), the one parse
|
|
66
|
+
* step behind the date faces (objectui#10866): a date-only `2026-09-01` names
|
|
67
|
+
* a calendar day and is rebuilt at local midnight of that day, while a value
|
|
68
|
+
* with a time part keeps its instant. The engine's own parse read the
|
|
69
|
+
* date-only form as UTC midnight, so it named August 31st west of UTC, and
|
|
70
|
+
* for every viewer once `timeZone` was west of UTC.
|
|
71
|
+
*
|
|
72
|
+
* `timeZone` therefore applies to an instant only. A date-only value carries
|
|
73
|
+
* no instant for a zone to place, and the local midnight the shared step
|
|
74
|
+
* built reads back as its day only in the local zone, so it is formatted
|
|
75
|
+
* there. A value the shared step refuses, such as a day its month does not
|
|
76
|
+
* have (objectui#10026), comes back as its string.
|
|
77
|
+
*
|
|
41
78
|
* @example
|
|
42
79
|
* ```ts
|
|
43
80
|
* formatDateSpec(new Date(), {
|
|
@@ -48,15 +85,17 @@ export function resolvePlural(rule, count, locale = 'en') {
|
|
|
48
85
|
* ```
|
|
49
86
|
*/
|
|
50
87
|
export function formatDateSpec(date, format, locale = 'en') {
|
|
51
|
-
const d =
|
|
88
|
+
const d = toDisplayDate(date);
|
|
52
89
|
if (isNaN(d.getTime()))
|
|
53
90
|
return String(date);
|
|
91
|
+
// The value's shape tells the two apart, the same test the shared step makes.
|
|
92
|
+
const dateOnly = typeof date === 'string' && isRealCalendarDate(date);
|
|
54
93
|
const options = {};
|
|
55
94
|
if (format.dateStyle)
|
|
56
95
|
options.dateStyle = format.dateStyle;
|
|
57
96
|
if (format.timeStyle)
|
|
58
97
|
options.timeStyle = format.timeStyle;
|
|
59
|
-
if (format.timeZone)
|
|
98
|
+
if (format.timeZone && !dateOnly)
|
|
60
99
|
options.timeZone = format.timeZone;
|
|
61
100
|
if (format.hour12 !== undefined)
|
|
62
101
|
options.hour12 = format.hour12;
|
|
@@ -50,9 +50,22 @@ export interface SpecTranslationData {
|
|
|
50
50
|
/**
|
|
51
51
|
* Detect whether the given record uses the spec `TranslationData` format.
|
|
52
52
|
*
|
|
53
|
-
* Returns `true` when
|
|
54
|
-
*
|
|
55
|
-
*
|
|
53
|
+
* Returns `true` when the payload carries at least one spec translation group
|
|
54
|
+
* (see {@link SPEC_TRANSLATION_GROUPS}) whose value is an object — every spec
|
|
55
|
+
* group is a record or an object. A bundle is recognised by ANY group it
|
|
56
|
+
* carries: one that translates only object labels, apps, pages, dashboards or
|
|
57
|
+
* flows, with no field label anywhere, is as much a spec payload as one that
|
|
58
|
+
* translates fields (objectui#10235 — this used to require an
|
|
59
|
+
* `objects.<n>.fields` entry, and such a bundle was returned untransformed and
|
|
60
|
+
* read by nothing).
|
|
61
|
+
*
|
|
62
|
+
* What stays `false` is the other shape the console's loader accepts: an
|
|
63
|
+
* i18next resource tree that is already namespaced, as a mock or local-dev
|
|
64
|
+
* server may return it. Its top-level keys are namespace names — a built-in
|
|
65
|
+
* pack namespace such as `common`, an app namespace such as `crm`, or `app`,
|
|
66
|
+
* the one {@link transformSpecTranslations} itself emits — and none of them
|
|
67
|
+
* is a spec group, so feeding the transform's output back in never wraps it
|
|
68
|
+
* twice. A group-named key holding a string or an array is not a group either.
|
|
56
69
|
*/
|
|
57
70
|
export declare function isSpecTranslationData(data: Record<string, unknown> | null | undefined): data is SpecTranslationData;
|
|
58
71
|
/**
|
|
@@ -23,25 +23,64 @@
|
|
|
23
23
|
* console transform forgets to copy it, leaving lookups to fall back to the
|
|
24
24
|
* untranslated source label.
|
|
25
25
|
*/
|
|
26
|
+
/**
|
|
27
|
+
* The top-level groups of the translation document the server serves at
|
|
28
|
+
* `/api/v1/i18n/translations/:locale` — the keys of the `translations` shape
|
|
29
|
+
* of `@objectstack/spec`'s `GetTranslationsResponseSchema`: every per-app
|
|
30
|
+
* `TranslationData` group plus the platform-only `settings`, because the
|
|
31
|
+
* served document is the merge of the app's bundle and the platform's.
|
|
32
|
+
*
|
|
33
|
+
* Written out rather than imported: `@object-ui/i18n` deliberately does not
|
|
34
|
+
* depend on `@objectstack/spec`. The console does, so the pin lives beside
|
|
35
|
+
* its loader — `apps/console/src/loadLanguage.test.tsx` walks the spec's own
|
|
36
|
+
* key list and requires a payload carrying any one group, alone, to take the
|
|
37
|
+
* transform branch. A group the spec adds and this list lacks turns that test
|
|
38
|
+
* red instead of dropping the group's bundles silently (objectui#10235).
|
|
39
|
+
*/
|
|
40
|
+
const SPEC_TRANSLATION_GROUPS = [
|
|
41
|
+
'objects',
|
|
42
|
+
'apps',
|
|
43
|
+
'messages',
|
|
44
|
+
'globalActions',
|
|
45
|
+
'dashboards',
|
|
46
|
+
'datasets',
|
|
47
|
+
'pages',
|
|
48
|
+
'flows',
|
|
49
|
+
'settings',
|
|
50
|
+
'metadataForms',
|
|
51
|
+
'settingsCommon',
|
|
52
|
+
// `@objectstack/spec` 17.6.0 serves `picklists` (objectstack `addbbf02`, the
|
|
53
|
+
// `picklist` metadata kind); added at that bump (objectui#11438). The
|
|
54
|
+
// transform below copies it through onto `app` like any other group.
|
|
55
|
+
'picklists',
|
|
56
|
+
];
|
|
57
|
+
function isPlainObject(value) {
|
|
58
|
+
return typeof value === 'object' && value !== null && !Array.isArray(value);
|
|
59
|
+
}
|
|
26
60
|
/**
|
|
27
61
|
* Detect whether the given record uses the spec `TranslationData` format.
|
|
28
62
|
*
|
|
29
|
-
* Returns `true` when
|
|
30
|
-
*
|
|
31
|
-
*
|
|
63
|
+
* Returns `true` when the payload carries at least one spec translation group
|
|
64
|
+
* (see {@link SPEC_TRANSLATION_GROUPS}) whose value is an object — every spec
|
|
65
|
+
* group is a record or an object. A bundle is recognised by ANY group it
|
|
66
|
+
* carries: one that translates only object labels, apps, pages, dashboards or
|
|
67
|
+
* flows, with no field label anywhere, is as much a spec payload as one that
|
|
68
|
+
* translates fields (objectui#10235 — this used to require an
|
|
69
|
+
* `objects.<n>.fields` entry, and such a bundle was returned untransformed and
|
|
70
|
+
* read by nothing).
|
|
71
|
+
*
|
|
72
|
+
* What stays `false` is the other shape the console's loader accepts: an
|
|
73
|
+
* i18next resource tree that is already namespaced, as a mock or local-dev
|
|
74
|
+
* server may return it. Its top-level keys are namespace names — a built-in
|
|
75
|
+
* pack namespace such as `common`, an app namespace such as `crm`, or `app`,
|
|
76
|
+
* the one {@link transformSpecTranslations} itself emits — and none of them
|
|
77
|
+
* is a spec group, so feeding the transform's output back in never wraps it
|
|
78
|
+
* twice. A group-named key holding a string or an array is not a group either.
|
|
32
79
|
*/
|
|
33
80
|
export function isSpecTranslationData(data) {
|
|
34
|
-
if (!data)
|
|
81
|
+
if (!isPlainObject(data))
|
|
35
82
|
return false;
|
|
36
|
-
|
|
37
|
-
if (!objects || typeof objects !== 'object' || Array.isArray(objects))
|
|
38
|
-
return false;
|
|
39
|
-
for (const obj of Object.values(objects)) {
|
|
40
|
-
if (obj && typeof obj === 'object' && !Array.isArray(obj) && 'fields' in obj) {
|
|
41
|
-
return true;
|
|
42
|
-
}
|
|
43
|
-
}
|
|
44
|
-
return false;
|
|
83
|
+
return SPEC_TRANSLATION_GROUPS.some((group) => isPlainObject(data[group]));
|
|
45
84
|
}
|
|
46
85
|
/** Top-level keys that are copied through unchanged onto the `app` namespace. */
|
|
47
86
|
const PASSTHROUGH_TOP_LEVEL_KEYS = [
|