@strato-admin/i18n 0.1.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/LICENSE +21 -0
- package/README.md +3 -0
- package/dist/hash.d.ts +5 -0
- package/dist/hash.js +14 -0
- package/dist/icuI18nProvider.d.ts +2 -0
- package/dist/icuI18nProvider.js +80 -0
- package/dist/index.d.ts +2 -0
- package/dist/index.js +2 -0
- package/package.json +52 -0
- package/src/hash.ts +14 -0
- package/src/icuI18nProvider.ts +94 -0
- package/src/index.test.ts +72 -0
- package/src/index.ts +2 -0
package/LICENSE
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 Vadim Gubergrits
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
package/README.md
ADDED
package/dist/hash.d.ts
ADDED
package/dist/hash.js
ADDED
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Simple FNV-1a hash for generating stable message IDs.
|
|
3
|
+
* This is lightweight and works identically in Node and the Browser.
|
|
4
|
+
*/
|
|
5
|
+
export function generateMessageId(msg) {
|
|
6
|
+
let hash = 0x811c9dc5;
|
|
7
|
+
for (let i = 0; i < msg.length; i++) {
|
|
8
|
+
hash ^= msg.charCodeAt(i);
|
|
9
|
+
// FNV-1a prime multiplication (hash * 16777619)
|
|
10
|
+
hash += (hash << 1) + (hash << 4) + (hash << 7) + (hash << 8) + (hash << 24);
|
|
11
|
+
}
|
|
12
|
+
// Convert to a base36 string for a compact, stable ID
|
|
13
|
+
return (hash >>> 0).toString(36);
|
|
14
|
+
}
|
|
@@ -0,0 +1,80 @@
|
|
|
1
|
+
import { IntlMessageFormat } from 'intl-messageformat';
|
|
2
|
+
import { generateMessageId } from './hash';
|
|
3
|
+
export const icuI18nProvider = (getMessages, initialLocale = 'en', availableLocales = [{ locale: 'en', name: 'English' }]) => {
|
|
4
|
+
let locale = initialLocale;
|
|
5
|
+
let messages = getMessages(initialLocale);
|
|
6
|
+
const formatters = new Map();
|
|
7
|
+
if (messages instanceof Promise) {
|
|
8
|
+
throw new Error(`The i18nProvider returned a Promise for the messages of the default locale (${initialLocale}). Please update your i18nProvider to return the messages of the default locale in a synchronous way.`);
|
|
9
|
+
}
|
|
10
|
+
return {
|
|
11
|
+
translate: (key, options = {}) => {
|
|
12
|
+
let finalKey = key;
|
|
13
|
+
let finalOptions = options;
|
|
14
|
+
// Handle React Admin's special validation error format
|
|
15
|
+
if (typeof key === 'string' && key.startsWith('@@react-admin@@')) {
|
|
16
|
+
try {
|
|
17
|
+
const parsed = JSON.parse(key.substring(15)); // 15 is the length of '@@react-admin@@'
|
|
18
|
+
if (typeof parsed === 'object' && parsed !== null) {
|
|
19
|
+
finalKey = parsed.message;
|
|
20
|
+
finalOptions = { ...options, ...parsed.args };
|
|
21
|
+
}
|
|
22
|
+
else {
|
|
23
|
+
finalKey = parsed;
|
|
24
|
+
}
|
|
25
|
+
}
|
|
26
|
+
catch (e) {
|
|
27
|
+
// Fallback to original key if parsing fails
|
|
28
|
+
}
|
|
29
|
+
}
|
|
30
|
+
const { _: defaultMessage, ...values } = finalOptions;
|
|
31
|
+
// 1. Generate the hash ID for the English key
|
|
32
|
+
const msgid = generateMessageId(finalKey);
|
|
33
|
+
// 2. Lookup by hash first, then fall back to literal key (for ra.* keys)
|
|
34
|
+
const message = messages[msgid] || messages[finalKey];
|
|
35
|
+
if (message === undefined) {
|
|
36
|
+
return defaultMessage !== undefined ? defaultMessage : finalKey;
|
|
37
|
+
}
|
|
38
|
+
if (typeof message !== 'string') {
|
|
39
|
+
return finalKey;
|
|
40
|
+
}
|
|
41
|
+
const cacheKey = `${locale}_${finalKey}`;
|
|
42
|
+
let formatter = formatters.get(cacheKey);
|
|
43
|
+
if (!formatter) {
|
|
44
|
+
try {
|
|
45
|
+
formatter = new IntlMessageFormat(message, locale);
|
|
46
|
+
formatters.set(cacheKey, formatter);
|
|
47
|
+
}
|
|
48
|
+
catch (error) {
|
|
49
|
+
console.error(`Error parsing message for key "${finalKey}":`, error);
|
|
50
|
+
return defaultMessage !== undefined ? defaultMessage : finalKey;
|
|
51
|
+
}
|
|
52
|
+
}
|
|
53
|
+
try {
|
|
54
|
+
return formatter.format(values);
|
|
55
|
+
}
|
|
56
|
+
catch (error) {
|
|
57
|
+
console.error(`Error formatting message for key "${finalKey}":`, error);
|
|
58
|
+
return defaultMessage !== undefined ? defaultMessage : finalKey;
|
|
59
|
+
}
|
|
60
|
+
},
|
|
61
|
+
changeLocale: (newLocale) => {
|
|
62
|
+
const newMessages = getMessages(newLocale);
|
|
63
|
+
if (newMessages instanceof Promise) {
|
|
64
|
+
return newMessages.then((resolvedMessages) => {
|
|
65
|
+
locale = newLocale;
|
|
66
|
+
messages = resolvedMessages;
|
|
67
|
+
formatters.clear();
|
|
68
|
+
});
|
|
69
|
+
}
|
|
70
|
+
else {
|
|
71
|
+
locale = newLocale;
|
|
72
|
+
messages = newMessages;
|
|
73
|
+
formatters.clear();
|
|
74
|
+
return Promise.resolve();
|
|
75
|
+
}
|
|
76
|
+
},
|
|
77
|
+
getLocale: () => locale,
|
|
78
|
+
getLocales: () => availableLocales,
|
|
79
|
+
};
|
|
80
|
+
};
|
package/dist/index.d.ts
ADDED
package/dist/index.js
ADDED
package/package.json
ADDED
|
@@ -0,0 +1,52 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "@strato-admin/i18n",
|
|
3
|
+
"version": "0.1.0",
|
|
4
|
+
"description": "Strato Admin I18n - ICU i18nProvider for React Admin / Strato Admin",
|
|
5
|
+
"main": "dist/index.js",
|
|
6
|
+
"types": "dist/index.d.ts",
|
|
7
|
+
"exports": {
|
|
8
|
+
".": {
|
|
9
|
+
"types": "./dist/index.d.ts",
|
|
10
|
+
"default": "dist/index.js"
|
|
11
|
+
}
|
|
12
|
+
},
|
|
13
|
+
"files": [
|
|
14
|
+
"dist",
|
|
15
|
+
"src",
|
|
16
|
+
"README.md",
|
|
17
|
+
"LICENSE"
|
|
18
|
+
],
|
|
19
|
+
"publishConfig": {
|
|
20
|
+
"access": "public"
|
|
21
|
+
},
|
|
22
|
+
"keywords": [
|
|
23
|
+
"strato",
|
|
24
|
+
"admin",
|
|
25
|
+
"react-admin",
|
|
26
|
+
"i18n",
|
|
27
|
+
"icu"
|
|
28
|
+
],
|
|
29
|
+
"author": "Vadim Gubergrits <vadim.gubergrits@gmail.com>",
|
|
30
|
+
"license": "MIT",
|
|
31
|
+
"repository": {
|
|
32
|
+
"type": "git",
|
|
33
|
+
"url": "https://github.com/vgrits/strato-admin.git",
|
|
34
|
+
"directory": "packages/strato-i18n"
|
|
35
|
+
},
|
|
36
|
+
"peerDependencies": {
|
|
37
|
+
"@strato-admin/core": "0.1.0"
|
|
38
|
+
},
|
|
39
|
+
"dependencies": {
|
|
40
|
+
"intl-messageformat": "^11.1.2"
|
|
41
|
+
},
|
|
42
|
+
"devDependencies": {
|
|
43
|
+
"@types/node": "^25.5.0",
|
|
44
|
+
"typescript": "^5.9.3",
|
|
45
|
+
"vitest": "^3.0.7",
|
|
46
|
+
"@strato-admin/ra-core": "^0.1.0"
|
|
47
|
+
},
|
|
48
|
+
"scripts": {
|
|
49
|
+
"build": "tsc -p tsconfig.build.json",
|
|
50
|
+
"test": "vitest run"
|
|
51
|
+
}
|
|
52
|
+
}
|
package/src/hash.ts
ADDED
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Simple FNV-1a hash for generating stable message IDs.
|
|
3
|
+
* This is lightweight and works identically in Node and the Browser.
|
|
4
|
+
*/
|
|
5
|
+
export function generateMessageId(msg: string): string {
|
|
6
|
+
let hash = 0x811c9dc5;
|
|
7
|
+
for (let i = 0; i < msg.length; i++) {
|
|
8
|
+
hash ^= msg.charCodeAt(i);
|
|
9
|
+
// FNV-1a prime multiplication (hash * 16777619)
|
|
10
|
+
hash += (hash << 1) + (hash << 4) + (hash << 7) + (hash << 8) + (hash << 24);
|
|
11
|
+
}
|
|
12
|
+
// Convert to a base36 string for a compact, stable ID
|
|
13
|
+
return (hash >>> 0).toString(36);
|
|
14
|
+
}
|
|
@@ -0,0 +1,94 @@
|
|
|
1
|
+
import { IntlMessageFormat } from 'intl-messageformat';
|
|
2
|
+
import type { I18nProvider, TranslationMessages } from '@strato-admin/core';
|
|
3
|
+
import { generateMessageId } from './hash';
|
|
4
|
+
|
|
5
|
+
export const icuI18nProvider = (
|
|
6
|
+
getMessages: (locale: string) => any | Promise<any>,
|
|
7
|
+
initialLocale: string = 'en',
|
|
8
|
+
availableLocales: any[] = [{ locale: 'en', name: 'English' }],
|
|
9
|
+
): I18nProvider => {
|
|
10
|
+
let locale = initialLocale;
|
|
11
|
+
let messages = getMessages(initialLocale);
|
|
12
|
+
const formatters = new Map<string, IntlMessageFormat>();
|
|
13
|
+
|
|
14
|
+
if (messages instanceof Promise) {
|
|
15
|
+
throw new Error(
|
|
16
|
+
`The i18nProvider returned a Promise for the messages of the default locale (${initialLocale}). Please update your i18nProvider to return the messages of the default locale in a synchronous way.`,
|
|
17
|
+
);
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
return {
|
|
21
|
+
translate: (key: string, options: any = {}) => {
|
|
22
|
+
let finalKey = key;
|
|
23
|
+
let finalOptions = options;
|
|
24
|
+
|
|
25
|
+
// Handle React Admin's special validation error format
|
|
26
|
+
if (typeof key === 'string' && key.startsWith('@@react-admin@@')) {
|
|
27
|
+
try {
|
|
28
|
+
const parsed = JSON.parse(key.substring(15)); // 15 is the length of '@@react-admin@@'
|
|
29
|
+
if (typeof parsed === 'object' && parsed !== null) {
|
|
30
|
+
finalKey = parsed.message;
|
|
31
|
+
finalOptions = { ...options, ...parsed.args };
|
|
32
|
+
} else {
|
|
33
|
+
finalKey = parsed;
|
|
34
|
+
}
|
|
35
|
+
} catch (e) {
|
|
36
|
+
// Fallback to original key if parsing fails
|
|
37
|
+
}
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
const { _: defaultMessage, ...values } = finalOptions;
|
|
41
|
+
|
|
42
|
+
// 1. Generate the hash ID for the English key
|
|
43
|
+
const msgid = generateMessageId(finalKey);
|
|
44
|
+
|
|
45
|
+
// 2. Lookup by hash first, then fall back to literal key (for ra.* keys)
|
|
46
|
+
const message = (messages as any)[msgid] || (messages as any)[finalKey];
|
|
47
|
+
|
|
48
|
+
if (message === undefined) {
|
|
49
|
+
return defaultMessage !== undefined ? defaultMessage : finalKey;
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
if (typeof message !== 'string') {
|
|
53
|
+
return finalKey;
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
const cacheKey = `${locale}_${finalKey}`;
|
|
57
|
+
let formatter = formatters.get(cacheKey);
|
|
58
|
+
|
|
59
|
+
if (!formatter) {
|
|
60
|
+
try {
|
|
61
|
+
formatter = new IntlMessageFormat(message, locale);
|
|
62
|
+
formatters.set(cacheKey, formatter);
|
|
63
|
+
} catch (error) {
|
|
64
|
+
console.error(`Error parsing message for key "${finalKey}":`, error);
|
|
65
|
+
return defaultMessage !== undefined ? defaultMessage : finalKey;
|
|
66
|
+
}
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
try {
|
|
70
|
+
return formatter.format(values) as string;
|
|
71
|
+
} catch (error) {
|
|
72
|
+
console.error(`Error formatting message for key "${finalKey}":`, error);
|
|
73
|
+
return defaultMessage !== undefined ? defaultMessage : finalKey;
|
|
74
|
+
}
|
|
75
|
+
},
|
|
76
|
+
changeLocale: (newLocale: string) => {
|
|
77
|
+
const newMessages = getMessages(newLocale);
|
|
78
|
+
if (newMessages instanceof Promise) {
|
|
79
|
+
return newMessages.then((resolvedMessages) => {
|
|
80
|
+
locale = newLocale;
|
|
81
|
+
messages = resolvedMessages;
|
|
82
|
+
formatters.clear();
|
|
83
|
+
});
|
|
84
|
+
} else {
|
|
85
|
+
locale = newLocale;
|
|
86
|
+
messages = newMessages;
|
|
87
|
+
formatters.clear();
|
|
88
|
+
return Promise.resolve();
|
|
89
|
+
}
|
|
90
|
+
},
|
|
91
|
+
getLocale: () => locale,
|
|
92
|
+
getLocales: () => availableLocales,
|
|
93
|
+
};
|
|
94
|
+
};
|
|
@@ -0,0 +1,72 @@
|
|
|
1
|
+
import { describe, it, expect } from 'vitest';
|
|
2
|
+
import { icuI18nProvider } from './index';
|
|
3
|
+
|
|
4
|
+
describe('icuI18nProvider', () => {
|
|
5
|
+
const getMessages = (locale: string) => {
|
|
6
|
+
if (locale === 'en') {
|
|
7
|
+
return {
|
|
8
|
+
simple: 'Hello',
|
|
9
|
+
withParam: 'Hello {name}',
|
|
10
|
+
plural: 'You have {count, plural, =0 {no messages} one {1 message} other {# messages}}.',
|
|
11
|
+
'nested.key': 'Nested value',
|
|
12
|
+
};
|
|
13
|
+
}
|
|
14
|
+
if (locale === 'fr') {
|
|
15
|
+
return {
|
|
16
|
+
simple: 'Bonjour',
|
|
17
|
+
};
|
|
18
|
+
}
|
|
19
|
+
return {};
|
|
20
|
+
};
|
|
21
|
+
|
|
22
|
+
it('translates simple keys', () => {
|
|
23
|
+
const i18n = icuI18nProvider(getMessages, 'en');
|
|
24
|
+
expect(i18n.translate('simple')).toBe('Hello');
|
|
25
|
+
});
|
|
26
|
+
|
|
27
|
+
it('translates keys with parameters', () => {
|
|
28
|
+
const i18n = icuI18nProvider(getMessages, 'en');
|
|
29
|
+
expect(i18n.translate('withParam', { name: 'World' })).toBe('Hello World');
|
|
30
|
+
});
|
|
31
|
+
|
|
32
|
+
it('translates plural keys', () => {
|
|
33
|
+
const i18n = icuI18nProvider(getMessages, 'en');
|
|
34
|
+
expect(i18n.translate('plural', { count: 0 })).toBe('You have no messages.');
|
|
35
|
+
expect(i18n.translate('plural', { count: 1 })).toBe('You have 1 message.');
|
|
36
|
+
expect(i18n.translate('plural', { count: 2 })).toBe('You have 2 messages.');
|
|
37
|
+
});
|
|
38
|
+
|
|
39
|
+
it('translates keys with dots (flat)', () => {
|
|
40
|
+
const i18n = icuI18nProvider(getMessages, 'en');
|
|
41
|
+
expect(i18n.translate('nested.key')).toBe('Nested value');
|
|
42
|
+
});
|
|
43
|
+
|
|
44
|
+
it('uses fallback if key is missing', () => {
|
|
45
|
+
const i18n = icuI18nProvider(getMessages, 'en');
|
|
46
|
+
expect(i18n.translate('missing', { _: 'Fallback' })).toBe('Fallback');
|
|
47
|
+
});
|
|
48
|
+
|
|
49
|
+
it('returns key if missing and no fallback provided', () => {
|
|
50
|
+
const i18n = icuI18nProvider(getMessages, 'en');
|
|
51
|
+
expect(i18n.translate('missing')).toBe('missing');
|
|
52
|
+
});
|
|
53
|
+
|
|
54
|
+
it('changes locale and updates translations', async () => {
|
|
55
|
+
const i18n = icuI18nProvider(getMessages, 'en');
|
|
56
|
+
expect(i18n.getLocale()).toBe('en');
|
|
57
|
+
expect(i18n.translate('simple')).toBe('Hello');
|
|
58
|
+
|
|
59
|
+
await i18n.changeLocale('fr');
|
|
60
|
+
expect(i18n.getLocale()).toBe('fr');
|
|
61
|
+
expect(i18n.translate('simple')).toBe('Bonjour');
|
|
62
|
+
});
|
|
63
|
+
|
|
64
|
+
it('clears formatters cache when locale changes', async () => {
|
|
65
|
+
const i18n = icuI18nProvider(getMessages, 'en');
|
|
66
|
+
expect(i18n.translate('simple')).toBe('Hello');
|
|
67
|
+
|
|
68
|
+
await i18n.changeLocale('fr');
|
|
69
|
+
// If cache wasn't cleared, it would still return "Hello" (as it's keyed by key and locale but let's be sure)
|
|
70
|
+
expect(i18n.translate('simple')).toBe('Bonjour');
|
|
71
|
+
});
|
|
72
|
+
});
|
package/src/index.ts
ADDED