@object-ui/i18n 3.1.5 → 3.3.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/CHANGELOG.md +6 -0
- package/README.md +21 -1
- package/dist/i18n.js +10 -1
- package/dist/index.d.ts +1 -0
- package/dist/index.js +2 -0
- package/dist/locales/ar.d.ts +260 -1
- package/dist/locales/ar.js +260 -1
- package/dist/locales/de.d.ts +260 -1
- package/dist/locales/de.js +269 -1
- package/dist/locales/en.d.ts +279 -0
- package/dist/locales/en.js +289 -0
- package/dist/locales/es.d.ts +260 -1
- package/dist/locales/es.js +260 -1
- package/dist/locales/fr.d.ts +260 -1
- package/dist/locales/fr.js +269 -1
- package/dist/locales/index.d.ts +2661 -9
- package/dist/locales/ja.d.ts +271 -1
- package/dist/locales/ja.js +271 -1
- package/dist/locales/ko.d.ts +260 -1
- package/dist/locales/ko.js +260 -1
- package/dist/locales/pt.d.ts +260 -1
- package/dist/locales/pt.js +260 -1
- package/dist/locales/ru.d.ts +271 -1
- package/dist/locales/ru.js +280 -1
- package/dist/locales/zh.d.ts +280 -1
- package/dist/locales/zh.js +290 -1
- package/dist/useObjectLabel.d.ts +46 -0
- package/dist/useObjectLabel.js +79 -12
- package/dist/useSafeTranslation.d.ts +5 -0
- package/dist/useSafeTranslation.js +46 -0
- package/package.json +31 -8
- package/.turbo/turbo-build.log +0 -4
- package/src/__tests__/i18n.test.ts +0 -228
- package/src/__tests__/provider.test.tsx +0 -117
- package/src/__tests__/spec-formatters.test.ts +0 -208
- package/src/__tests__/useObjectLabel.test.tsx +0 -131
- package/src/i18n.ts +0 -98
- package/src/index.ts +0 -78
- package/src/locales/ar.ts +0 -541
- package/src/locales/de.ts +0 -545
- package/src/locales/en.ts +0 -625
- package/src/locales/es.ts +0 -540
- package/src/locales/fr.ts +0 -545
- package/src/locales/index.ts +0 -42
- package/src/locales/ja.ts +0 -540
- package/src/locales/ko.ts +0 -540
- package/src/locales/pt.ts +0 -540
- package/src/locales/ru.ts +0 -540
- package/src/locales/zh.ts +0 -624
- package/src/provider.tsx +0 -177
- package/src/useObjectLabel.ts +0 -122
- package/src/utils/formatting.ts +0 -136
- package/src/utils/index.ts +0 -21
- package/src/utils/spec-formatters.ts +0 -220
- package/tsconfig.json +0 -12
- package/tsconfig.tsbuildinfo +0 -1
package/dist/useObjectLabel.d.ts
CHANGED
|
@@ -50,6 +50,43 @@ export declare function useObjectLabel(): {
|
|
|
50
50
|
* Resolve translated field label, falling back to the provided fallback string.
|
|
51
51
|
*/
|
|
52
52
|
fieldLabel: (objectName: string, fieldName: string, fallback: string) => string;
|
|
53
|
+
/**
|
|
54
|
+
* Resolve a translated select option label for a given object field.
|
|
55
|
+
* Falls back to the provided fallback (usually the English option label).
|
|
56
|
+
*/
|
|
57
|
+
fieldOptionLabel: (objectName: string, fieldName: string, optionValue: string, fallback: string) => string;
|
|
58
|
+
/**
|
|
59
|
+
* Translate all options for a given field, returning a new options array
|
|
60
|
+
* with translated labels. Pass the objectName and fieldName to look up
|
|
61
|
+
* translations; the original label is used as fallback.
|
|
62
|
+
*/
|
|
63
|
+
translateOptions: (objectName: string, fieldName: string, options: Array<{
|
|
64
|
+
value: string;
|
|
65
|
+
label: string;
|
|
66
|
+
[key: string]: any;
|
|
67
|
+
}>) => Array<{
|
|
68
|
+
value: string;
|
|
69
|
+
label: string;
|
|
70
|
+
[key: string]: any;
|
|
71
|
+
}>;
|
|
72
|
+
/**
|
|
73
|
+
* Resolve translated app label, falling back to appDef.label.
|
|
74
|
+
* Looks up `{ns}.apps.{appName}.label` from loaded i18next resources.
|
|
75
|
+
*/
|
|
76
|
+
appLabel: (appDef: {
|
|
77
|
+
name: string;
|
|
78
|
+
label?: string;
|
|
79
|
+
}) => string;
|
|
80
|
+
/**
|
|
81
|
+
* Resolve translated app description, falling back to appDef.description.
|
|
82
|
+
* Returns the translated value even when metadata has no description —
|
|
83
|
+
* translation-only descriptions (defined only in i18n bundles) are common
|
|
84
|
+
* in examples where the app metadata is English-only.
|
|
85
|
+
*/
|
|
86
|
+
appDescription: (appDef: {
|
|
87
|
+
name: string;
|
|
88
|
+
description?: string;
|
|
89
|
+
}) => string | undefined;
|
|
53
90
|
};
|
|
54
91
|
/**
|
|
55
92
|
* Safe wrapper for useObjectLabel that falls back to identity functions
|
|
@@ -58,4 +95,13 @@ export declare function useObjectLabel(): {
|
|
|
58
95
|
*/
|
|
59
96
|
export declare function useSafeFieldLabel(): {
|
|
60
97
|
fieldLabel: (objectName: string, fieldName: string, fallback: string) => string;
|
|
98
|
+
translateOptions: (objectName: string, fieldName: string, options: Array<{
|
|
99
|
+
value: string;
|
|
100
|
+
label: string;
|
|
101
|
+
[key: string]: any;
|
|
102
|
+
}>) => Array<{
|
|
103
|
+
value: string;
|
|
104
|
+
label: string;
|
|
105
|
+
[key: string]: any;
|
|
106
|
+
}>;
|
|
61
107
|
};
|
package/dist/useObjectLabel.js
CHANGED
|
@@ -48,7 +48,8 @@ export function useObjectLabel() {
|
|
|
48
48
|
/**
|
|
49
49
|
* Discover app namespace(s) from loaded i18next resources.
|
|
50
50
|
* Returns top-level keys (outside built-in Object UI keys) that contain
|
|
51
|
-
* an `objects` or `
|
|
51
|
+
* an `objects`, `fields`, or `apps` sub-key — e.g. "crm" when resources
|
|
52
|
+
* include crm.objects.* or crm.apps.*.
|
|
52
53
|
*/
|
|
53
54
|
const getAppNamespaces = () => {
|
|
54
55
|
if (!i18n || typeof i18n.getResourceBundle !== 'function')
|
|
@@ -58,17 +59,30 @@ export function useObjectLabel() {
|
|
|
58
59
|
if (!bundle)
|
|
59
60
|
return [];
|
|
60
61
|
return Object.keys(bundle).filter((key) => !BUILTIN_KEYS.has(key) && bundle[key] && typeof bundle[key] === 'object'
|
|
61
|
-
&& (bundle[key].objects || bundle[key].fields));
|
|
62
|
+
&& (bundle[key].objects || bundle[key].fields || bundle[key].apps));
|
|
63
|
+
};
|
|
64
|
+
/**
|
|
65
|
+
* Strip an ObjectStack namespace prefix (e.g. `crm__lead` → `lead`) so that
|
|
66
|
+
* translations authored against short object names still resolve when the
|
|
67
|
+
* runtime presents fully-qualified names. The first `__` separates the
|
|
68
|
+
* package namespace from the base name; everything after is preserved.
|
|
69
|
+
*/
|
|
70
|
+
const stripNamespace = (name) => {
|
|
71
|
+
const idx = name.indexOf('__');
|
|
72
|
+
return idx > 0 ? name.slice(idx + 2) : name;
|
|
62
73
|
};
|
|
63
74
|
/** Try resolving a key across all discovered app namespaces. */
|
|
64
|
-
const resolve = (
|
|
75
|
+
const resolve = (suffixes, fallback) => {
|
|
76
|
+
const suffixList = Array.isArray(suffixes) ? suffixes : [suffixes];
|
|
65
77
|
try {
|
|
66
78
|
const namespaces = getAppNamespaces();
|
|
67
79
|
for (const ns of namespaces) {
|
|
68
|
-
const
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
80
|
+
for (const suffix of suffixList) {
|
|
81
|
+
const key = `${ns}.${suffix}`;
|
|
82
|
+
const translated = t(key, { defaultValue: '' });
|
|
83
|
+
if (translated && translated !== key && translated !== '') {
|
|
84
|
+
return translated;
|
|
85
|
+
}
|
|
72
86
|
}
|
|
73
87
|
}
|
|
74
88
|
}
|
|
@@ -77,23 +91,75 @@ export function useObjectLabel() {
|
|
|
77
91
|
}
|
|
78
92
|
return fallback;
|
|
79
93
|
};
|
|
94
|
+
/** Build suffix candidates: prefer the given name, fall back to the base (unprefixed) name. */
|
|
95
|
+
const objectSuffixes = (objectName, tail) => {
|
|
96
|
+
const base = stripNamespace(objectName);
|
|
97
|
+
return base !== objectName
|
|
98
|
+
? [`objects.${objectName}.${tail}`, `objects.${base}.${tail}`]
|
|
99
|
+
: [`objects.${objectName}.${tail}`];
|
|
100
|
+
};
|
|
101
|
+
const fieldSuffixes = (objectName, fieldName) => {
|
|
102
|
+
const base = stripNamespace(objectName);
|
|
103
|
+
return base !== objectName
|
|
104
|
+
? [`fields.${objectName}.${fieldName}`, `fields.${base}.${fieldName}`]
|
|
105
|
+
: [`fields.${objectName}.${fieldName}`];
|
|
106
|
+
};
|
|
107
|
+
const optionSuffixes = (objectName, fieldName, optionValue) => {
|
|
108
|
+
const base = stripNamespace(objectName);
|
|
109
|
+
return base !== objectName
|
|
110
|
+
? [
|
|
111
|
+
`fieldOptions.${objectName}.${fieldName}.${optionValue}`,
|
|
112
|
+
`fieldOptions.${base}.${fieldName}.${optionValue}`,
|
|
113
|
+
]
|
|
114
|
+
: [`fieldOptions.${objectName}.${fieldName}.${optionValue}`];
|
|
115
|
+
};
|
|
80
116
|
return {
|
|
81
117
|
/**
|
|
82
118
|
* Resolve translated object label, falling back to objectDef.label.
|
|
83
119
|
*/
|
|
84
|
-
objectLabel: (objectDef) => resolve(
|
|
120
|
+
objectLabel: (objectDef) => resolve(objectSuffixes(objectDef.name, 'label'), objectDef.label),
|
|
85
121
|
/**
|
|
86
122
|
* Resolve translated object description, falling back to objectDef.description.
|
|
87
123
|
*/
|
|
88
124
|
objectDescription: (objectDef) => {
|
|
89
125
|
if (!objectDef.description)
|
|
90
126
|
return undefined;
|
|
91
|
-
return resolve(
|
|
127
|
+
return resolve(objectSuffixes(objectDef.name, 'description'), objectDef.description);
|
|
92
128
|
},
|
|
93
129
|
/**
|
|
94
130
|
* Resolve translated field label, falling back to the provided fallback string.
|
|
95
131
|
*/
|
|
96
|
-
fieldLabel: (objectName, fieldName, fallback) => resolve(
|
|
132
|
+
fieldLabel: (objectName, fieldName, fallback) => resolve(fieldSuffixes(objectName, fieldName), fallback),
|
|
133
|
+
/**
|
|
134
|
+
* Resolve a translated select option label for a given object field.
|
|
135
|
+
* Falls back to the provided fallback (usually the English option label).
|
|
136
|
+
*/
|
|
137
|
+
fieldOptionLabel: (objectName, fieldName, optionValue, fallback) => resolve(optionSuffixes(objectName, fieldName, optionValue), fallback),
|
|
138
|
+
/**
|
|
139
|
+
* Translate all options for a given field, returning a new options array
|
|
140
|
+
* with translated labels. Pass the objectName and fieldName to look up
|
|
141
|
+
* translations; the original label is used as fallback.
|
|
142
|
+
*/
|
|
143
|
+
translateOptions: (objectName, fieldName, options) => options.map(opt => ({
|
|
144
|
+
...opt,
|
|
145
|
+
label: resolve(optionSuffixes(objectName, fieldName, opt.value), opt.label),
|
|
146
|
+
})),
|
|
147
|
+
/**
|
|
148
|
+
* Resolve translated app label, falling back to appDef.label.
|
|
149
|
+
* Looks up `{ns}.apps.{appName}.label` from loaded i18next resources.
|
|
150
|
+
*/
|
|
151
|
+
appLabel: (appDef) => resolve(`apps.${appDef.name}.label`, appDef.label ?? appDef.name),
|
|
152
|
+
/**
|
|
153
|
+
* Resolve translated app description, falling back to appDef.description.
|
|
154
|
+
* Returns the translated value even when metadata has no description —
|
|
155
|
+
* translation-only descriptions (defined only in i18n bundles) are common
|
|
156
|
+
* in examples where the app metadata is English-only.
|
|
157
|
+
*/
|
|
158
|
+
appDescription: (appDef) => {
|
|
159
|
+
const fallback = appDef.description ?? '';
|
|
160
|
+
const resolved = resolve(`apps.${appDef.name}.description`, fallback);
|
|
161
|
+
return resolved || undefined;
|
|
162
|
+
},
|
|
97
163
|
};
|
|
98
164
|
}
|
|
99
165
|
/**
|
|
@@ -103,12 +169,13 @@ export function useObjectLabel() {
|
|
|
103
169
|
*/
|
|
104
170
|
export function useSafeFieldLabel() {
|
|
105
171
|
try {
|
|
106
|
-
const { fieldLabel } = useObjectLabel();
|
|
107
|
-
return { fieldLabel };
|
|
172
|
+
const { fieldLabel, translateOptions } = useObjectLabel();
|
|
173
|
+
return { fieldLabel, translateOptions };
|
|
108
174
|
}
|
|
109
175
|
catch {
|
|
110
176
|
return {
|
|
111
177
|
fieldLabel: (_objectName, _fieldName, fallback) => fallback,
|
|
178
|
+
translateOptions: (_objectName, _fieldName, options) => options,
|
|
112
179
|
};
|
|
113
180
|
}
|
|
114
181
|
}
|
|
@@ -0,0 +1,46 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Safe translation hook with fallback to default strings.
|
|
3
|
+
*
|
|
4
|
+
* When no I18nProvider is available (e.g., in tests or standalone usage),
|
|
5
|
+
* this hook falls back to the provided default translations instead of
|
|
6
|
+
* returning raw i18n keys.
|
|
7
|
+
*
|
|
8
|
+
* @param defaults - Fallback English translations keyed by i18n key
|
|
9
|
+
* @param testKey - A key to test if i18n is properly configured (must be in defaults)
|
|
10
|
+
*/
|
|
11
|
+
import { useObjectTranslation } from './provider';
|
|
12
|
+
export function createSafeTranslation(defaults, testKey) {
|
|
13
|
+
return function useSafeTranslation() {
|
|
14
|
+
try {
|
|
15
|
+
const result = useObjectTranslation();
|
|
16
|
+
const testValue = result.t(testKey);
|
|
17
|
+
if (testValue === testKey) {
|
|
18
|
+
return {
|
|
19
|
+
t: (key, options) => {
|
|
20
|
+
let value = defaults[key] || key;
|
|
21
|
+
if (options) {
|
|
22
|
+
for (const [k, v] of Object.entries(options)) {
|
|
23
|
+
value = value.replace(`{{${k}}}`, String(v));
|
|
24
|
+
}
|
|
25
|
+
}
|
|
26
|
+
return value;
|
|
27
|
+
},
|
|
28
|
+
};
|
|
29
|
+
}
|
|
30
|
+
return { t: result.t };
|
|
31
|
+
}
|
|
32
|
+
catch {
|
|
33
|
+
return {
|
|
34
|
+
t: (key, options) => {
|
|
35
|
+
let value = defaults[key] || key;
|
|
36
|
+
if (options) {
|
|
37
|
+
for (const [k, v] of Object.entries(options)) {
|
|
38
|
+
value = value.replace(`{{${k}}}`, String(v));
|
|
39
|
+
}
|
|
40
|
+
}
|
|
41
|
+
return value;
|
|
42
|
+
},
|
|
43
|
+
};
|
|
44
|
+
}
|
|
45
|
+
};
|
|
46
|
+
}
|
package/package.json
CHANGED
|
@@ -1,14 +1,14 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@object-ui/i18n",
|
|
3
|
-
"version": "3.1
|
|
3
|
+
"version": "3.3.1",
|
|
4
4
|
"type": "module",
|
|
5
5
|
"sideEffects": false,
|
|
6
6
|
"license": "MIT",
|
|
7
7
|
"description": "Internationalization (i18n) support for Object UI with 10+ language packs, RTL layout, and date/currency formatting.",
|
|
8
|
-
"homepage": "https://www.objectui.org",
|
|
8
|
+
"homepage": "https://www.objectui.org/docs/packages/i18n",
|
|
9
9
|
"repository": {
|
|
10
10
|
"type": "git",
|
|
11
|
-
"url": "https://github.com/objectstack-ai/objectui.git",
|
|
11
|
+
"url": "git+https://github.com/objectstack-ai/objectui.git",
|
|
12
12
|
"directory": "packages/i18n"
|
|
13
13
|
},
|
|
14
14
|
"bugs": {
|
|
@@ -30,18 +30,41 @@
|
|
|
30
30
|
}
|
|
31
31
|
},
|
|
32
32
|
"dependencies": {
|
|
33
|
-
"i18next": "^
|
|
34
|
-
"react-i18next": "^
|
|
33
|
+
"i18next": "^26.0.8",
|
|
34
|
+
"react-i18next": "^17.0.6"
|
|
35
35
|
},
|
|
36
36
|
"peerDependencies": {
|
|
37
37
|
"react": "^18.0.0 || ^19.0.0"
|
|
38
38
|
},
|
|
39
39
|
"devDependencies": {
|
|
40
40
|
"@types/react": "19.2.14",
|
|
41
|
-
"react": "19.2.
|
|
42
|
-
"typescript": "^
|
|
43
|
-
"vitest": "^4.1.
|
|
41
|
+
"react": "19.2.5",
|
|
42
|
+
"typescript": "^6.0.3",
|
|
43
|
+
"vitest": "^4.1.5"
|
|
44
44
|
},
|
|
45
|
+
"keywords": [
|
|
46
|
+
"objectui",
|
|
47
|
+
"sdui",
|
|
48
|
+
"schema-driven-ui",
|
|
49
|
+
"react",
|
|
50
|
+
"tailwind",
|
|
51
|
+
"shadcn",
|
|
52
|
+
"objectstack",
|
|
53
|
+
"i18n",
|
|
54
|
+
"internationalization",
|
|
55
|
+
"localization",
|
|
56
|
+
"rtl"
|
|
57
|
+
],
|
|
58
|
+
"author": "ObjectStack Team <team@objectstack.ai>",
|
|
59
|
+
"publishConfig": {
|
|
60
|
+
"access": "public"
|
|
61
|
+
},
|
|
62
|
+
"files": [
|
|
63
|
+
"dist",
|
|
64
|
+
"README.md",
|
|
65
|
+
"CHANGELOG.md",
|
|
66
|
+
"LICENSE"
|
|
67
|
+
],
|
|
45
68
|
"scripts": {
|
|
46
69
|
"build": "tsc",
|
|
47
70
|
"test": "vitest run",
|
package/.turbo/turbo-build.log
DELETED
|
@@ -1,228 +0,0 @@
|
|
|
1
|
-
import { describe, it, expect, beforeEach } from 'vitest';
|
|
2
|
-
import {
|
|
3
|
-
createI18n,
|
|
4
|
-
getDirection,
|
|
5
|
-
getAvailableLanguages,
|
|
6
|
-
builtInLocales,
|
|
7
|
-
isRTL,
|
|
8
|
-
RTL_LANGUAGES,
|
|
9
|
-
formatDate,
|
|
10
|
-
formatDateTime,
|
|
11
|
-
formatCurrency,
|
|
12
|
-
formatNumber,
|
|
13
|
-
} from '../index';
|
|
14
|
-
|
|
15
|
-
describe('@object-ui/i18n', () => {
|
|
16
|
-
describe('createI18n', () => {
|
|
17
|
-
it('creates an i18next instance with default config', () => {
|
|
18
|
-
const i18n = createI18n({ detectBrowserLanguage: false });
|
|
19
|
-
expect(i18n).toBeDefined();
|
|
20
|
-
expect(i18n.language).toBe('en');
|
|
21
|
-
});
|
|
22
|
-
|
|
23
|
-
it('creates an instance with specified default language', () => {
|
|
24
|
-
const i18n = createI18n({ defaultLanguage: 'zh', detectBrowserLanguage: false });
|
|
25
|
-
expect(i18n.language).toBe('zh');
|
|
26
|
-
});
|
|
27
|
-
|
|
28
|
-
it('loads all built-in locales', () => {
|
|
29
|
-
const i18n = createI18n({ detectBrowserLanguage: false });
|
|
30
|
-
const langs = getAvailableLanguages(i18n);
|
|
31
|
-
expect(langs).toContain('en');
|
|
32
|
-
expect(langs).toContain('zh');
|
|
33
|
-
expect(langs).toContain('ja');
|
|
34
|
-
expect(langs).toContain('ko');
|
|
35
|
-
expect(langs).toContain('de');
|
|
36
|
-
expect(langs).toContain('fr');
|
|
37
|
-
expect(langs).toContain('es');
|
|
38
|
-
expect(langs).toContain('pt');
|
|
39
|
-
expect(langs).toContain('ru');
|
|
40
|
-
expect(langs).toContain('ar');
|
|
41
|
-
expect(langs.length).toBeGreaterThanOrEqual(10);
|
|
42
|
-
});
|
|
43
|
-
|
|
44
|
-
it('translates common keys in English', () => {
|
|
45
|
-
const i18n = createI18n({ defaultLanguage: 'en', detectBrowserLanguage: false });
|
|
46
|
-
expect(i18n.t('common.save')).toBe('Save');
|
|
47
|
-
expect(i18n.t('common.cancel')).toBe('Cancel');
|
|
48
|
-
expect(i18n.t('common.delete')).toBe('Delete');
|
|
49
|
-
expect(i18n.t('common.loading')).toBe('Loading...');
|
|
50
|
-
});
|
|
51
|
-
|
|
52
|
-
it('translates common keys in Chinese', () => {
|
|
53
|
-
const i18n = createI18n({ defaultLanguage: 'zh', detectBrowserLanguage: false });
|
|
54
|
-
expect(i18n.t('common.save')).toBe('保存');
|
|
55
|
-
expect(i18n.t('common.cancel')).toBe('取消');
|
|
56
|
-
expect(i18n.t('common.delete')).toBe('删除');
|
|
57
|
-
expect(i18n.t('common.loading')).toBe('加载中...');
|
|
58
|
-
});
|
|
59
|
-
|
|
60
|
-
it('translates common keys in Japanese', () => {
|
|
61
|
-
const i18n = createI18n({ defaultLanguage: 'ja', detectBrowserLanguage: false });
|
|
62
|
-
expect(i18n.t('common.save')).toBe('保存');
|
|
63
|
-
expect(i18n.t('common.cancel')).toBe('キャンセル');
|
|
64
|
-
});
|
|
65
|
-
|
|
66
|
-
it('translates common keys in Korean', () => {
|
|
67
|
-
const i18n = createI18n({ defaultLanguage: 'ko', detectBrowserLanguage: false });
|
|
68
|
-
expect(i18n.t('common.save')).toBe('저장');
|
|
69
|
-
expect(i18n.t('common.cancel')).toBe('취소');
|
|
70
|
-
});
|
|
71
|
-
|
|
72
|
-
it('supports interpolation in validation messages', () => {
|
|
73
|
-
const i18n = createI18n({ defaultLanguage: 'en', detectBrowserLanguage: false });
|
|
74
|
-
expect(i18n.t('validation.required', { field: 'Name' })).toBe('Name is required');
|
|
75
|
-
expect(i18n.t('validation.minLength', { field: 'Password', min: 8 })).toBe(
|
|
76
|
-
'Password must be at least 8 characters',
|
|
77
|
-
);
|
|
78
|
-
});
|
|
79
|
-
|
|
80
|
-
it('supports interpolation in Chinese', () => {
|
|
81
|
-
const i18n = createI18n({ defaultLanguage: 'zh', detectBrowserLanguage: false });
|
|
82
|
-
expect(i18n.t('validation.required', { field: '姓名' })).toBe('姓名不能为空');
|
|
83
|
-
});
|
|
84
|
-
|
|
85
|
-
it('falls back to English for unknown language', () => {
|
|
86
|
-
const i18n = createI18n({ defaultLanguage: 'xx', fallbackLanguage: 'en', detectBrowserLanguage: false });
|
|
87
|
-
expect(i18n.t('common.save')).toBe('Save');
|
|
88
|
-
});
|
|
89
|
-
|
|
90
|
-
it('merges custom resources with built-in locales', () => {
|
|
91
|
-
const i18n = createI18n({
|
|
92
|
-
defaultLanguage: 'en',
|
|
93
|
-
detectBrowserLanguage: false,
|
|
94
|
-
resources: {
|
|
95
|
-
en: { custom: { greeting: 'Hello!' } },
|
|
96
|
-
},
|
|
97
|
-
});
|
|
98
|
-
expect(i18n.t('custom.greeting')).toBe('Hello!');
|
|
99
|
-
// Built-in translations still work
|
|
100
|
-
expect(i18n.t('common.save')).toBe('Save');
|
|
101
|
-
});
|
|
102
|
-
|
|
103
|
-
it('changes language dynamically', async () => {
|
|
104
|
-
const i18n = createI18n({ defaultLanguage: 'en', detectBrowserLanguage: false });
|
|
105
|
-
expect(i18n.t('common.save')).toBe('Save');
|
|
106
|
-
|
|
107
|
-
await i18n.changeLanguage('zh');
|
|
108
|
-
expect(i18n.language).toBe('zh');
|
|
109
|
-
expect(i18n.t('common.save')).toBe('保存');
|
|
110
|
-
|
|
111
|
-
await i18n.changeLanguage('ja');
|
|
112
|
-
expect(i18n.language).toBe('ja');
|
|
113
|
-
expect(i18n.t('common.save')).toBe('保存');
|
|
114
|
-
});
|
|
115
|
-
});
|
|
116
|
-
|
|
117
|
-
describe('getDirection', () => {
|
|
118
|
-
it('returns ltr for English', () => {
|
|
119
|
-
expect(getDirection('en')).toBe('ltr');
|
|
120
|
-
});
|
|
121
|
-
|
|
122
|
-
it('returns ltr for Chinese', () => {
|
|
123
|
-
expect(getDirection('zh')).toBe('ltr');
|
|
124
|
-
});
|
|
125
|
-
|
|
126
|
-
it('returns rtl for Arabic', () => {
|
|
127
|
-
expect(getDirection('ar')).toBe('rtl');
|
|
128
|
-
});
|
|
129
|
-
|
|
130
|
-
it('returns ltr for unknown languages', () => {
|
|
131
|
-
expect(getDirection('xx')).toBe('ltr');
|
|
132
|
-
});
|
|
133
|
-
});
|
|
134
|
-
|
|
135
|
-
describe('isRTL', () => {
|
|
136
|
-
it('returns true for Arabic', () => {
|
|
137
|
-
expect(isRTL('ar')).toBe(true);
|
|
138
|
-
});
|
|
139
|
-
|
|
140
|
-
it('returns false for English', () => {
|
|
141
|
-
expect(isRTL('en')).toBe(false);
|
|
142
|
-
});
|
|
143
|
-
|
|
144
|
-
it('returns false for Chinese', () => {
|
|
145
|
-
expect(isRTL('zh')).toBe(false);
|
|
146
|
-
});
|
|
147
|
-
});
|
|
148
|
-
|
|
149
|
-
describe('RTL_LANGUAGES', () => {
|
|
150
|
-
it('includes Arabic', () => {
|
|
151
|
-
expect(RTL_LANGUAGES).toContain('ar');
|
|
152
|
-
});
|
|
153
|
-
|
|
154
|
-
it('includes Hebrew', () => {
|
|
155
|
-
expect(RTL_LANGUAGES).toContain('he');
|
|
156
|
-
});
|
|
157
|
-
});
|
|
158
|
-
|
|
159
|
-
describe('builtInLocales', () => {
|
|
160
|
-
it('has 10 built-in language packs', () => {
|
|
161
|
-
expect(Object.keys(builtInLocales).length).toBe(10);
|
|
162
|
-
});
|
|
163
|
-
|
|
164
|
-
it('all locales have the same top-level keys', () => {
|
|
165
|
-
const enKeys = Object.keys(builtInLocales.en).sort();
|
|
166
|
-
for (const [lang, locale] of Object.entries(builtInLocales)) {
|
|
167
|
-
const keys = Object.keys(locale).sort();
|
|
168
|
-
expect(keys).toEqual(enKeys);
|
|
169
|
-
}
|
|
170
|
-
});
|
|
171
|
-
|
|
172
|
-
it('all locales have common section keys matching English', () => {
|
|
173
|
-
const enCommonKeys = Object.keys(builtInLocales.en.common).sort();
|
|
174
|
-
for (const [lang, locale] of Object.entries(builtInLocales)) {
|
|
175
|
-
const keys = Object.keys(locale.common).sort();
|
|
176
|
-
expect(keys).toEqual(enCommonKeys);
|
|
177
|
-
}
|
|
178
|
-
});
|
|
179
|
-
});
|
|
180
|
-
|
|
181
|
-
describe('formatDate', () => {
|
|
182
|
-
it('formats a date with default options', () => {
|
|
183
|
-
const result = formatDate(new Date(2026, 0, 15), { locale: 'en' });
|
|
184
|
-
expect(result).toContain('Jan');
|
|
185
|
-
expect(result).toContain('15');
|
|
186
|
-
expect(result).toContain('2026');
|
|
187
|
-
});
|
|
188
|
-
|
|
189
|
-
it('formats a date with short style', () => {
|
|
190
|
-
const result = formatDate(new Date(2026, 0, 15), { locale: 'en', style: 'short' });
|
|
191
|
-
expect(result).toContain('15');
|
|
192
|
-
});
|
|
193
|
-
|
|
194
|
-
it('returns string for invalid dates', () => {
|
|
195
|
-
const result = formatDate('invalid-date');
|
|
196
|
-
expect(result).toBe('invalid-date');
|
|
197
|
-
});
|
|
198
|
-
});
|
|
199
|
-
|
|
200
|
-
describe('formatCurrency', () => {
|
|
201
|
-
it('formats USD by default', () => {
|
|
202
|
-
const result = formatCurrency(1234.56, { locale: 'en' });
|
|
203
|
-
expect(result).toContain('1,234.56');
|
|
204
|
-
});
|
|
205
|
-
|
|
206
|
-
it('formats EUR', () => {
|
|
207
|
-
const result = formatCurrency(1234.56, { locale: 'de', currency: 'EUR' });
|
|
208
|
-
expect(result).toContain('1.234,56');
|
|
209
|
-
});
|
|
210
|
-
|
|
211
|
-
it('formats CNY', () => {
|
|
212
|
-
const result = formatCurrency(1234.56, { locale: 'zh', currency: 'CNY' });
|
|
213
|
-
expect(result).toContain('1,234.56');
|
|
214
|
-
});
|
|
215
|
-
});
|
|
216
|
-
|
|
217
|
-
describe('formatNumber', () => {
|
|
218
|
-
it('formats a number with default locale', () => {
|
|
219
|
-
const result = formatNumber(1234567.89, { locale: 'en' });
|
|
220
|
-
expect(result).toContain('1,234,567.89');
|
|
221
|
-
});
|
|
222
|
-
|
|
223
|
-
it('formats with compact notation', () => {
|
|
224
|
-
const result = formatNumber(1234567, { locale: 'en', notation: 'compact' });
|
|
225
|
-
expect(result).toContain('M'); // e.g. 1.2M
|
|
226
|
-
});
|
|
227
|
-
});
|
|
228
|
-
});
|
|
@@ -1,117 +0,0 @@
|
|
|
1
|
-
import { describe, it, expect, vi } from 'vitest';
|
|
2
|
-
import { renderHook, waitFor, act } from '@testing-library/react';
|
|
3
|
-
import React from 'react';
|
|
4
|
-
import { I18nProvider, useObjectTranslation, useI18nContext } from '../provider';
|
|
5
|
-
import { createI18n } from '../i18n';
|
|
6
|
-
|
|
7
|
-
describe('I18nProvider', () => {
|
|
8
|
-
it('creates i18n instance from config', () => {
|
|
9
|
-
const wrapper = ({ children }: { children: React.ReactNode }) =>
|
|
10
|
-
React.createElement(I18nProvider, { config: { defaultLanguage: 'en', detectBrowserLanguage: false } }, children);
|
|
11
|
-
|
|
12
|
-
const { result } = renderHook(() => useObjectTranslation(), { wrapper });
|
|
13
|
-
|
|
14
|
-
expect(result.current.i18n).toBeDefined();
|
|
15
|
-
expect(result.current.language).toBe('en');
|
|
16
|
-
});
|
|
17
|
-
|
|
18
|
-
it('accepts pre-created instance', () => {
|
|
19
|
-
const instance = createI18n({ defaultLanguage: 'fr', detectBrowserLanguage: false });
|
|
20
|
-
|
|
21
|
-
const wrapper = ({ children }: { children: React.ReactNode }) =>
|
|
22
|
-
React.createElement(I18nProvider, { instance }, children);
|
|
23
|
-
|
|
24
|
-
const { result } = renderHook(() => useObjectTranslation(), { wrapper });
|
|
25
|
-
|
|
26
|
-
expect(result.current.i18n).toBeDefined();
|
|
27
|
-
expect(result.current.language).toBe('fr');
|
|
28
|
-
});
|
|
29
|
-
});
|
|
30
|
-
|
|
31
|
-
describe('useObjectTranslation', () => {
|
|
32
|
-
const wrapper = ({ children }: { children: React.ReactNode }) =>
|
|
33
|
-
React.createElement(I18nProvider, { config: { defaultLanguage: 'en', detectBrowserLanguage: false } }, children);
|
|
34
|
-
|
|
35
|
-
it('returns t, language, changeLanguage, direction, i18n', () => {
|
|
36
|
-
const { result } = renderHook(() => useObjectTranslation(), { wrapper });
|
|
37
|
-
|
|
38
|
-
expect(result.current.t).toBeTypeOf('function');
|
|
39
|
-
expect(result.current.language).toBeTypeOf('string');
|
|
40
|
-
expect(result.current.changeLanguage).toBeTypeOf('function');
|
|
41
|
-
expect(result.current.direction).toBeTypeOf('string');
|
|
42
|
-
expect(result.current.i18n).toBeDefined();
|
|
43
|
-
});
|
|
44
|
-
|
|
45
|
-
it('translates keys correctly', () => {
|
|
46
|
-
const { result } = renderHook(() => useObjectTranslation(), { wrapper });
|
|
47
|
-
|
|
48
|
-
expect(result.current.t('common.save')).toBe('Save');
|
|
49
|
-
expect(result.current.t('common.cancel')).toBe('Cancel');
|
|
50
|
-
expect(result.current.t('common.delete')).toBe('Delete');
|
|
51
|
-
});
|
|
52
|
-
|
|
53
|
-
it('returns en as default language', () => {
|
|
54
|
-
const { result } = renderHook(() => useObjectTranslation(), { wrapper });
|
|
55
|
-
|
|
56
|
-
expect(result.current.language).toBe('en');
|
|
57
|
-
});
|
|
58
|
-
|
|
59
|
-
it('works with Chinese language', () => {
|
|
60
|
-
const zhWrapper = ({ children }: { children: React.ReactNode }) =>
|
|
61
|
-
React.createElement(I18nProvider, { config: { defaultLanguage: 'zh', detectBrowserLanguage: false } }, children);
|
|
62
|
-
|
|
63
|
-
const { result } = renderHook(() => useObjectTranslation(), { wrapper: zhWrapper });
|
|
64
|
-
|
|
65
|
-
expect(result.current.language).toBe('zh');
|
|
66
|
-
expect(result.current.t('common.save')).toBe('保存');
|
|
67
|
-
expect(result.current.t('common.cancel')).toBe('取消');
|
|
68
|
-
});
|
|
69
|
-
|
|
70
|
-
it('changeLanguage updates language', async () => {
|
|
71
|
-
const { result } = renderHook(() => useObjectTranslation(), { wrapper });
|
|
72
|
-
|
|
73
|
-
expect(result.current.language).toBe('en');
|
|
74
|
-
|
|
75
|
-
await act(async () => {
|
|
76
|
-
await result.current.changeLanguage('zh');
|
|
77
|
-
});
|
|
78
|
-
|
|
79
|
-
await waitFor(() => {
|
|
80
|
-
expect(result.current.language).toBe('zh');
|
|
81
|
-
});
|
|
82
|
-
});
|
|
83
|
-
|
|
84
|
-
it('returns RTL direction for Arabic', () => {
|
|
85
|
-
const arWrapper = ({ children }: { children: React.ReactNode }) =>
|
|
86
|
-
React.createElement(I18nProvider, { config: { defaultLanguage: 'ar', detectBrowserLanguage: false } }, children);
|
|
87
|
-
|
|
88
|
-
const { result } = renderHook(() => useObjectTranslation(), { wrapper: arWrapper });
|
|
89
|
-
|
|
90
|
-
expect(result.current.direction).toBe('rtl');
|
|
91
|
-
});
|
|
92
|
-
});
|
|
93
|
-
|
|
94
|
-
describe('useI18nContext', () => {
|
|
95
|
-
it('throws when used outside provider', () => {
|
|
96
|
-
// Suppress console.error from React for the expected error
|
|
97
|
-
const spy = vi.spyOn(console, 'error').mockImplementation(() => {});
|
|
98
|
-
|
|
99
|
-
expect(() => {
|
|
100
|
-
renderHook(() => useI18nContext());
|
|
101
|
-
}).toThrow('useI18nContext must be used within an I18nProvider');
|
|
102
|
-
|
|
103
|
-
spy.mockRestore();
|
|
104
|
-
});
|
|
105
|
-
|
|
106
|
-
it('returns context inside provider', () => {
|
|
107
|
-
const wrapper = ({ children }: { children: React.ReactNode }) =>
|
|
108
|
-
React.createElement(I18nProvider, { config: { defaultLanguage: 'en', detectBrowserLanguage: false } }, children);
|
|
109
|
-
|
|
110
|
-
const { result } = renderHook(() => useI18nContext(), { wrapper });
|
|
111
|
-
|
|
112
|
-
expect(result.current.language).toBe('en');
|
|
113
|
-
expect(result.current.changeLanguage).toBeTypeOf('function');
|
|
114
|
-
expect(result.current.direction).toBe('ltr');
|
|
115
|
-
expect(result.current.i18n).toBeDefined();
|
|
116
|
-
});
|
|
117
|
-
});
|