@cosmicdrift/kumiko-renderer 0.321.0 → 0.322.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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@cosmicdrift/kumiko-renderer",
3
- "version": "0.321.0",
3
+ "version": "0.322.0",
4
4
  "description": "Platform-agnostic React renderer for Kumiko screens. Contains the shared logic — primitives-contract, hooks, KumikoScreen, navigation & SSE abstractions — that any platform-specific renderer (web, native) composes. No DOM, no EventSource, no react-dom.",
5
5
  "license": "BUSL-1.1",
6
6
  "author": "Marc Frost <marc@cosmicdriftgamestudio.com>",
@@ -15,9 +15,9 @@
15
15
  }
16
16
  },
17
17
  "dependencies": {
18
- "@cosmicdrift/kumiko-framework": "0.321.0",
19
- "@cosmicdrift/kumiko-headless": "0.321.0",
20
- "@cosmicdrift/kumiko-types": "0.321.0",
18
+ "@cosmicdrift/kumiko-framework": "0.322.0",
19
+ "@cosmicdrift/kumiko-headless": "0.322.0",
20
+ "@cosmicdrift/kumiko-types": "0.322.0",
21
21
  "react": "^19.2.6",
22
22
  "temporal-polyfill": "^0.3.2",
23
23
  "zod": "^4.4.3"
@@ -28,7 +28,7 @@
28
28
  "@types/react-dom": "^19.2.3",
29
29
  "jsdom": "^29.1.1",
30
30
  "react-dom": "^19.2.6",
31
- "@cosmicdrift/kumiko-locale-de": "0.321.0"
31
+ "@cosmicdrift/kumiko-locale-de": "0.322.0"
32
32
  },
33
33
  "repository": {
34
34
  "type": "git",
@@ -88,6 +88,34 @@ describe("useTranslation — lookup order", () => {
88
88
  });
89
89
  expect(result.current("greet", { name: "Marc" })).toBe("Hallo Marc!");
90
90
  });
91
+ test("resolves a plural entry's CLDR category from a fallback bundle — matches the server's output", () => {
92
+ const bundles: TranslationsByLocale[] = [
93
+ {
94
+ de: {
95
+ "notifications.unread": {
96
+ one: "{count} ungelesene Nachricht",
97
+ other: "{count} ungelesene Nachrichten",
98
+ },
99
+ },
100
+ en: {
101
+ "notifications.unread": {
102
+ one: "{count} unread message",
103
+ other: "{count} unread messages",
104
+ },
105
+ },
106
+ },
107
+ ];
108
+ const { result: en } = renderHook(() => useTranslation(), {
109
+ wrapper: wrap(createStaticLocaleResolver({ locale: "en" }), bundles),
110
+ });
111
+ expect(en.current("notifications.unread", { count: 1 })).toBe("1 unread message");
112
+ expect(en.current("notifications.unread", { count: 5 })).toBe("5 unread messages");
113
+
114
+ const { result: de } = renderHook(() => useTranslation(), {
115
+ wrapper: wrap(createStaticLocaleResolver({ locale: "de" }), bundles),
116
+ });
117
+ expect(de.current("notifications.unread", { count: 1 })).toBe("1 ungelesene Nachricht");
118
+ });
91
119
  });
92
120
  describe("useTranslation — formality", () => {
93
121
  const bundles: TranslationsByLocale[] = [
@@ -7,10 +7,11 @@
7
7
  // with boolean facets coerced from URL-state strings to real booleans.
8
8
 
9
9
  import { describe, expect, test } from "bun:test";
10
- import type {
11
- EntityDefinition,
12
- EntityListScreenDefinition,
13
- ProjectionListScreenDefinition,
10
+ import {
11
+ type EntityDefinition,
12
+ type EntityListScreenDefinition,
13
+ type ProjectionListScreenDefinition,
14
+ translationValueOtherText,
14
15
  } from "@cosmicdrift/kumiko-framework/ui-types";
15
16
  import type { Dispatcher } from "@cosmicdrift/kumiko-headless";
16
17
  import { act, render, waitFor } from "@testing-library/react";
@@ -360,8 +361,10 @@ describe("projectionList filter + facets (fw#2224)", () => {
360
361
  if (en === undefined) throw new Error("missing en default translations");
361
362
  const save = de?.["kumiko.actions.save"] ?? en["kumiko.actions.save"];
362
363
  const cancel = de?.["kumiko.actions.cancel"] ?? en["kumiko.actions.cancel"];
363
- expect(facet?.label).toBe(save);
364
- expect(facet?.options?.[0]?.label).toBe(cancel);
364
+ expect(facet?.label).toBe(save === undefined ? undefined : translationValueOtherText(save));
365
+ expect(facet?.options?.[0]?.label).toBe(
366
+ cancel === undefined ? undefined : translationValueOtherText(cancel),
367
+ );
365
368
  });
366
369
 
367
370
  test("a reference facet loads its options from the referenced entity's list query, and selecting one sends an id filter", async () => {
package/src/i18n.tsx CHANGED
@@ -14,7 +14,12 @@
14
14
  // resolver's `subscribe()` — app code can switch language mid-session
15
15
  // without a reload.
16
16
 
17
- import { type Formality, formalLocaleTag } from "@cosmicdrift/kumiko-framework/ui-types";
17
+ import {
18
+ type Formality,
19
+ formalLocaleTag,
20
+ resolveTranslationValue,
21
+ type TranslationValue,
22
+ } from "@cosmicdrift/kumiko-framework/ui-types";
18
23
  import type { LocaleResolver } from "@cosmicdrift/kumiko-headless";
19
24
  import {
20
25
  createContext,
@@ -25,19 +30,22 @@ import {
25
30
  useSyncExternalStore,
26
31
  } from "react";
27
32
 
28
- /** Map von i18n-Key → Template-String. Templates dürfen `{name}`-
29
- * Platzhalter enthalten — identische Semantik zu i18next-t. */
30
- export type TranslationBundle = Readonly<Record<string, string>>;
33
+ /** Map of i18n key → TranslationValue. Strings may contain `{name}`
34
+ * placeholders (same semantics as i18next t); plural entries resolve via
35
+ * `resolveTranslationValue`. */
36
+ export type TranslationBundle = Readonly<Record<string, TranslationValue>>;
31
37
 
32
38
  /** Map von Locale-Code (BCP-47, z.B. `"de"`, `"en-US"`) → Bundle. */
33
39
  export type TranslationsByLocale = Readonly<Record<string, TranslationBundle>>;
34
40
 
35
- /** Key-first shape for `r.translations({ keys })` — each key maps locale → string. */
36
- export type TranslationsByKey = Readonly<Record<string, Readonly<Record<string, string>>>>;
41
+ /** Key-first shape for `r.translations({ keys })` — each key maps locale → TranslationValue. */
42
+ export type TranslationsByKey = Readonly<
43
+ Record<string, Readonly<Record<string, TranslationValue>>>
44
+ >;
37
45
 
38
46
  /** Pivot key-first server translations to locale-first client bundles. */
39
47
  export function translationsByLocaleFromKeys(source: TranslationsByKey): TranslationsByLocale {
40
- const out: Record<string, Record<string, string>> = {};
48
+ const out: Record<string, Record<string, TranslationValue>> = {};
41
49
  for (const [key, byLocale] of Object.entries(source)) {
42
50
  for (const [locale, value] of Object.entries(byLocale)) {
43
51
  out[locale] ??= {};
@@ -55,7 +63,7 @@ export function mergeTranslations(
55
63
  override: TranslationsByLocale,
56
64
  ): TranslationsByLocale {
57
65
  const locales = new Set([...Object.keys(base), ...Object.keys(override)]);
58
- const merged: Record<string, Record<string, string>> = {};
66
+ const merged: Record<string, Record<string, TranslationValue>> = {};
59
67
  for (const locale of locales) {
60
68
  merged[locale] = { ...(base[locale] ?? {}), ...(override[locale] ?? {}) };
61
69
  }
@@ -199,7 +207,7 @@ function translateWithFallbacks(
199
207
  for (const bundle of ctx.fallbackBundles) {
200
208
  for (const localeToTry of localesToTry) {
201
209
  const value = bundle[localeToTry]?.[key];
202
- if (value !== undefined) return interpolate(value, params);
210
+ if (value !== undefined) return resolveTranslationValue(value, localeToTry, params);
203
211
  }
204
212
  }
205
213
  }
@@ -217,14 +225,6 @@ function bundleLocaleTiers(
217
225
  return [[formalLocaleTag(locale), formalLocaleTag(languageRoot)], plainTier];
218
226
  }
219
227
 
220
- function interpolate(template: string, params?: Readonly<Record<string, unknown>>): string {
221
- if (params === undefined) return template;
222
- return template.replace(/\{(\w+)\}/g, (_, name: string) => {
223
- const value = params[name];
224
- return value !== undefined ? String(value) : `{${name}}`;
225
- });
226
- }
227
-
228
228
  /** Non-throwing LocaleContext read — undefined outside LocaleProvider.
229
229
  * Used by DataTable FormatCell so plain tables without a provider do not crash. */
230
230
  export function useOptionalLocale(): string | undefined {