@pyreon/i18n 0.10.0 → 0.11.1
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/lib/analysis/core.js.html +5406 -0
- package/lib/analysis/index.js.html +1 -1
- package/lib/core.js +249 -0
- package/lib/core.js.map +1 -0
- package/lib/devtools.js.map +1 -1
- package/lib/index.js +29 -53
- package/lib/index.js.map +1 -1
- package/lib/types/core.d.ts +142 -0
- package/lib/types/core.d.ts.map +1 -0
- package/lib/types/devtools.d.ts.map +1 -1
- package/lib/types/index.d.ts.map +1 -1
- package/package.json +19 -7
- package/src/context.ts +5 -7
- package/src/core.ts +22 -0
- package/src/create-i18n.ts +59 -67
- package/src/devtools.ts +6 -8
- package/src/index.ts +8 -8
- package/src/interpolation.ts +4 -9
- package/src/pluralization.ts +3 -3
- package/src/tests/devtools.test.ts +57 -59
- package/src/tests/i18n.test.tsx +356 -342
- package/src/tests/setup.ts +1 -1
- package/src/trans.tsx +4 -4
- package/src/types.ts +3 -11
|
@@ -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,
|
|
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"}
|
package/lib/types/index.d.ts.map
CHANGED
|
@@ -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,
|
|
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.
|
|
3
|
+
"version": "0.11.1",
|
|
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/
|
|
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/
|
|
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": "
|
|
49
|
-
"@pyreon/reactivity": "
|
|
54
|
+
"@pyreon/core": "^0.11.1",
|
|
55
|
+
"@pyreon/reactivity": "^0.11.1"
|
|
56
|
+
},
|
|
57
|
+
"devDependencies": {
|
|
58
|
+
"@happy-dom/global-registrator": "^20.8.3",
|
|
59
|
+
"@pyreon/core": "^0.11.1",
|
|
60
|
+
"@pyreon/reactivity": "^0.11.1",
|
|
61
|
+
"@pyreon/runtime-dom": "^0.11.1"
|
|
50
62
|
}
|
|
51
63
|
}
|
package/src/context.ts
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
|
-
import type { Props, VNode, VNodeChild } from
|
|
2
|
-
import { createContext, provide, useContext } from
|
|
3
|
-
import type { I18nInstance } from
|
|
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 ===
|
|
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"
|
package/src/create-i18n.ts
CHANGED
|
@@ -1,54 +1,71 @@
|
|
|
1
|
-
import { computed, signal } from
|
|
2
|
-
import { interpolate } from
|
|
3
|
-
import { resolvePluralCategory } from
|
|
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
|
-
|
|
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 ===
|
|
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 ===
|
|
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 ===
|
|
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 ===
|
|
63
|
+
typeof sourceVal === "object" &&
|
|
44
64
|
sourceVal !== null &&
|
|
45
|
-
typeof targetVal ===
|
|
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 &&
|
|
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,
|
|
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,
|
|
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 ===
|
|
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,
|
|
71
|
+
const ns = safeRead(instance, "loadedNamespaces", new Set())
|
|
74
72
|
return {
|
|
75
|
-
locale: safeRead(instance,
|
|
76
|
-
availableLocales: safeRead(instance,
|
|
73
|
+
locale: safeRead(instance, "locale"),
|
|
74
|
+
availableLocales: safeRead(instance, "availableLocales", []),
|
|
77
75
|
loadedNamespaces: ns instanceof Set ? [...ns] : [],
|
|
78
|
-
isLoading: safeRead(instance,
|
|
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
|
|
2
|
-
export { I18nContext, I18nProvider, useI18n } from
|
|
3
|
-
export { createI18n } from
|
|
4
|
-
export { interpolate } from
|
|
5
|
-
export { resolvePluralCategory } from
|
|
6
|
-
export type { TransProps } from
|
|
7
|
-
export { parseRichText, Trans } from
|
|
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
|
|
16
|
+
} from "./types"
|
package/src/interpolation.ts
CHANGED
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import type { InterpolationValues } from
|
|
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
|
|
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 ===
|
|
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
|
}
|
package/src/pluralization.ts
CHANGED
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import type { PluralRules } from
|
|
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 !==
|
|
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 ?
|
|
30
|
+
return count === 1 ? "one" : "other"
|
|
31
31
|
}
|