@object-ui/i18n 17.6.0 → 18.0.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 (62) hide show
  1. package/CHANGELOG.md +5408 -0
  2. package/README.md +255 -13
  3. package/dist/LocalizationContext.d.ts +32 -9
  4. package/dist/LocalizationContext.js +32 -10
  5. package/dist/builtinAggregateLabels.d.ts +40 -0
  6. package/dist/builtinAggregateLabels.js +23 -0
  7. package/dist/currency.d.ts +27 -3
  8. package/dist/currency.js +28 -4
  9. package/dist/fallbackInterpolation.d.ts +79 -0
  10. package/dist/fallbackInterpolation.js +124 -0
  11. package/dist/i18n.d.ts +27 -1
  12. package/dist/i18n.js +99 -16
  13. package/dist/index.d.ts +8 -13
  14. package/dist/index.js +45 -17
  15. package/dist/locales/ar.d.ts +1504 -188
  16. package/dist/locales/ar.js +1711 -281
  17. package/dist/locales/de.d.ts +1080 -188
  18. package/dist/locales/de.js +1248 -261
  19. package/dist/locales/en.d.ts +1141 -249
  20. package/dist/locales/en.js +1714 -295
  21. package/dist/locales/es.d.ts +1186 -188
  22. package/dist/locales/es.js +1350 -255
  23. package/dist/locales/fr.d.ts +1186 -188
  24. package/dist/locales/fr.js +1351 -258
  25. package/dist/locales/index.d.ts +11940 -2033
  26. package/dist/locales/index.js +48 -9
  27. package/dist/locales/ja.d.ts +1080 -188
  28. package/dist/locales/ja.js +1240 -254
  29. package/dist/locales/ko.d.ts +1080 -188
  30. package/dist/locales/ko.js +1244 -258
  31. package/dist/locales/pt.d.ts +1186 -188
  32. package/dist/locales/pt.js +1350 -255
  33. package/dist/locales/registry.d.ts +102 -0
  34. package/dist/locales/registry.js +220 -0
  35. package/dist/locales/rtl.d.ts +24 -0
  36. package/dist/locales/rtl.js +26 -0
  37. package/dist/locales/ru.d.ts +1292 -188
  38. package/dist/locales/ru.js +1479 -272
  39. package/dist/locales/zh.d.ts +1144 -252
  40. package/dist/locales/zh.js +1245 -259
  41. package/dist/pickLocalized.d.ts +30 -0
  42. package/dist/pickLocalized.js +39 -0
  43. package/dist/provider.d.ts +101 -12
  44. package/dist/provider.js +335 -50
  45. package/dist/translateFn.d.ts +29 -0
  46. package/dist/translateFn.js +8 -0
  47. package/dist/useDisplayLocale.d.ts +11 -0
  48. package/dist/useDisplayLocale.js +11 -0
  49. package/dist/useObjectLabel.d.ts +143 -100
  50. package/dist/useObjectLabel.js +129 -82
  51. package/dist/useSafeTranslation.js +40 -42
  52. package/dist/utils/first-day-of-week.d.ts +71 -0
  53. package/dist/utils/first-day-of-week.js +61 -0
  54. package/dist/utils/formatting.d.ts +23 -1
  55. package/dist/utils/formatting.js +34 -5
  56. package/dist/utils/index.d.ts +1 -0
  57. package/dist/utils/index.js +1 -0
  58. package/dist/utils/spec-formatters.d.ts +13 -23
  59. package/dist/utils/spec-formatters.js +41 -2
  60. package/dist/utils/spec-translations.d.ts +16 -3
  61. package/dist/utils/spec-translations.js +52 -13
  62. package/package.json +12 -6
@@ -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 = date instanceof Date ? date : new Date(date);
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 = date instanceof Date ? date : new Date(date);
43
+ const d = toDisplayDate(date);
32
44
  if (isNaN(d.getTime())) {
33
45
  return String(date);
34
46
  }
@@ -42,16 +54,33 @@ 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.
62
+ *
63
+ * `options` are `Intl.RelativeTimeFormat`'s own `style` and `numeric`, passed
64
+ * through unchanged; omitted, the result is what it always was (`long`,
65
+ * `auto`: "yesterday", "2 days ago"). A surface with little room asks for a
66
+ * compact form, e.g. `{ style: 'short', numeric: 'always' }` ("1 day ago",
67
+ * "3 hr. ago").
45
68
  */
46
- export function formatRelativeTime(date, locale = 'en') {
47
- const d = date instanceof Date ? date : new Date(date);
69
+ export function formatRelativeTime(date, locale = 'en', options = {}) {
70
+ const d = toDisplayDate(date);
71
+ if (isNaN(d.getTime())) {
72
+ return String(date);
73
+ }
48
74
  const now = new Date();
49
75
  const diffMs = d.getTime() - now.getTime();
50
76
  const diffSec = Math.round(diffMs / 1000);
51
77
  const diffMin = Math.round(diffSec / 60);
52
78
  const diffHour = Math.round(diffMin / 60);
53
79
  const diffDay = Math.round(diffHour / 24);
54
- const rtf = new Intl.RelativeTimeFormat(locale, { numeric: 'auto' });
80
+ const rtf = new Intl.RelativeTimeFormat(locale, {
81
+ numeric: options.numeric ?? 'auto',
82
+ style: options.style ?? 'long',
83
+ });
55
84
  if (Math.abs(diffSec) < 60)
56
85
  return rtf.format(diffSec, 'second');
57
86
  if (Math.abs(diffMin) < 60)
@@ -2,3 +2,4 @@ export { formatDate, formatDateTime, formatRelativeTime, formatCurrency, formatN
2
2
  export { resolvePlural, formatDateSpec, formatNumberSpec, applyLocaleConfig, type SpecPluralRule, type SpecDateFormat, type SpecNumberFormat, type SpecLocaleConfig, } from './spec-formatters.js';
3
3
  export { isSpecTranslationData, transformSpecTranslations, type SpecTranslationData, } from './spec-translations.js';
4
4
  export { formatDisplayNumber, shouldGroupDisplayNumber, type DisplayNumberFormatOptions, } from './number-display.js';
5
+ export { firstDayOfWeek, type WeekdayIndex } from './first-day-of-week.js';
@@ -2,3 +2,4 @@ export { formatDate, formatDateTime, formatRelativeTime, formatCurrency, formatN
2
2
  export { resolvePlural, formatDateSpec, formatNumberSpec, applyLocaleConfig, } from './spec-formatters.js';
3
3
  export { isSpecTranslationData, transformSpecTranslations, } from './spec-translations.js';
4
4
  export { formatDisplayNumber, shouldGroupDisplayNumber, } from './number-display.js';
5
+ export { firstDayOfWeek } from './first-day-of-week.js';
@@ -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 = date instanceof Date ? date : new Date(date);
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 `data.objects` exists and at least one entry has a
54
- * nested `fields` object (the distinguishing trait vs. an already-flattened
55
- * namespace tree).
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 `data.objects` exists and at least one entry has a
30
- * nested `fields` object (the distinguishing trait vs. an already-flattened
31
- * namespace tree).
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
- const objects = data.objects;
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 = [
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@object-ui/i18n",
3
- "version": "17.6.0",
3
+ "version": "18.0.0",
4
4
  "type": "module",
5
5
  "sideEffects": false,
6
6
  "license": "MIT",
@@ -23,6 +23,11 @@
23
23
  "import": "./dist/index.js",
24
24
  "default": "./dist/index.js"
25
25
  },
26
+ "./locales": {
27
+ "types": "./dist/locales/index.d.ts",
28
+ "import": "./dist/locales/index.js",
29
+ "default": "./dist/locales/index.js"
30
+ },
26
31
  "./locales/*": {
27
32
  "types": "./dist/locales/*.d.ts",
28
33
  "import": "./dist/locales/*.js",
@@ -30,9 +35,9 @@
30
35
  }
31
36
  },
32
37
  "dependencies": {
33
- "i18next": "^26.3.6",
38
+ "i18next": "^26.4.0",
34
39
  "react-i18next": "^17.0.11",
35
- "@object-ui/core": "17.6.0"
40
+ "@object-ui/core": "18.0.0"
36
41
  },
37
42
  "peerDependencies": {
38
43
  "react": "^18.0.0 || ^19.0.0"
@@ -41,7 +46,8 @@
41
46
  "@types/react": "19.2.18",
42
47
  "react": "19.2.8",
43
48
  "typescript": "^6.0.3",
44
- "vitest": "^4.1.10"
49
+ "vitest": "^4.1.10",
50
+ "@object-ui/test-support": "17.4.0"
45
51
  },
46
52
  "keywords": [
47
53
  "objectui",
@@ -67,8 +73,8 @@
67
73
  "LICENSE"
68
74
  ],
69
75
  "scripts": {
70
- "build": "tsc",
71
- "test": "vitest run",
76
+ "build": "tsc && node ../../scripts/check-dist-completeness.mjs",
77
+ "test": "vitest run --root ../.. packages/i18n/",
72
78
  "type-check": "tsc --noEmit && tsc -p tsconfig.test.json",
73
79
  "lint": "eslint ."
74
80
  }