@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
@@ -11,6 +11,7 @@
11
11
  * | What | Auto-generated key | Fallback |
12
12
  * |--------------------|-------------------------------------------------|-----------------------|
13
13
  * | Object label | {ns}.objects.{objectName}.label | objectDef.label |
14
+ * | Object plural | {ns}.objects.{objectName}.pluralLabel | objectDef.pluralLabel, then the object label |
14
15
  * | Object description | {ns}.objects.{objectName}.description | objectDef.description |
15
16
  * | Field label | {ns}.fields.{objectName}.{fieldName} | field.label |
16
17
  *
@@ -108,9 +109,10 @@ export function useObjectLabel() {
108
109
  * include crm.objects.* or crm.apps.*.
109
110
  *
110
111
  * `globalActions` is included so an app bundle whose only translated scope
111
- * is global actions (no object/field entries) is still discovered — its
112
- * `globalActions.<action>.*` overlays would otherwise be unreachable
113
- * (objectui#3372).
112
+ * is global actions (no object/field entries) is still discovered — the
113
+ * `globalActions.<action>.*` copy of its object-less actions would otherwise
114
+ * be unreachable (objectui#3372; object-less only since objectui#11439, see
115
+ * `actionSuffixes`).
114
116
  */
115
117
  const getAppNamespaces = () => {
116
118
  if (!hasUsableI18nInstance(i18n))
@@ -168,28 +170,35 @@ export function useObjectLabel() {
168
170
  };
169
171
  /**
170
172
  * Build suffix candidates for an action-scoped key, mirroring the canonical
171
- * `@objectstack/spec` resolver (`lookupActionField` in `system/i18n-resolver`).
173
+ * `@objectstack/spec` resolver (`actionTranslationNode` behind
174
+ * `lookupActionField`, in `system/i18n-resolver`): ONE node per action, chosen
175
+ * by its key object.
172
176
  *
173
- * When the action is object-scoped, the object key
174
- * (`objects.<obj>._actions.<action>.<tail>`) wins, but the global key
175
- * (`globalActions.<action>.<tail>`) is appended as a fallback so a
176
- * **globalAction surfaced on an object's action bar** still picks up its
177
- * `globalActions.<action>.*` overlay when no object-scoped translation
178
- * exists (objectui#3372). Without this fallback, a globalAction rendered on
179
- * a record-detail action bar — where the caller passes `objectDef.name` for
180
- * every action — misses `objects.<obj>._actions.<action>.label` and leaks the
181
- * English metadata literal.
177
+ * `objectName` is the action's KEY object — the object the action belongs
178
+ * to: its declared `objectName`, else the host object that embeds it, which
179
+ * is what the spec's `translateObject` stamps on an embedded action. Callers
180
+ * pass that object (`useActionTextLocalizer` derives it from the action).
182
181
  *
183
- * Object-less actions (`objectName` omitted) resolve straight to the global
184
- * namespace, as before. Object precedence is preserved: the global key is
185
- * only consulted after every object-scoped candidate misses.
182
+ * - A key object reads `objects.<obj>._actions.<action>.<tail>` and nothing
183
+ * else.
184
+ * - No key object reads `globalActions.<action>.<tail>` and nothing else.
185
+ *
186
+ * There is deliberately NO object-then-global chain. objectui#3372 added one
187
+ * so `globalActions` copy reached actions drawn on an object's bar; the
188
+ * spec's `TranslationDataSchema.globalActions` is for actions "not bound to a
189
+ * specific object via `objectName`", `@objectstack/spec` 17.7.0 never reads it
190
+ * for a bound action, and objectui#11439 (triage's amended ruling) retired the
191
+ * chain so this client and the server resolve one bundle entry the same way.
192
+ * A bound action's copy filed under `globalActions` now misses on every
193
+ * surface alike and the authored text shows — the misfiling is what
194
+ * `os validate` already refuses, and it is fixed in the bundle, not here.
195
+ *
196
+ * The two object-name candidates (`objects.<ns__obj>` then `objects.<obj>`)
197
+ * are a separate axis and stay — see `stripNamespace`.
186
198
  */
187
- const actionSuffixes = (objectName, actionName, tail) => {
188
- const globalSuffix = `globalActions.${actionName}.${tail}`;
189
- return objectName
190
- ? [...objectSuffixes(objectName, `_actions.${actionName}.${tail}`), globalSuffix]
191
- : [globalSuffix];
192
- };
199
+ const actionSuffixes = (objectName, actionName, tail) => objectName
200
+ ? objectSuffixes(objectName, `_actions.${actionName}.${tail}`)
201
+ : [`globalActions.${actionName}.${tail}`];
193
202
  const fieldSuffixes = (objectName, fieldName) => {
194
203
  const base = stripNamespace(objectName);
195
204
  return base !== objectName
@@ -242,8 +251,8 @@ export function useObjectLabel() {
242
251
  * name under `_views` — the runtime view identity's own name, stripped of the
243
252
  * object prefix. That single spelling is canonical per the objectstack#5164
244
253
  * ruling A (2026-08-06): the i18n extractor asks the view composer for the key
245
- * (objectstack#6124) and `packages/lint` enforces exactly that spelling
246
- * (objectstack#6038), so this resolver accepts exactly what those produce.
254
+ * (objectstack `b3c1f3cd5`) and `packages/lint` enforces exactly that spelling
255
+ * (objectstack `7618ee814`), so this resolver accepts exactly what those produce.
247
256
  *
248
257
  * Deliberately NOT a second candidate: the prefixed full name
249
258
  * (`_views.<objectName>.<viewName>`). Accepting it made this client more
@@ -268,11 +277,37 @@ export function useObjectLabel() {
268
277
  const bareViewName = matchedPrefix ? viewName.slice(matchedPrefix.length) : viewName;
269
278
  return objectSuffixes(objectName, `_views.${bareViewName}.${tail}`);
270
279
  };
280
+ /** The singular object label; `objectPluralLabel` falls back to it. */
281
+ const objectLabel = (objectDef) => resolve(objectSuffixes(objectDef.name, 'label'), objectDef.label);
271
282
  return {
272
283
  /**
273
284
  * Resolve translated object label, falling back to objectDef.label.
274
285
  */
275
- objectLabel: (objectDef) => resolve(objectSuffixes(objectDef.name, 'label'), objectDef.label),
286
+ objectLabel,
287
+ /**
288
+ * Resolve the object's PLURAL label — what a list of its records is called:
289
+ * an object list page's title and the breadcrumb segment that links to that
290
+ * list (objectui#11696). Record-scoped surfaces (the record page, a record
291
+ * drawer, "New …") keep {@link objectLabel}.
292
+ *
293
+ * Order: the translated plural (`{ns}.objects.{objectName}.pluralLabel`),
294
+ * else the declared `objectDef.pluralLabel`, else the singular as
295
+ * `objectLabel` resolves it — `pluralLabel` is optional in the spec, and an
296
+ * object that declares none is called by its label everywhere.
297
+ *
298
+ * The first two rungs are the client half of `translateObject` in
299
+ * `@objectstack/spec` (`system/i18n-resolver`), which serves `pluralLabel`
300
+ * as the catalog entry, else the authored value. There is deliberately NO
301
+ * rung that prefers a translated SINGULAR over a declared plural the bundle
302
+ * leaves untranslated: the server has none either, so every consumer of
303
+ * `/meta` shows the authored plural in that case, and this client showing
304
+ * something else would be a second dialect (see `viewSuffixes`). The gap is
305
+ * the bundle's — `os i18n extract` writes `pluralLabel` wherever the object
306
+ * declares one — and is fixed there.
307
+ */
308
+ objectPluralLabel: (objectDef) => resolve(objectSuffixes(objectDef.name, 'pluralLabel'), '')
309
+ || objectDef.pluralLabel
310
+ || objectLabel(objectDef),
276
311
  /**
277
312
  * Resolve translated object description, falling back to objectDef.description.
278
313
  */
@@ -328,15 +363,17 @@ export function useObjectLabel() {
328
363
  * reaches this client — so nav labels arrive already localized. One
329
364
  * owner, not two. To translate a sidebar group, translate it there.
330
365
  *
331
- * History (objectui#5197): until then this docstring promised
366
+ * History (`9c60144b5`): until then this docstring promised
332
367
  * `"Sales" → "销售"` for sidebar groups, and `NavigationRenderer`
333
368
  * accepted `resolveGroupLabel`/`resolveItemLabel` to wire it up. Those
334
369
  * props could never fire — the renderer's `isCustomized` guard compared a
335
370
  * node's authored label against its own `id` (`Workspace` vs
336
371
  * `grp_workspace`), which never match, so the guard was true for every
337
- * real entry. The props are gone; the promise was false, not merely
338
- * unused. This helper is kept as a plain key reader for a consumer that
339
- * renders navigation itself — it has no first-party caller.
372
+ * real entry. The props are gone, and so is the guard itself
373
+ * (objectui#11201: the renderer no longer matches a label's text against
374
+ * any name); the promise was false, not merely unused. This helper is kept
375
+ * as a plain key reader for a consumer that renders navigation itself — it
376
+ * has no first-party caller.
340
377
  */
341
378
  navGroupLabel: (appName, groupId, fallback) => resolve(`apps.${appName}.navigation.${groupId}.label`, fallback),
342
379
  /**
@@ -371,39 +408,17 @@ export function useObjectLabel() {
371
408
  * Resolve translated widget description within a dashboard.
372
409
  * Convention: `{ns}.dashboards.{dashboardName}.widgets.{widgetId}.description`.
373
410
  * Returns undefined when neither metadata nor translation provides one.
411
+ *
412
+ * This is a widget's ONE translated description. The sibling sub-caption
413
+ * key, `widgets.{widgetId}.subCaption`, is retired with its resolver
414
+ * (objectui#11389, ruling C): `@objectstack/spec` 17.7.0 refuses it by
415
+ * name in a translation bundle, and nothing here reads it.
374
416
  */
375
417
  widgetDescription: (dashboardName, widgetId, fallback) => {
376
418
  const fb = fallback ?? '';
377
419
  const resolved = resolve(dashboardSuffixes(dashboardName, `widgets.${widgetId}.description`), fb);
378
420
  return resolved || undefined;
379
421
  },
380
- /**
381
- * Resolve a translated metric-widget SUB-CAPTION within a dashboard.
382
- * Convention: `{ns}.dashboards.{dashboardName}.widgets.{widgetId}.subCaption`.
383
- * Returns undefined when neither metadata nor translation provides one.
384
- *
385
- * Deliberately its OWN key, not a second reader of `widgets.{id}.description`.
386
- * The KPI card renders two authored strings from two different fields — the
387
- * shared card header's `widget.description`, and the sub-caption under the
388
- * value, which is authored as `widget.options.description` — and the
389
- * objectstack#5428 item-4 ruling (2026-08-06) settled that they get two
390
- * keys, not one: 「两个作者字段两个 key」. Collapsing them would make one
391
- * translation entry silently retarget the other field on any widget type
392
- * that renders both at once (`kpi`, `gauge`, `bullet` — every metric-family
393
- * type except the self-contained `metric`).
394
- *
395
- * `subCaption` is the member objectstack#8056 added to the widget
396
- * translation node for exactly this, shipped in `@objectstack/spec@17.0.0`.
397
- * The server-side resolver reads the SAME key and overlays it onto
398
- * `options.description` (`translateDashboard`), so a document served
399
- * through `/meta` and a document translated here land on the same string —
400
- * this is the client half of one convention, not a second dialect.
401
- */
402
- widgetSubCaption: (dashboardName, widgetId, fallback) => {
403
- const fb = fallback ?? '';
404
- const resolved = resolve(dashboardSuffixes(dashboardName, `widgets.${widgetId}.subCaption`), fb);
405
- return resolved || undefined;
406
- },
407
422
  /**
408
423
  * Resolve translated page label, falling back to pageDef.label.
409
424
  * Convention: `{ns}.pages.{pageName}.label`.
@@ -422,15 +437,27 @@ export function useObjectLabel() {
422
437
  * that spells the prefix out does not resolve (see `viewSuffixes`).
423
438
  */
424
439
  viewLabel: (objectName, viewName, fallback) => resolve(viewSuffixes(objectName, viewName, 'label'), fallback),
425
- /**
426
- * Resolve translated list-view description.
427
- * Convention: `{ns}.objects.{objectName}._views.{viewName}.description`.
440
+ /*
441
+ * There is deliberately NO `viewDescription` member here, and no
442
+ * `{ns}.objects.{objectName}._views.{viewName}.description` convention for
443
+ * it to resolve (objectui#7219, maintainer ruling 2026-09-02, option B).
444
+ *
445
+ * A list view's description has exactly ONE channel: the `I18nLabel` value
446
+ * authored on the view entry -- a string or an inline locale map -- which
447
+ * `ObjectView` relays and the render site resolves with `pickLocalized`
448
+ * (objectui#7199). The catalog key used to be declared and resolved right
449
+ * here, between its two wired-up siblings, but had zero callers and zero
450
+ * in-repo bundle usage: a bundle entry written under it reached no screen.
451
+ *
452
+ * Wiring it in instead was weighed and NOT taken -- two vocabularies for
453
+ * one concept plus a precedence rule is the ambiguity, not the fix. Leaving
454
+ * it declared and unfulfilled was not taken either; the standing rule is
455
+ * remove, not phase out, when a surface has measured zero use.
456
+ *
457
+ * `viewSuffixes` is NOT retired with it: `viewLabel` above and
458
+ * `viewEmptyState` below share that helper and keep resolving. Only the
459
+ * `'description'` tail passed to it is gone.
428
460
  */
429
- viewDescription: (objectName, viewName, fallback) => {
430
- const fb = fallback ?? '';
431
- const resolved = resolve(viewSuffixes(objectName, viewName, 'description'), fb);
432
- return resolved || undefined;
433
- },
434
461
  /**
435
462
  * Resolve translated list-view emptyState. Returns a {title, message}
436
463
  * tuple with each field independently translated. Convention:
@@ -455,17 +482,18 @@ export function useObjectLabel() {
455
482
  sectionLabel: (objectName, sectionName, fallback) => resolve(objectSuffixes(objectName, `_sections.${sectionName}.label`), fallback),
456
483
  /**
457
484
  * Resolve translated action label.
458
- * Convention: `{ns}.objects.{objectName}._actions.{actionName}.label`,
459
- * falling back to `{ns}.globalActions.{actionName}.label` — both when
460
- * objectName is omitted AND when the object-scoped key misses (so a
461
- * globalAction surfaced on a record-detail action bar still resolves;
462
- * objectui#3372).
485
+ * `objectName` is the action's KEY object (its declared `objectName`, else
486
+ * the host that embeds it). Convention:
487
+ * `{ns}.objects.{objectName}._actions.{actionName}.label`; with no key
488
+ * object, `{ns}.globalActions.{actionName}.label`. Never both: a bound
489
+ * action does not read `globalActions` (objectui#11439, see
490
+ * `actionSuffixes`). Every action resolver below follows the same rule.
463
491
  */
464
492
  actionLabel: (objectName, actionName, fallback) => resolve(actionSuffixes(objectName, actionName, 'label'), fallback),
465
493
  /**
466
494
  * Resolve translated action confirmation prompt.
467
- * Convention: `{ns}.objects.{objectName}._actions.{actionName}.confirmText`,
468
- * falling back to `{ns}.globalActions.{actionName}.confirmText`.
495
+ * Convention: `{ns}.objects.{objectName}._actions.{actionName}.confirmText`;
496
+ * with no key object, `{ns}.globalActions.{actionName}.confirmText`.
469
497
  * Returns undefined when no translation and no fallback exist.
470
498
  */
471
499
  actionConfirm: (objectName, actionName, fallback) => {
@@ -475,20 +503,38 @@ export function useObjectLabel() {
475
503
  },
476
504
  /**
477
505
  * Resolve translated action success message.
478
- * Convention: `{ns}.objects.{objectName}._actions.{actionName}.successMessage`,
479
- * falling back to `{ns}.globalActions.{actionName}.successMessage`.
506
+ * Convention: `{ns}.objects.{objectName}._actions.{actionName}.successMessage`;
507
+ * with no key object, `{ns}.globalActions.{actionName}.successMessage`.
480
508
  */
481
509
  actionSuccess: (objectName, actionName, fallback) => {
482
510
  const fb = fallback ?? '';
483
511
  const resolved = resolve(actionSuffixes(objectName, actionName, 'successMessage'), fb);
484
512
  return resolved || undefined;
485
513
  },
514
+ /**
515
+ * Resolve the translated success copy for ONE handler outcome — an entry of
516
+ * the action's `outcomeMessages` (`@objectstack/spec` 17.6.0, objectui#11344).
517
+ * Convention: `{ns}.objects.{objectName}._actions.{actionName}.outcomeMessages.{outcome}`;
518
+ * with no key object, `{ns}.globalActions.{actionName}.outcomeMessages.{outcome}`
519
+ * — the addresses the spec's translation bundle declares for the key.
520
+ * Returns undefined when no translation and no fallback exist.
521
+ *
522
+ * Like `actionSuccess`, the answer is a TEMPLATE: a `${result.*}` token in a
523
+ * bundle entry passes through untouched (i18next interpolates its own
524
+ * `{{…}}` form, never `${…}`), and the action runner fills it in from the
525
+ * handler's answer once the action has run.
526
+ */
527
+ actionOutcome: (objectName, actionName, outcome, fallback) => {
528
+ const fb = fallback ?? '';
529
+ const resolved = resolve(actionSuffixes(objectName, actionName, `outcomeMessages.${outcome}`), fb);
530
+ return resolved || undefined;
531
+ },
486
532
  /**
487
533
  * Resolve translated action description (the explanatory line shown in the
488
534
  * action's param dialog / sheet / drawer header).
489
- * Convention: `{ns}.objects.{objectName}._actions.{actionName}.description`.
490
- * Falls back to `{ns}.globalActions.{actionName}.description`, then the
491
- * metadata's literal string; undefined when nothing resolves.
535
+ * Convention: `{ns}.objects.{objectName}._actions.{actionName}.description`;
536
+ * with no key object, `{ns}.globalActions.{actionName}.description`. Falls
537
+ * back to the metadata's literal string; undefined when nothing resolves.
492
538
  */
493
539
  actionDescription: (objectName, actionName, fallback) => {
494
540
  const fb = fallback ?? '';
@@ -500,10 +546,9 @@ export function useObjectLabel() {
500
546
  /**
501
547
  * Resolve a translated copy of an action's post-success RESULT DIALOG
502
548
  * (`title` / `description` / `acknowledge` + per-field labels).
503
- * Convention: `{ns}.objects.{objectName}._actions.{actionName}.resultDialog.*`,
504
- * falling back to `{ns}.globalActions.{actionName}.resultDialog.*` (when
505
- * objectName is omitted OR the object-scoped key misses), then to the
506
- * metadata's literal strings.
549
+ * Convention: `{ns}.objects.{objectName}._actions.{actionName}.resultDialog.*`;
550
+ * with no key object, `{ns}.globalActions.{actionName}.resultDialog.*`.
551
+ * Falls back to the metadata's literal strings.
507
552
  *
508
553
  * The `fields` translation node is keyed by the LITERAL result-field path
509
554
  * from the action metadata (may contain dots, e.g. `"user.email"`), so it
@@ -556,7 +601,8 @@ export function useObjectLabel() {
556
601
  },
557
602
  /**
558
603
  * Resolve translated action-PARAMETER text (label / placeholder / helpText).
559
- * Convention: `{ns}.objects.{objectName}._actions.{actionName}.params.{paramName}.{attr}`.
604
+ * Convention: `{ns}.objects.{objectName}._actions.{actionName}.params.{paramName}.{attr}`;
605
+ * with no key object, `{ns}.globalActions.{actionName}.params.{paramName}.{attr}`.
560
606
  * Falls back to the provided value (the metadata's literal string) when no
561
607
  * translation exists, so untranslated params keep rendering as authored.
562
608
  */
@@ -569,7 +615,8 @@ export function useObjectLabel() {
569
615
  },
570
616
  /**
571
617
  * Resolve a translated action-parameter SELECT OPTION label.
572
- * Convention: `{ns}.objects.{objectName}._actions.{actionName}.params.{paramName}.options.{optionValue}`.
618
+ * Convention: `{ns}.objects.{objectName}._actions.{actionName}.params.{paramName}.options.{optionValue}`;
619
+ * with no key object, the same path under `{ns}.globalActions.{actionName}`.
573
620
  * Falls back to the provided (English metadata) label when untranslated.
574
621
  */
575
622
  actionParamOptionLabel: (objectName, actionName, paramName, optionValue, fallback) => {
@@ -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[key]` (the pack value's stand-in here)
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 i18next option that names the string to use when the lookup misses.
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
- * It is a LOOKUP CONTROL, not interpolation data (objectui#3865): it chooses
20
- * which string is rendered, so it must never also be spliced into a
21
- * `{{defaultValue}}` hole in the string it chose — or in any other.
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
- const DEFAULT_VALUE_OPTION = 'defaultValue';
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[key] || (typeof inlineDefault === 'string' ? inlineDefault : '') || key;
52
- if (options) {
53
- for (const [k, v] of Object.entries(options)) {
54
- // Reserved: see DEFAULT_VALUE_OPTION. Skipped whatever its type, so an
55
- // ignored non-string default cannot re-enter through this loop.
56
- //
57
- // This is the one deliberate divergence from i18next on this path, and
58
- // it is unreachable with today's strings: i18next passes the whole
59
- // options object to its interpolator, so it WOULD render
60
- // `'Fallback: {{defaultValue}}'` as `'Fallback: INLINE'` (measured on
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() {
@@ -0,0 +1,71 @@
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
+ * The first day of the week for a BCP-47 locale tag (objectui#11675).
10
+ *
11
+ * The first day of the week follows the user's locale, as dates, times and
12
+ * numbers do (ruling B on objectui#11675: there is no separate week-start
13
+ * setting). So a week grid, a weekday header row and a "this week" bound all
14
+ * read it from the tag they already format with, and never from a hard-coded
15
+ * Sunday or Monday: `en-US` starts on Sunday, `en-GB` and `zh-CN` on Monday.
16
+ *
17
+ * ## The engine's own week info comes first
18
+ *
19
+ * `Intl.Locale` carries CLDR's week data for the tag's region, including a
20
+ * `-u-fw-` keyword on the tag. It has two spellings: the `getWeekInfo()` method
21
+ * of the current Intl Locale Info proposal, and the `weekInfo` accessor engines
22
+ * shipped first. Both are read, the method first. Measured while writing this:
23
+ * Chromium 141 has both, Node 22 (the test and CI runtime) has the accessor
24
+ * only. Both count ISO weekdays, 1 (Monday) to 7 (Sunday).
25
+ *
26
+ * ## Where the engine has none, CLDR's table answers
27
+ *
28
+ * An engine with neither spelling gets {@link NON_MONDAY_REGIONS}: CLDR's
29
+ * `weekData` `firstDay` rows, read by region. The region is the tag's own, or
30
+ * the one CLDR's likely subtags give it (`Intl.Locale.prototype.maximize`), so
31
+ * `en` reads `US` and `zh` reads `CN`. A region the table does not list, and a
32
+ * tag with no region even after `maximize()`, reads Monday, CLDR's world
33
+ * default (`001`). This path reads the region only: a `-u-fw-` or `-u-rg-`
34
+ * keyword is the engine path's alone.
35
+ *
36
+ * The table transcribes ONE CLDR release, the one {@link WEEK_DATA_CLDR} names:
37
+ * the newest release the repository's CI runtime ships. Engines carry their own
38
+ * week data, and it differs: CLDR moves a region now and then, and a browser
39
+ * may carry data of its own. That is why the engine's answer wins wherever
40
+ * there is one. What re-derives the table against the running engine is
41
+ * `__tests__/firstDayOfWeek-11675.test.ts`, region by region. On a runtime
42
+ * whose CLDR release is not the table's, it reads that release's recorded
43
+ * differences from the table instead of reporting them as drift.
44
+ *
45
+ * ## A tag `Intl` refuses is refused here too
46
+ *
47
+ * A malformed tag throws `Intl.Locale`'s `RangeError`, as `Intl.DateTimeFormat`
48
+ * and `toLocaleDateString` do for the same tag. Every caller formats its dates
49
+ * with the tag it hands this function, so a tag that reached here and is
50
+ * malformed fails the caller's own formatting the same way. ⛔ No Sunday or
51
+ * Monday is guessed for it.
52
+ */
53
+ /** A weekday as `Date.prototype.getDay` numbers it: 0 is Sunday, 6 is Saturday. */
54
+ export type WeekdayIndex = 0 | 1 | 2 | 3 | 4 | 5 | 6;
55
+ /**
56
+ * The CLDR release {@link NON_MONDAY_REGIONS} transcribes, as the major part of
57
+ * `process.versions.cldr`. Change it only together with the table, from a
58
+ * runtime that ships that release.
59
+ */
60
+ export declare const WEEK_DATA_CLDR = "48";
61
+ /**
62
+ * The first day of the week in `locale`, numbered as `Date.prototype.getDay`
63
+ * numbers weekdays (0 is Sunday), which is also what react-day-picker's
64
+ * `weekStartsOn` takes. Read the module header for where the answer comes from.
65
+ *
66
+ * @example
67
+ * firstDayOfWeek('en-US'); // 0, Sunday
68
+ * firstDayOfWeek('en-GB'); // 1, Monday
69
+ * firstDayOfWeek('zh-CN'); // 1, Monday
70
+ */
71
+ export declare function firstDayOfWeek(locale: string): WeekdayIndex;
@@ -0,0 +1,61 @@
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
+ /** ISO weekday 1 (Monday) to 7 (Sunday), as a {@link WeekdayIndex}. */
9
+ const BY_ISO_WEEKDAY = [1, 2, 3, 4, 5, 6, 0];
10
+ function fromIsoWeekday(day) {
11
+ return Number.isInteger(day) && day >= 1 && day <= 7 ? BY_ISO_WEEKDAY[day - 1] : undefined;
12
+ }
13
+ /**
14
+ * The CLDR release {@link NON_MONDAY_REGIONS} transcribes, as the major part of
15
+ * `process.versions.cldr`. Change it only together with the table, from a
16
+ * runtime that ships that release.
17
+ */
18
+ export const WEEK_DATA_CLDR = '48';
19
+ /**
20
+ * CLDR's `weekData` `firstDay` rows other than Monday, by region, as of the
21
+ * release {@link WEEK_DATA_CLDR} names. Regions are the canonical codes
22
+ * `Intl.Locale` reports (`BU` reads `MM`), so the deprecated aliases CLDR also
23
+ * lists are left out.
24
+ */
25
+ const NON_MONDAY_REGIONS = [
26
+ [
27
+ 0,
28
+ 'AG AS BD BR BS BT BW BZ CA CO DM DO ET GT GU HK HN ID IL IN IS JM JP KE KH KR LA MH MM MO MT MX ' +
29
+ 'MZ NI NP PA PE PH PK PR PT PY SA SG SV TH TT TW UM US VE VI WS YE ZA ZW',
30
+ ],
31
+ [5, 'MV'],
32
+ [6, 'AF BH DJ DZ EG IQ IR JO KW LY OM QA SD SY'],
33
+ ];
34
+ /** CLDR's world default (`001`): the week starts on Monday. */
35
+ const WORLD_FIRST_DAY = 1;
36
+ const FIRST_DAY_BY_REGION = new Map(NON_MONDAY_REGIONS.flatMap(([day, regions]) => regions.split(' ').map((region) => [region, day])));
37
+ function engineFirstDay(locale) {
38
+ const faces = locale;
39
+ if (typeof faces.getWeekInfo === 'function')
40
+ return fromIsoWeekday(faces.getWeekInfo().firstDay);
41
+ const info = faces.weekInfo;
42
+ return info ? fromIsoWeekday(info.firstDay) : undefined;
43
+ }
44
+ function tableFirstDay(locale) {
45
+ const region = locale.region ?? locale.maximize().region;
46
+ return (region !== undefined ? FIRST_DAY_BY_REGION.get(region) : undefined) ?? WORLD_FIRST_DAY;
47
+ }
48
+ /**
49
+ * The first day of the week in `locale`, numbered as `Date.prototype.getDay`
50
+ * numbers weekdays (0 is Sunday), which is also what react-day-picker's
51
+ * `weekStartsOn` takes. Read the module header for where the answer comes from.
52
+ *
53
+ * @example
54
+ * firstDayOfWeek('en-US'); // 0, Sunday
55
+ * firstDayOfWeek('en-GB'); // 1, Monday
56
+ * firstDayOfWeek('zh-CN'); // 1, Monday
57
+ */
58
+ export function firstDayOfWeek(locale) {
59
+ const parsed = new Intl.Locale(locale);
60
+ return engineFirstDay(parsed) ?? tableFirstDay(parsed);
61
+ }
@@ -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,8 +44,19 @@ 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.
52
+ *
53
+ * `options` are `Intl.RelativeTimeFormat`'s own `style` and `numeric`, passed
54
+ * through unchanged; omitted, the result is what it always was (`long`,
55
+ * `auto`: "yesterday", "2 days ago"). A surface with little room asks for a
56
+ * compact form, e.g. `{ style: 'short', numeric: 'always' }` ("1 day ago",
57
+ * "3 hr. ago").
36
58
  */
37
- export declare function formatRelativeTime(date: Date | string | number, locale?: string): string;
59
+ export declare function formatRelativeTime(date: Date | string | number, locale?: string, options?: Pick<Intl.RelativeTimeFormatOptions, 'style' | 'numeric'>): string;
38
60
  /**
39
61
  * Format a currency value according to locale conventions. When `currency`
40
62
  * is omitted, falls back to a plain locale-formatted number (no symbol)