@pyreon/i18n 0.9.0 → 0.11.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.
@@ -0,0 +1,142 @@
1
+ import { Computed, Signal } from "@pyreon/reactivity";
2
+
3
+ //#region src/types.d.ts
4
+ /** A nested dictionary of translation strings. */
5
+ type TranslationDictionary = {
6
+ [key: string]: string | TranslationDictionary;
7
+ };
8
+ /** Map of locale → dictionary (or namespace → dictionary). */
9
+ type TranslationMessages = Record<string, TranslationDictionary>;
10
+ /**
11
+ * Async function that loads translations for a locale and namespace.
12
+ * Return the dictionary for that namespace, or undefined if not found.
13
+ */
14
+ type NamespaceLoader = (locale: string, namespace: string) => Promise<TranslationDictionary | undefined>;
15
+ /** Interpolation values for translation strings. */
16
+ type InterpolationValues = Record<string, string | number>;
17
+ /** Pluralization rules map — locale → function that picks the plural form. */
18
+ type PluralRules = Record<string, (count: number) => string>;
19
+ /** Options for creating an i18n instance. */
20
+ interface I18nOptions {
21
+ /** The initial locale (e.g. "en"). */
22
+ locale: string;
23
+ /** Fallback locale used when a key is missing in the active locale. */
24
+ fallbackLocale?: string;
25
+ /** Static messages keyed by locale. */
26
+ messages?: Record<string, TranslationDictionary>;
27
+ /**
28
+ * Async loader for namespace-based translation loading.
29
+ * Called with (locale, namespace) when `loadNamespace()` is invoked.
30
+ */
31
+ loader?: NamespaceLoader;
32
+ /**
33
+ * Default namespace used when `t()` is called without a namespace prefix.
34
+ * Defaults to "common".
35
+ */
36
+ defaultNamespace?: string;
37
+ /**
38
+ * Custom plural rules per locale.
39
+ * If not provided, uses `Intl.PluralRules` where available.
40
+ */
41
+ pluralRules?: PluralRules;
42
+ /**
43
+ * Missing key handler — called when a translation key is not found.
44
+ * Useful for logging, reporting, or returning a custom fallback.
45
+ */
46
+ onMissingKey?: (locale: string, key: string, namespace?: string) => string | undefined;
47
+ }
48
+ /** The public i18n instance returned by `createI18n()`. */
49
+ interface I18nInstance {
50
+ /**
51
+ * Translate a key with optional interpolation.
52
+ * Reads the current locale reactively — re-evaluates in effects/computeds.
53
+ *
54
+ * Key format: "key" (uses default namespace) or "namespace:key".
55
+ * Nested keys use dots: "user.greeting" or "auth:errors.invalid".
56
+ *
57
+ * Interpolation: "Hello {{name}}" + { name: "Alice" } → "Hello Alice"
58
+ * Pluralization: key with "_one", "_other" etc. suffixes + { count: N }
59
+ */
60
+ t: (key: string, values?: InterpolationValues) => string;
61
+ /** Current locale (reactive signal). */
62
+ locale: Signal<string>;
63
+ /**
64
+ * Load a namespace's translations for the given locale (or current locale).
65
+ * Returns a promise that resolves when loading is complete.
66
+ */
67
+ loadNamespace: (namespace: string, locale?: string) => Promise<void>;
68
+ /**
69
+ * Whether any namespace is currently being loaded.
70
+ */
71
+ isLoading: Computed<boolean>;
72
+ /**
73
+ * Set of namespaces that have been loaded for the current locale.
74
+ */
75
+ loadedNamespaces: Computed<Set<string>>;
76
+ /**
77
+ * Check if a translation key exists in the current locale.
78
+ */
79
+ exists: (key: string) => boolean;
80
+ /**
81
+ * Add translations for a locale (merged with existing).
82
+ * Useful for adding translations at runtime without async loading.
83
+ */
84
+ addMessages: (locale: string, messages: TranslationDictionary, namespace?: string) => void;
85
+ /**
86
+ * Get all available locales (those with any registered messages).
87
+ */
88
+ availableLocales: Computed<string[]>;
89
+ }
90
+ //#endregion
91
+ //#region src/create-i18n.d.ts
92
+ /**
93
+ * Create a reactive i18n instance.
94
+ *
95
+ * @example
96
+ * const i18n = createI18n({
97
+ * locale: 'en',
98
+ * fallbackLocale: 'en',
99
+ * messages: {
100
+ * en: { greeting: 'Hello {{name}}!' },
101
+ * de: { greeting: 'Hallo {{name}}!' },
102
+ * },
103
+ * })
104
+ *
105
+ * // Reactive translation — re-evaluates on locale change
106
+ * i18n.t('greeting', { name: 'Alice' }) // "Hello Alice!"
107
+ * i18n.locale.set('de')
108
+ * i18n.t('greeting', { name: 'Alice' }) // "Hallo Alice!"
109
+ *
110
+ * @example
111
+ * // Async namespace loading
112
+ * const i18n = createI18n({
113
+ * locale: 'en',
114
+ * loader: async (locale, namespace) => {
115
+ * const mod = await import(`./locales/${locale}/${namespace}.json`)
116
+ * return mod.default
117
+ * },
118
+ * })
119
+ * await i18n.loadNamespace('auth')
120
+ * i18n.t('auth:errors.invalid') // looks up "errors.invalid" in "auth" namespace
121
+ */
122
+ declare function createI18n(options: I18nOptions): I18nInstance;
123
+ //#endregion
124
+ //#region src/interpolation.d.ts
125
+ /**
126
+ * Replace `{{key}}` placeholders in a string with values from the given record.
127
+ * Supports optional whitespace inside braces: `{{ name }}` works too.
128
+ * Unmatched placeholders are left as-is.
129
+ */
130
+ declare function interpolate(template: string, values?: InterpolationValues): string;
131
+ //#endregion
132
+ //#region src/pluralization.d.ts
133
+ /**
134
+ * Resolve the plural category for a given count and locale.
135
+ *
136
+ * Uses custom rules if provided, otherwise falls back to `Intl.PluralRules`.
137
+ * Returns CLDR plural categories: "zero", "one", "two", "few", "many", "other".
138
+ */
139
+ declare function resolvePluralCategory(locale: string, count: number, customRules?: PluralRules): string;
140
+ //#endregion
141
+ export { type I18nInstance, type I18nOptions, type InterpolationValues, type NamespaceLoader, type PluralRules, type TranslationDictionary, type TranslationMessages, createI18n, interpolate, resolvePluralCategory };
142
+ //# sourceMappingURL=core2.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"core2.d.ts","names":[],"sources":["../../../src/types.ts","../../../src/create-i18n.ts","../../../src/interpolation.ts","../../../src/pluralization.ts"],"mappings":";;;;KAGY,qBAAA;EAAA,CACT,GAAA,oBAAuB,qBAAA;AAAA;;KAId,mBAAA,GAAsB,MAAA,SAAe,qBAAA;;AAAjD;;;KAMY,eAAA,IACV,MAAA,UACA,SAAA,aACG,OAAA,CAAQ,qBAAA;;KAGD,mBAAA,GAAsB,MAAA;;KAGtB,WAAA,GAAc,MAAA,UAAgB,KAAA;;UAGzB,WAAA;EAVf;EAYA,MAAA;EAXW;EAaX,cAAA;EAbgC;EAehC,QAAA,GAAW,MAAA,SAAe,qBAAA;EAZG;;;;EAiB7B,MAAA,GAAS,eAAA;EAdY;;;;EAmBrB,gBAAA;EAhB0B;;;;EAqB1B,WAAA,GAAc,WAAA;EAAA;;;;EAKd,YAAA,IAAgB,MAAA,UAAgB,GAAA,UAAa,SAAA;AAAA;;UAI9B,YAAA;EAnBf;;;;;;;;;;EA8BA,CAAA,GAAI,GAAA,UAAa,MAAA,GAAS,mBAAA;EAXX;EAcf,MAAA,EAAQ,MAAA;;;;;EAMR,aAAA,GAAgB,SAAA,UAAmB,MAAA,cAAoB,OAAA;EAU5B;;;EAL3B,SAAA,EAAW,QAAA;EAqBe;;;EAhB1B,gBAAA,EAAkB,QAAA,CAAS,GAAA;EAnBD;;;EAwB1B,MAAA,GAAS,GAAA;EAfT;;;;EAqBA,WAAA,GAAc,MAAA,UAAgB,QAAA,EAAU,qBAAA,EAAuB,SAAA;EAhBpD;;;EAqBX,gBAAA,EAAkB,QAAA;AAAA;;;;;AAnGpB;;;;;AAKA;;;;;AAMA;;;;;;;;;;AAMA;;;;;AAGA;;;iBCiFgB,UAAA,CAAW,OAAA,EAAS,WAAA,GAAc,YAAA;;;;;ADrGlD;;;iBEMgB,WAAA,CAAY,QAAA,UAAkB,MAAA,GAAS,mBAAA;;;;;AFNvD;;;;iBGKgB,qBAAA,CACd,MAAA,UACA,KAAA,UACA,WAAA,GAAc,WAAA"}
@@ -1 +1 @@
1
- {"version":3,"file":"devtools2.d.ts","names":[],"sources":["../../../src/devtools.ts"],"mappings":";;AAmBA;;;;;AAMA;;;;;iBANgB,YAAA,CAAa,IAAA,UAAc,QAAA;;iBAM3B,cAAA,CAAe,IAAA;;iBAMf,sBAAA,CAAA;AAQhB;AAAA,iBAAgB,eAAA,CAAgB,IAAA;;;;iBA4BhB,eAAA,CACd,IAAA,WACC,MAAA;;iBAaa,YAAA,CAAa,QAAA;;iBAQb,cAAA,CAAA"}
1
+ {"version":3,"file":"devtools2.d.ts","names":[],"sources":["../../../src/devtools.ts"],"mappings":";;AAmBA;;;;;AAMA;;;;;iBANgB,YAAA,CAAa,IAAA,UAAc,QAAA;;iBAM3B,cAAA,CAAe,IAAA;;iBAMf,sBAAA,CAAA;AAQhB;AAAA,iBAAgB,eAAA,CAAgB,IAAA;;;;iBA4BhB,eAAA,CAAgB,IAAA,WAAe,MAAA;;iBAa/B,YAAA,CAAa,QAAA;;iBAQb,cAAA,CAAA"}
@@ -1 +1 @@
1
- {"version":3,"file":"index2.d.ts","names":[],"sources":["../../../src/types.ts","../../../src/context.ts","../../../src/create-i18n.ts","../../../src/interpolation.ts","../../../src/pluralization.ts","../../../src/trans.tsx"],"mappings":";;;;;;KAGY,qBAAA;EAAA,CACT,GAAA,oBAAuB,qBAAA;AAAA;AAD1B;AAAA,KAKY,mBAAA,GAAsB,MAAA,SAAe,qBAAA;;;;AAAjD;KAMY,eAAA,IACV,MAAA,UACA,SAAA,aACG,OAAA,CAAQ,qBAAA;;KAGD,mBAAA,GAAsB,MAAA;;KAGtB,WAAA,GAAc,MAAA,UAAgB,KAAA;;UAGzB,WAAA;EATL;EAWV,MAAA;EAZA;EAcA,cAAA;EAbW;EAeX,QAAA,GAAW,MAAA,SAAe,qBAAA;EAfM;AAGlC;;;EAiBE,MAAA,GAAS,eAAA;EAjB6B;AAGxC;;;EAmBE,gBAAA;EAnBqD;AAGvD;;;EAqBE,WAAA,GAAc,WAAA;EAfH;;;;EAoBX,YAAA,IACE,MAAA,UACA,GAAA,UACA,SAAA;AAAA;;UAKa,YAAA;EA5BJ;;;;;;;;;;EAuCX,CAAA,GAAI,GAAA,UAAa,MAAA,GAAS,mBAAA;EAhBN;EAmBpB,MAAA,EAAQ,MAAA;EAdO;;;;EAoBf,aAAA,GAAgB,SAAA,UAAmB,MAAA,cAAoB,OAAA;EAAA;;;EAKvD,SAAA,EAAW,QAAA;EAkBC;;;EAbZ,gBAAA,EAAkB,QAAA,CAAS,GAAA;EAnB3B;;;EAwBA,MAAA,GAAS,GAAA;EArBT;;;;EA2BA,WAAA,GACE,MAAA,UACA,QAAA,EAAU,qBAAA,EACV,SAAA;EAxBqD;;;EA8BvD,gBAAA,EAAkB,QAAA;AAAA;;;cC1GP,WAAA,EAAW,aAAA,CAAA,OAAA,CAAA,YAAA;AAAA,UAEP,iBAAA,SAA0B,KAAA;EACzC,QAAA,EAAU,YAAA;EACV,QAAA,GAAW,UAAA;AAAA;;;;ADAb;;;;;AAMA;;;iBCQgB,YAAA,CAAa,KAAA,EAAO,iBAAA,GAAoB,KAAA;;;;;;;ADFxD;;;;iBCmBgB,OAAA,CAAA,GAAW,YAAA;;;;;;;ADpC3B;;;;;AAKA;;;;;AAMA;;;;;;;;;;AAMA;;;;;AAGA;iBEgEgB,UAAA,CAAW,OAAA,EAAS,WAAA,GAAc,YAAA;;;;;;;AFpFlD;iBGMgB,WAAA,CACd,QAAA,UACA,MAAA,GAAS,mBAAA;;;;;;;AHRX;;iBIKgB,qBAAA,CACd,MAAA,UACA,KAAA,UACA,WAAA,GAAc,WAAA;;;UCNN,QAAA;EACR,GAAA;EACA,QAAA;AAAA;;;;;ALCF;;;iBKSgB,aAAA,CAAc,IAAA,qBAAyB,QAAA;AAAA,UAiBtC,UAAA,SAAmB,KAAA;ELpBxB;EKsBV,OAAA;;EAEA,MAAA,GAAS,mBAAA;ELvBT;;;;;;AAKF;;;;;AAGA;EK4BE,UAAA,GAAa,MAAA,UAAgB,QAAA;;;;ALzB/B;EK8BE,CAAA,GAAI,GAAA,UAAa,MAAA,GAAS,mBAAA;AAAA;;;;;;;;;;;;;;;;;;;;;;;ALI5B;;;;;;;;;iBK8BgB,KAAA,CAAM,KAAA,EAAO,UAAA,GAAa,KAAA"}
1
+ {"version":3,"file":"index2.d.ts","names":[],"sources":["../../../src/types.ts","../../../src/context.ts","../../../src/create-i18n.ts","../../../src/interpolation.ts","../../../src/pluralization.ts","../../../src/trans.tsx"],"mappings":";;;;;;KAGY,qBAAA;EAAA,CACT,GAAA,oBAAuB,qBAAA;AAAA;AAD1B;AAAA,KAKY,mBAAA,GAAsB,MAAA,SAAe,qBAAA;;;;AAAjD;KAMY,eAAA,IACV,MAAA,UACA,SAAA,aACG,OAAA,CAAQ,qBAAA;;KAGD,mBAAA,GAAsB,MAAA;;KAGtB,WAAA,GAAc,MAAA,UAAgB,KAAA;;UAGzB,WAAA;EATL;EAWV,MAAA;EAZA;EAcA,cAAA;EAbW;EAeX,QAAA,GAAW,MAAA,SAAe,qBAAA;EAfM;AAGlC;;;EAiBE,MAAA,GAAS,eAAA;EAjB6B;AAGxC;;;EAmBE,gBAAA;EAnBqD;AAGvD;;;EAqBE,WAAA,GAAc,WAAA;EAfH;;;;EAoBX,YAAA,IAAgB,MAAA,UAAgB,GAAA,UAAa,SAAA;AAAA;;UAI9B,YAAA;EAxBJ;;;;;;;;;;EAmCX,CAAA,GAAI,GAAA,UAAa,MAAA,GAAS,mBAAA;EAfqC;EAkB/D,MAAA,EAAQ,MAAA;EAdO;;;;EAoBf,aAAA,GAAgB,SAAA,UAAmB,MAAA,cAAoB,OAAA;EAAA;;;EAKvD,SAAA,EAAW,QAAA;EAgB6B;;;EAXxC,gBAAA,EAAkB,QAAA,CAAS,GAAA;EAnB3B;;;EAwBA,MAAA,GAAS,GAAA;EArBT;;;;EA2BA,WAAA,GAAc,MAAA,UAAgB,QAAA,EAAU,qBAAA,EAAuB,SAAA;EArBR;;;EA0BvD,gBAAA,EAAkB,QAAA;AAAA;;;cClGP,WAAA,EAAW,aAAA,CAAA,OAAA,CAAA,YAAA;AAAA,UAEP,iBAAA,SAA0B,KAAA;EACzC,QAAA,EAAU,YAAA;EACV,QAAA,GAAW,UAAA;AAAA;;;;ADAb;;;;;AAMA;;;iBCQgB,YAAA,CAAa,KAAA,EAAO,iBAAA,GAAoB,KAAA;;;;;;;ADFxD;;;;iBCmBgB,OAAA,CAAA,GAAW,YAAA;;;;;;;ADpC3B;;;;;AAKA;;;;;AAMA;;;;;;;;;;AAMA;;;;;AAGA;iBEiFgB,UAAA,CAAW,OAAA,EAAS,WAAA,GAAc,YAAA;;;;;;;AFrGlD;iBGMgB,WAAA,CAAY,QAAA,UAAkB,MAAA,GAAS,mBAAA;;;;;;;AHNvD;;iBIKgB,qBAAA,CACd,MAAA,UACA,KAAA,UACA,WAAA,GAAc,WAAA;;;UCNN,QAAA;EACR,GAAA;EACA,QAAA;AAAA;;;;;ALCF;;;iBKSgB,aAAA,CAAc,IAAA,qBAAyB,QAAA;AAAA,UAiBtC,UAAA,SAAmB,KAAA;ELpBxB;EKsBV,OAAA;;EAEA,MAAA,GAAS,mBAAA;ELvBT;;;;;;AAKF;;;;;AAGA;EK4BE,UAAA,GAAa,MAAA,UAAgB,QAAA;;;;ALzB/B;EK8BE,CAAA,GAAI,GAAA,UAAa,MAAA,GAAS,mBAAA;AAAA;;;;;;;;;;;;;;;;;;;;;;;ALA5B;;;;;;;;;iBKkCgB,KAAA,CAAM,KAAA,EAAO,UAAA,GAAa,KAAA"}
package/package.json CHANGED
@@ -1,16 +1,16 @@
1
1
  {
2
2
  "name": "@pyreon/i18n",
3
- "version": "0.9.0",
3
+ "version": "0.11.0",
4
4
  "description": "Reactive internationalization for Pyreon with async namespace loading",
5
5
  "license": "MIT",
6
6
  "repository": {
7
7
  "type": "git",
8
- "url": "https://github.com/pyreon/fundamentals.git",
9
- "directory": "packages/i18n"
8
+ "url": "https://github.com/pyreon/pyreon.git",
9
+ "directory": "packages/fundamentals/i18n"
10
10
  },
11
11
  "homepage": "https://github.com/pyreon/fundamentals/tree/main/packages/i18n#readme",
12
12
  "bugs": {
13
- "url": "https://github.com/pyreon/fundamentals/issues"
13
+ "url": "https://github.com/pyreon/pyreon/issues"
14
14
  },
15
15
  "publishConfig": {
16
16
  "access": "public"
@@ -32,6 +32,11 @@
32
32
  "import": "./lib/index.js",
33
33
  "types": "./lib/types/index.d.ts"
34
34
  },
35
+ "./core": {
36
+ "bun": "./src/core.ts",
37
+ "import": "./lib/core.js",
38
+ "types": "./lib/types/core.d.ts"
39
+ },
35
40
  "./devtools": {
36
41
  "bun": "./src/devtools.ts",
37
42
  "import": "./lib/devtools.js",
@@ -42,10 +47,17 @@
42
47
  "build": "vl_rolldown_build",
43
48
  "dev": "vl_rolldown_build-watch",
44
49
  "test": "vitest run",
45
- "typecheck": "tsc --noEmit"
50
+ "typecheck": "tsc --noEmit",
51
+ "lint": "biome check ."
46
52
  },
47
53
  "peerDependencies": {
48
- "@pyreon/core": ">=0.7.0 <0.8.0",
49
- "@pyreon/reactivity": ">=0.7.0 <0.8.0"
54
+ "@pyreon/core": "^0.11.0",
55
+ "@pyreon/reactivity": "^0.11.0"
56
+ },
57
+ "devDependencies": {
58
+ "@happy-dom/global-registrator": "^20.8.3",
59
+ "@pyreon/core": "^0.11.0",
60
+ "@pyreon/reactivity": "^0.11.0",
61
+ "@pyreon/runtime-dom": "^0.11.0"
50
62
  }
51
63
  }
package/src/context.ts CHANGED
@@ -1,6 +1,6 @@
1
- import type { Props, VNode, VNodeChild } from '@pyreon/core'
2
- import { createContext, provide, useContext } from '@pyreon/core'
3
- import type { I18nInstance } from './types'
1
+ import type { Props, VNode, VNodeChild } from "@pyreon/core"
2
+ import { createContext, provide, useContext } from "@pyreon/core"
3
+ import type { I18nInstance } from "./types"
4
4
 
5
5
  export const I18nContext = createContext<I18nInstance | null>(null)
6
6
 
@@ -24,7 +24,7 @@ export function I18nProvider(props: I18nProviderProps): VNode {
24
24
  provide(I18nContext, props.instance)
25
25
 
26
26
  const ch = props.children
27
- return (typeof ch === 'function' ? (ch as () => VNodeChild)() : ch) as VNode
27
+ return (typeof ch === "function" ? (ch as () => VNodeChild)() : ch) as VNode
28
28
  }
29
29
 
30
30
  /**
@@ -40,9 +40,7 @@ export function I18nProvider(props: I18nProviderProps): VNode {
40
40
  export function useI18n(): I18nInstance {
41
41
  const instance = useContext(I18nContext)
42
42
  if (!instance) {
43
- throw new Error(
44
- '[@pyreon/i18n] useI18n() must be used within an <I18nProvider>.',
45
- )
43
+ throw new Error("[@pyreon/i18n] useI18n() must be used within an <I18nProvider>.")
46
44
  }
47
45
  return instance
48
46
  }
package/src/core.ts ADDED
@@ -0,0 +1,22 @@
1
+ /**
2
+ * Framework-agnostic i18n core — no @pyreon/core dependency.
3
+ * Use this entry point for backend/server usage where you only need
4
+ * createI18n() and translation utilities.
5
+ *
6
+ * @example
7
+ * ```ts
8
+ * import { createI18n, interpolate } from "@pyreon/i18n/core"
9
+ * ```
10
+ */
11
+ export { createI18n } from "./create-i18n"
12
+ export { interpolate } from "./interpolation"
13
+ export { resolvePluralCategory } from "./pluralization"
14
+ export type {
15
+ I18nInstance,
16
+ I18nOptions,
17
+ InterpolationValues,
18
+ NamespaceLoader,
19
+ PluralRules,
20
+ TranslationDictionary,
21
+ TranslationMessages,
22
+ } from "./types"
@@ -1,54 +1,71 @@
1
- import { computed, signal } from '@pyreon/reactivity'
2
- import { interpolate } from './interpolation'
3
- import { resolvePluralCategory } from './pluralization'
4
- import type {
5
- I18nInstance,
6
- I18nOptions,
7
- InterpolationValues,
8
- TranslationDictionary,
9
- } from './types'
1
+ import { computed, signal } from "@pyreon/reactivity"
2
+ import { interpolate } from "./interpolation"
3
+ import { resolvePluralCategory } from "./pluralization"
4
+ import type { I18nInstance, I18nOptions, InterpolationValues, TranslationDictionary } from "./types"
10
5
 
11
6
  /**
12
7
  * Resolve a dot-separated key path in a nested dictionary.
13
8
  * E.g. "user.greeting" → dictionary.user.greeting
14
9
  */
15
- function resolveKey(
16
- dict: TranslationDictionary,
17
- keyPath: string,
18
- ): string | undefined {
19
- const parts = keyPath.split('.')
10
+ function resolveKey(dict: TranslationDictionary, keyPath: string): string | undefined {
11
+ const parts = keyPath.split(".")
20
12
  let current: TranslationDictionary | string = dict
21
13
 
22
14
  for (const part of parts) {
23
- if (current == null || typeof current === 'string') return undefined
15
+ if (current == null || typeof current === "string") return undefined
24
16
  current = current[part] as TranslationDictionary | string
25
17
  }
26
18
 
27
- return typeof current === 'string' ? current : undefined
19
+ return typeof current === "string" ? current : undefined
20
+ }
21
+
22
+ /**
23
+ * Convert flat dotted keys into nested objects.
24
+ * `{ 'section.title': 'Report' }` → `{ section: { title: 'Report' } }`
25
+ * Keys that don't contain dots are passed through as-is.
26
+ * Already-nested objects are preserved — only string values with dotted keys are expanded.
27
+ */
28
+ function nestFlatKeys(messages: TranslationDictionary): TranslationDictionary {
29
+ const result: TranslationDictionary = {}
30
+ let hasFlatKeys = false
31
+
32
+ for (const key of Object.keys(messages)) {
33
+ const value = messages[key]
34
+ if (key.includes(".") && typeof value === "string") {
35
+ hasFlatKeys = true
36
+ const parts = key.split(".")
37
+ let current: TranslationDictionary = result
38
+ for (let i = 0; i < parts.length - 1; i++) {
39
+ const part = parts[i] as string
40
+ if (!(part in current) || typeof current[part] !== "object") {
41
+ current[part] = {}
42
+ }
43
+ current = current[part] as TranslationDictionary
44
+ }
45
+ current[parts[parts.length - 1] as string] = value
46
+ } else if (value !== undefined) {
47
+ result[key] = value
48
+ }
49
+ }
50
+
51
+ return hasFlatKeys ? result : messages
28
52
  }
29
53
 
30
54
  /**
31
55
  * Deep-merge source into target (mutates target).
32
56
  */
33
- function deepMerge(
34
- target: TranslationDictionary,
35
- source: TranslationDictionary,
36
- ): void {
57
+ function deepMerge(target: TranslationDictionary, source: TranslationDictionary): void {
37
58
  for (const key of Object.keys(source)) {
38
- if (key === '__proto__' || key === 'constructor' || key === 'prototype')
39
- continue
59
+ if (key === "__proto__" || key === "constructor" || key === "prototype") continue
40
60
  const sourceVal = source[key]
41
61
  const targetVal = target[key]
42
62
  if (
43
- typeof sourceVal === 'object' &&
63
+ typeof sourceVal === "object" &&
44
64
  sourceVal !== null &&
45
- typeof targetVal === 'object' &&
65
+ typeof targetVal === "object" &&
46
66
  targetVal !== null
47
67
  ) {
48
- deepMerge(
49
- targetVal as TranslationDictionary,
50
- sourceVal as TranslationDictionary,
51
- )
68
+ deepMerge(targetVal as TranslationDictionary, sourceVal as TranslationDictionary)
52
69
  } else {
53
70
  target[key] = sourceVal!
54
71
  }
@@ -86,13 +103,7 @@ function deepMerge(
86
103
  * i18n.t('auth:errors.invalid') // looks up "errors.invalid" in "auth" namespace
87
104
  */
88
105
  export function createI18n(options: I18nOptions): I18nInstance {
89
- const {
90
- fallbackLocale,
91
- loader,
92
- defaultNamespace = 'common',
93
- pluralRules,
94
- onMissingKey,
95
- } = options
106
+ const { fallbackLocale, loader, defaultNamespace = "common", pluralRules, onMissingKey } = options
96
107
 
97
108
  // ── Reactive state ──────────────────────────────────────────────────
98
109
 
@@ -144,11 +155,7 @@ export function createI18n(options: I18nOptions): I18nInstance {
144
155
  return nsMap
145
156
  }
146
157
 
147
- function lookupKey(
148
- loc: string,
149
- namespace: string,
150
- keyPath: string,
151
- ): string | undefined {
158
+ function lookupKey(loc: string, namespace: string, keyPath: string): string | undefined {
152
159
  const nsMap = store.get(loc)
153
160
  if (!nsMap) return undefined
154
161
  const dict = nsMap.get(namespace)
@@ -156,10 +163,7 @@ export function createI18n(options: I18nOptions): I18nInstance {
156
163
  return resolveKey(dict, keyPath)
157
164
  }
158
165
 
159
- function resolveTranslation(
160
- key: string,
161
- values?: InterpolationValues,
162
- ): string {
166
+ function resolveTranslation(key: string, values?: InterpolationValues): string {
163
167
  // Subscribe to reactive dependencies
164
168
  const currentLocale = locale()
165
169
  storeVersion()
@@ -168,14 +172,14 @@ export function createI18n(options: I18nOptions): I18nInstance {
168
172
  let namespace = defaultNamespace
169
173
  let keyPath = key
170
174
 
171
- const colonIndex = key.indexOf(':')
175
+ const colonIndex = key.indexOf(":")
172
176
  if (colonIndex > 0) {
173
177
  namespace = key.slice(0, colonIndex)
174
178
  keyPath = key.slice(colonIndex + 1)
175
179
  }
176
180
 
177
181
  // Handle pluralization: if values contain `count`, try plural suffixes
178
- if (values && 'count' in values) {
182
+ if (values && "count" in values) {
179
183
  const count = Number(values.count)
180
184
  const category = resolvePluralCategory(currentLocale, count, pluralRules)
181
185
 
@@ -183,9 +187,7 @@ export function createI18n(options: I18nOptions): I18nInstance {
183
187
  const pluralKey = `${keyPath}_${category}`
184
188
  const pluralResult =
185
189
  lookupKey(currentLocale, namespace, pluralKey) ??
186
- (fallbackLocale
187
- ? lookupKey(fallbackLocale, namespace, pluralKey)
188
- : undefined)
190
+ (fallbackLocale ? lookupKey(fallbackLocale, namespace, pluralKey) : undefined)
189
191
 
190
192
  if (pluralResult) {
191
193
  return interpolate(pluralResult, values)
@@ -195,9 +197,7 @@ export function createI18n(options: I18nOptions): I18nInstance {
195
197
  // Standard lookup: current locale → fallback locale
196
198
  const result =
197
199
  lookupKey(currentLocale, namespace, keyPath) ??
198
- (fallbackLocale
199
- ? lookupKey(fallbackLocale, namespace, keyPath)
200
- : undefined)
200
+ (fallbackLocale ? lookupKey(fallbackLocale, namespace, keyPath) : undefined)
201
201
 
202
202
  if (result !== undefined) {
203
203
  return interpolate(result, values)
@@ -219,10 +219,7 @@ export function createI18n(options: I18nOptions): I18nInstance {
219
219
  return resolveTranslation(key, values)
220
220
  }
221
221
 
222
- const loadNamespace = async (
223
- namespace: string,
224
- loc?: string,
225
- ): Promise<void> => {
222
+ const loadNamespace = async (namespace: string, loc?: string): Promise<void> => {
226
223
  if (!loader) return
227
224
 
228
225
  const targetLocale = loc ?? locale.peek()
@@ -260,7 +257,7 @@ export function createI18n(options: I18nOptions): I18nInstance {
260
257
 
261
258
  let namespace = defaultNamespace
262
259
  let keyPath = key
263
- const colonIndex = key.indexOf(':')
260
+ const colonIndex = key.indexOf(":")
264
261
  if (colonIndex > 0) {
265
262
  namespace = key.slice(0, colonIndex)
266
263
  keyPath = key.slice(colonIndex + 1)
@@ -268,27 +265,22 @@ export function createI18n(options: I18nOptions): I18nInstance {
268
265
 
269
266
  return (
270
267
  lookupKey(currentLocale, namespace, keyPath) !== undefined ||
271
- (fallbackLocale
272
- ? lookupKey(fallbackLocale, namespace, keyPath) !== undefined
273
- : false)
268
+ (fallbackLocale ? lookupKey(fallbackLocale, namespace, keyPath) !== undefined : false)
274
269
  )
275
270
  }
276
271
 
277
- const addMessages = (
278
- loc: string,
279
- messages: TranslationDictionary,
280
- namespace?: string,
281
- ): void => {
272
+ const addMessages = (loc: string, messages: TranslationDictionary, namespace?: string): void => {
282
273
  const ns = namespace ?? defaultNamespace
283
274
  const nsMap = getNamespaceMap(loc)
275
+ const nested = nestFlatKeys(messages)
284
276
  const existing = nsMap.get(ns)
285
277
 
286
278
  if (existing) {
287
- deepMerge(existing, messages)
279
+ deepMerge(existing, nested)
288
280
  } else {
289
281
  // Deep-clone to prevent external mutation from corrupting the store
290
282
  const cloned: TranslationDictionary = {}
291
- deepMerge(cloned, messages)
283
+ deepMerge(cloned, nested)
292
284
  nsMap.set(ns, cloned)
293
285
  }
294
286
 
package/src/devtools.ts CHANGED
@@ -56,7 +56,7 @@ function safeRead(
56
56
  ): unknown {
57
57
  try {
58
58
  const val = obj[key]
59
- return typeof val === 'function' ? (val as () => unknown)() : fallback
59
+ return typeof val === "function" ? (val as () => unknown)() : fallback
60
60
  } catch {
61
61
  return fallback
62
62
  }
@@ -65,17 +65,15 @@ function safeRead(
65
65
  /**
66
66
  * Get a snapshot of an i18n instance's state.
67
67
  */
68
- export function getI18nSnapshot(
69
- name: string,
70
- ): Record<string, unknown> | undefined {
68
+ export function getI18nSnapshot(name: string): Record<string, unknown> | undefined {
71
69
  const instance = getI18nInstance(name) as Record<string, unknown> | undefined
72
70
  if (!instance) return undefined
73
- const ns = safeRead(instance, 'loadedNamespaces', new Set())
71
+ const ns = safeRead(instance, "loadedNamespaces", new Set())
74
72
  return {
75
- locale: safeRead(instance, 'locale'),
76
- availableLocales: safeRead(instance, 'availableLocales', []),
73
+ locale: safeRead(instance, "locale"),
74
+ availableLocales: safeRead(instance, "availableLocales", []),
77
75
  loadedNamespaces: ns instanceof Set ? [...ns] : [],
78
- isLoading: safeRead(instance, 'isLoading', false),
76
+ isLoading: safeRead(instance, "isLoading", false),
79
77
  }
80
78
  }
81
79
 
package/src/index.ts CHANGED
@@ -1,10 +1,10 @@
1
- export type { I18nProviderProps } from './context'
2
- export { I18nContext, I18nProvider, useI18n } from './context'
3
- export { createI18n } from './create-i18n'
4
- export { interpolate } from './interpolation'
5
- export { resolvePluralCategory } from './pluralization'
6
- export type { TransProps } from './trans'
7
- export { parseRichText, Trans } from './trans'
1
+ export type { I18nProviderProps } from "./context"
2
+ export { I18nContext, I18nProvider, useI18n } from "./context"
3
+ export { createI18n } from "./create-i18n"
4
+ export { interpolate } from "./interpolation"
5
+ export { resolvePluralCategory } from "./pluralization"
6
+ export type { TransProps } from "./trans"
7
+ export { parseRichText, Trans } from "./trans"
8
8
  export type {
9
9
  I18nInstance,
10
10
  I18nOptions,
@@ -13,4 +13,4 @@ export type {
13
13
  PluralRules,
14
14
  TranslationDictionary,
15
15
  TranslationMessages,
16
- } from './types'
16
+ } from "./types"
@@ -1,4 +1,4 @@
1
- import type { InterpolationValues } from './types'
1
+ import type { InterpolationValues } from "./types"
2
2
 
3
3
  const INTERPOLATION_RE = /\{\{(\s*\w+\s*)\}\}/g
4
4
 
@@ -7,20 +7,15 @@ const INTERPOLATION_RE = /\{\{(\s*\w+\s*)\}\}/g
7
7
  * Supports optional whitespace inside braces: `{{ name }}` works too.
8
8
  * Unmatched placeholders are left as-is.
9
9
  */
10
- export function interpolate(
11
- template: string,
12
- values?: InterpolationValues,
13
- ): string {
14
- if (!values || !template.includes('{{')) return template
10
+ export function interpolate(template: string, values?: InterpolationValues): string {
11
+ if (!values || !template.includes("{{")) return template
15
12
  return template.replace(INTERPOLATION_RE, (_, key: string) => {
16
13
  const trimmed = key.trim()
17
14
  const value = values[trimmed]
18
15
  if (value === undefined) return `{{${trimmed}}}`
19
16
  // Safely coerce — guard against malicious toString/valueOf
20
17
  try {
21
- return typeof value === 'object' && value !== null
22
- ? JSON.stringify(value)
23
- : `${value}`
18
+ return typeof value === "object" && value !== null ? JSON.stringify(value) : `${value}`
24
19
  } catch {
25
20
  return `{{${trimmed}}}`
26
21
  }
@@ -1,4 +1,4 @@
1
- import type { PluralRules } from './types'
1
+ import type { PluralRules } from "./types"
2
2
 
3
3
  /**
4
4
  * Resolve the plural category for a given count and locale.
@@ -17,7 +17,7 @@ export function resolvePluralCategory(
17
17
  }
18
18
 
19
19
  // Use Intl.PluralRules if available
20
- if (typeof Intl !== 'undefined' && Intl.PluralRules) {
20
+ if (typeof Intl !== "undefined" && Intl.PluralRules) {
21
21
  try {
22
22
  const pr = new Intl.PluralRules(locale)
23
23
  return pr.select(count)
@@ -27,5 +27,5 @@ export function resolvePluralCategory(
27
27
  }
28
28
 
29
29
  // Basic fallback
30
- return count === 1 ? 'one' : 'other'
30
+ return count === 1 ? "one" : "other"
31
31
  }