@lokascript/i18n 1.0.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/README.md +286 -0
- package/dist/browser.cjs +7669 -0
- package/dist/browser.cjs.map +1 -0
- package/dist/browser.d.cts +50 -0
- package/dist/browser.d.ts +50 -0
- package/dist/browser.js +7592 -0
- package/dist/browser.js.map +1 -0
- package/dist/hyperfixi-i18n.min.js +2 -0
- package/dist/hyperfixi-i18n.min.js.map +1 -0
- package/dist/hyperfixi-i18n.mjs +8558 -0
- package/dist/hyperfixi-i18n.mjs.map +1 -0
- package/dist/index.cjs +14205 -0
- package/dist/index.cjs.map +1 -0
- package/dist/index.d.cts +947 -0
- package/dist/index.d.ts +947 -0
- package/dist/index.js +14095 -0
- package/dist/index.js.map +1 -0
- package/dist/transformer-Ckask-yw.d.cts +1041 -0
- package/dist/transformer-Ckask-yw.d.ts +1041 -0
- package/package.json +84 -0
- package/src/browser.ts +122 -0
- package/src/compatibility/browser-tests/grammar-demo.spec.ts +169 -0
- package/src/constants.ts +366 -0
- package/src/dictionaries/ar.ts +233 -0
- package/src/dictionaries/bn.ts +156 -0
- package/src/dictionaries/de.ts +233 -0
- package/src/dictionaries/derive.ts +515 -0
- package/src/dictionaries/en.ts +237 -0
- package/src/dictionaries/es.ts +233 -0
- package/src/dictionaries/fr.ts +233 -0
- package/src/dictionaries/hi.ts +270 -0
- package/src/dictionaries/id.ts +233 -0
- package/src/dictionaries/index.ts +238 -0
- package/src/dictionaries/it.ts +233 -0
- package/src/dictionaries/ja.ts +233 -0
- package/src/dictionaries/ko.ts +233 -0
- package/src/dictionaries/ms.ts +276 -0
- package/src/dictionaries/pl.ts +239 -0
- package/src/dictionaries/pt.ts +237 -0
- package/src/dictionaries/qu.ts +233 -0
- package/src/dictionaries/ru.ts +270 -0
- package/src/dictionaries/sw.ts +233 -0
- package/src/dictionaries/th.ts +156 -0
- package/src/dictionaries/tl.ts +276 -0
- package/src/dictionaries/tr.ts +233 -0
- package/src/dictionaries/uk.ts +270 -0
- package/src/dictionaries/vi.ts +210 -0
- package/src/dictionaries/zh.ts +233 -0
- package/src/enhanced-i18n.test.ts +454 -0
- package/src/enhanced-i18n.ts +713 -0
- package/src/examples/new-languages.ts +326 -0
- package/src/formatting.test.ts +213 -0
- package/src/formatting.ts +416 -0
- package/src/grammar/direct-mappings.ts +353 -0
- package/src/grammar/grammar.test.ts +1053 -0
- package/src/grammar/index.ts +59 -0
- package/src/grammar/profiles/index.ts +860 -0
- package/src/grammar/transformer.ts +1318 -0
- package/src/grammar/types.ts +630 -0
- package/src/index.ts +202 -0
- package/src/new-languages.test.ts +389 -0
- package/src/parser/analyze-conflicts.test.ts +229 -0
- package/src/parser/ar.ts +40 -0
- package/src/parser/create-provider.ts +309 -0
- package/src/parser/de.ts +36 -0
- package/src/parser/es.ts +31 -0
- package/src/parser/fr.ts +31 -0
- package/src/parser/id.ts +34 -0
- package/src/parser/index.ts +50 -0
- package/src/parser/ja.ts +36 -0
- package/src/parser/ko.ts +37 -0
- package/src/parser/locale-manager.test.ts +198 -0
- package/src/parser/locale-manager.ts +197 -0
- package/src/parser/parser-integration.test.ts +439 -0
- package/src/parser/pt.ts +37 -0
- package/src/parser/qu.ts +37 -0
- package/src/parser/sw.ts +37 -0
- package/src/parser/tr.ts +38 -0
- package/src/parser/types.ts +113 -0
- package/src/parser/zh.ts +38 -0
- package/src/plugins/vite.ts +224 -0
- package/src/plugins/webpack.ts +124 -0
- package/src/pluralization.test.ts +197 -0
- package/src/pluralization.ts +393 -0
- package/src/runtime.ts +441 -0
- package/src/ssr-integration.ts +225 -0
- package/src/test-setup.ts +195 -0
- package/src/translation-validation.test.ts +171 -0
- package/src/translator.test.ts +252 -0
- package/src/translator.ts +297 -0
- package/src/types.ts +209 -0
- package/src/utils/locale.ts +190 -0
- package/src/utils/tokenizer-adapter.ts +469 -0
- package/src/utils/tokenizer.ts +19 -0
- package/src/validators/index.ts +174 -0
- package/src/validators/schema.ts +129 -0
|
@@ -0,0 +1,297 @@
|
|
|
1
|
+
// packages/i18n/src/translator.ts
|
|
2
|
+
|
|
3
|
+
import {
|
|
4
|
+
Dictionary,
|
|
5
|
+
DICTIONARY_CATEGORIES,
|
|
6
|
+
I18nConfig,
|
|
7
|
+
TranslationOptions,
|
|
8
|
+
TranslationResult,
|
|
9
|
+
Token,
|
|
10
|
+
TokenType,
|
|
11
|
+
ValidationResult,
|
|
12
|
+
} from './types';
|
|
13
|
+
import { dictionaries } from './dictionaries';
|
|
14
|
+
import { detectLocale } from './utils/locale';
|
|
15
|
+
import { tokenize } from './utils/tokenizer';
|
|
16
|
+
import { validate } from './validators';
|
|
17
|
+
|
|
18
|
+
export class HyperscriptTranslator {
|
|
19
|
+
private config: I18nConfig;
|
|
20
|
+
private dictionaries: Map<string, Dictionary>;
|
|
21
|
+
private reverseDictionaries: Map<string, Map<string, string>>;
|
|
22
|
+
|
|
23
|
+
constructor(config: I18nConfig) {
|
|
24
|
+
this.config = {
|
|
25
|
+
fallbackLocale: 'en',
|
|
26
|
+
preserveOriginalAttribute: 'data-i18n-original',
|
|
27
|
+
detectLocale: true, // Enable language detection by default
|
|
28
|
+
...config,
|
|
29
|
+
locale: config.locale || 'en', // Ensure locale has a default
|
|
30
|
+
};
|
|
31
|
+
|
|
32
|
+
this.dictionaries = new Map();
|
|
33
|
+
this.reverseDictionaries = new Map();
|
|
34
|
+
|
|
35
|
+
// Load built-in dictionaries
|
|
36
|
+
Object.entries(dictionaries).forEach(([locale, dict]) => {
|
|
37
|
+
this.addDictionary(locale, dict);
|
|
38
|
+
});
|
|
39
|
+
|
|
40
|
+
// Load custom dictionaries
|
|
41
|
+
if (config.dictionaries) {
|
|
42
|
+
Object.entries(config.dictionaries).forEach(([locale, dict]) => {
|
|
43
|
+
this.addDictionary(locale, dict);
|
|
44
|
+
});
|
|
45
|
+
}
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
translate(text: string, options: TranslationOptions): string {
|
|
49
|
+
const result = this.translateWithDetails(text, options);
|
|
50
|
+
return result.translated;
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
translateWithDetails(text: string, options: TranslationOptions): TranslationResult {
|
|
54
|
+
const fromLocale = options.from || this.detectLanguage(text);
|
|
55
|
+
const toLocale = options.to;
|
|
56
|
+
|
|
57
|
+
if (fromLocale === toLocale) {
|
|
58
|
+
return {
|
|
59
|
+
translated: text,
|
|
60
|
+
original: text,
|
|
61
|
+
tokens: [],
|
|
62
|
+
locale: { from: fromLocale, to: toLocale },
|
|
63
|
+
};
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
// Get dictionaries
|
|
67
|
+
const fromDict = this.getDictionary(fromLocale);
|
|
68
|
+
const toDict = this.getDictionary(toLocale);
|
|
69
|
+
|
|
70
|
+
if (!fromDict || !toDict) {
|
|
71
|
+
throw new Error(`Missing dictionary for locale: ${!fromDict ? fromLocale : toLocale}`);
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
// Tokenize the input
|
|
75
|
+
const tokens = tokenize(text, fromLocale);
|
|
76
|
+
|
|
77
|
+
// Translate tokens
|
|
78
|
+
const translatedTokens = this.translateTokens(tokens, fromLocale, toLocale);
|
|
79
|
+
|
|
80
|
+
// Reconstruct the text
|
|
81
|
+
const translated = this.reconstructText(translatedTokens);
|
|
82
|
+
|
|
83
|
+
// Validate target dictionary if requested
|
|
84
|
+
if (options.validate && toDict) {
|
|
85
|
+
const validation = validate(toDict, toLocale);
|
|
86
|
+
if (!validation.valid) {
|
|
87
|
+
console.warn('Translation validation warnings:', validation.warnings);
|
|
88
|
+
}
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
const result: TranslationResult = {
|
|
92
|
+
translated,
|
|
93
|
+
tokens: translatedTokens,
|
|
94
|
+
locale: { from: fromLocale, to: toLocale },
|
|
95
|
+
warnings: [],
|
|
96
|
+
};
|
|
97
|
+
if (options.preserveOriginal) {
|
|
98
|
+
result.original = text;
|
|
99
|
+
}
|
|
100
|
+
return result;
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
private translateTokens(tokens: Token[], fromLocale: string, toLocale: string): Token[] {
|
|
104
|
+
const fromDict = this.getDictionary(fromLocale);
|
|
105
|
+
const toDict = this.getDictionary(toLocale);
|
|
106
|
+
const reverseFromDict = this.getReverseDictionary(fromLocale);
|
|
107
|
+
const emptyDict: Dictionary = {
|
|
108
|
+
commands: {},
|
|
109
|
+
modifiers: {},
|
|
110
|
+
events: {},
|
|
111
|
+
logical: {},
|
|
112
|
+
temporal: {},
|
|
113
|
+
values: {},
|
|
114
|
+
attributes: {},
|
|
115
|
+
expressions: {},
|
|
116
|
+
};
|
|
117
|
+
|
|
118
|
+
return tokens.map(token => {
|
|
119
|
+
let translated = token.value;
|
|
120
|
+
|
|
121
|
+
// Only translate keywords, not identifiers or literals
|
|
122
|
+
if (this.isTranslatableToken(token)) {
|
|
123
|
+
// First, try direct translation from source to target
|
|
124
|
+
if (fromLocale !== 'en' && toLocale !== 'en') {
|
|
125
|
+
// Translate through English as intermediate
|
|
126
|
+
const english = this.findTranslation(token.value, fromDict || emptyDict, reverseFromDict);
|
|
127
|
+
if (english) {
|
|
128
|
+
translated =
|
|
129
|
+
this.findTranslation(english, toDict || emptyDict, new Map()) || token.value;
|
|
130
|
+
}
|
|
131
|
+
} else if (fromLocale === 'en') {
|
|
132
|
+
// Direct translation from English
|
|
133
|
+
translated =
|
|
134
|
+
this.findTranslation(token.value, toDict || emptyDict, new Map()) || token.value;
|
|
135
|
+
} else {
|
|
136
|
+
// Translation to English
|
|
137
|
+
translated =
|
|
138
|
+
this.findTranslation(token.value, fromDict || emptyDict, reverseFromDict) ||
|
|
139
|
+
token.value;
|
|
140
|
+
}
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
return {
|
|
144
|
+
...token,
|
|
145
|
+
translated,
|
|
146
|
+
};
|
|
147
|
+
});
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
private findTranslation(
|
|
151
|
+
word: string,
|
|
152
|
+
dict: Dictionary,
|
|
153
|
+
reverseDict: Map<string, string>
|
|
154
|
+
): string | null {
|
|
155
|
+
const lowerWord = word.toLowerCase();
|
|
156
|
+
|
|
157
|
+
// Check if translating FROM this locale using reverse dictionary
|
|
158
|
+
if (reverseDict.size > 0) {
|
|
159
|
+
const english = reverseDict.get(lowerWord);
|
|
160
|
+
if (english) return english;
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
// Check categories in priority order (events before commands to handle 'click' etc.)
|
|
164
|
+
const categoryOrder = [
|
|
165
|
+
'events',
|
|
166
|
+
'commands',
|
|
167
|
+
'expressions',
|
|
168
|
+
'modifiers',
|
|
169
|
+
'logical',
|
|
170
|
+
'temporal',
|
|
171
|
+
'values',
|
|
172
|
+
'attributes',
|
|
173
|
+
];
|
|
174
|
+
|
|
175
|
+
for (const category of categoryOrder) {
|
|
176
|
+
const translations = dict[category as keyof Dictionary];
|
|
177
|
+
if (translations && typeof translations === 'object') {
|
|
178
|
+
for (const [key, value] of Object.entries(translations)) {
|
|
179
|
+
if (key.toLowerCase() === lowerWord) return value;
|
|
180
|
+
}
|
|
181
|
+
}
|
|
182
|
+
}
|
|
183
|
+
|
|
184
|
+
return null;
|
|
185
|
+
}
|
|
186
|
+
|
|
187
|
+
private isTranslatableToken(token: Token): boolean {
|
|
188
|
+
const translatableTypes: TokenType[] = [
|
|
189
|
+
'command',
|
|
190
|
+
'modifier',
|
|
191
|
+
'event',
|
|
192
|
+
'logical',
|
|
193
|
+
'temporal',
|
|
194
|
+
'value',
|
|
195
|
+
'attribute',
|
|
196
|
+
'expression',
|
|
197
|
+
];
|
|
198
|
+
return translatableTypes.includes(token.type);
|
|
199
|
+
}
|
|
200
|
+
|
|
201
|
+
private reconstructText(tokens: Token[]): string {
|
|
202
|
+
return tokens.map(token => token.translated || token.value).join('');
|
|
203
|
+
}
|
|
204
|
+
|
|
205
|
+
detectLanguage(text: string): string {
|
|
206
|
+
if (!this.config.detectLocale) {
|
|
207
|
+
return this.config.locale;
|
|
208
|
+
}
|
|
209
|
+
|
|
210
|
+
return detectLocale(text, Array.from(this.dictionaries.keys()));
|
|
211
|
+
}
|
|
212
|
+
|
|
213
|
+
addDictionary(locale: string, dictionary: Dictionary): void {
|
|
214
|
+
this.dictionaries.set(locale, dictionary);
|
|
215
|
+
|
|
216
|
+
// Build reverse dictionary for this locale using type-safe iteration
|
|
217
|
+
const reverseDict = new Map<string, string>();
|
|
218
|
+
|
|
219
|
+
for (const category of DICTIONARY_CATEGORIES) {
|
|
220
|
+
const translations = dictionary[category];
|
|
221
|
+
if (translations) {
|
|
222
|
+
for (const [english, translated] of Object.entries(translations)) {
|
|
223
|
+
reverseDict.set(translated.toLowerCase(), english);
|
|
224
|
+
}
|
|
225
|
+
}
|
|
226
|
+
}
|
|
227
|
+
|
|
228
|
+
this.reverseDictionaries.set(locale, reverseDict);
|
|
229
|
+
}
|
|
230
|
+
|
|
231
|
+
getDictionary(locale: string): Dictionary | undefined {
|
|
232
|
+
return this.dictionaries.get(locale);
|
|
233
|
+
}
|
|
234
|
+
|
|
235
|
+
private getReverseDictionary(locale: string): Map<string, string> {
|
|
236
|
+
return this.reverseDictionaries.get(locale) || new Map();
|
|
237
|
+
}
|
|
238
|
+
|
|
239
|
+
getSupportedLocales(): string[] {
|
|
240
|
+
return Array.from(this.dictionaries.keys());
|
|
241
|
+
}
|
|
242
|
+
|
|
243
|
+
validateDictionary(locale: string): ValidationResult {
|
|
244
|
+
const dict = this.getDictionary(locale);
|
|
245
|
+
if (!dict) {
|
|
246
|
+
return {
|
|
247
|
+
valid: false,
|
|
248
|
+
errors: [
|
|
249
|
+
{ type: 'missing', key: locale, message: `Dictionary not found for locale: ${locale}` },
|
|
250
|
+
],
|
|
251
|
+
warnings: [],
|
|
252
|
+
coverage: { total: 0, translated: 0, missing: [] },
|
|
253
|
+
};
|
|
254
|
+
}
|
|
255
|
+
|
|
256
|
+
return validate(dict, locale);
|
|
257
|
+
}
|
|
258
|
+
|
|
259
|
+
isRTL(locale: string): boolean {
|
|
260
|
+
const rtlLocales = this.config.rtlLocales || ['ar', 'he', 'fa', 'ur'];
|
|
261
|
+
return rtlLocales.includes(locale);
|
|
262
|
+
}
|
|
263
|
+
|
|
264
|
+
getCompletions(context: { text: string; position: number; locale: string }): string[] {
|
|
265
|
+
const dict = this.getDictionary(context.locale);
|
|
266
|
+
if (!dict) return [];
|
|
267
|
+
|
|
268
|
+
const completions: string[] = [];
|
|
269
|
+
|
|
270
|
+
// Get the partial word at cursor
|
|
271
|
+
const beforeCursor = context.text.substring(0, context.position);
|
|
272
|
+
const partial = beforeCursor.match(/\b(\w+)$/)?.[1] || '';
|
|
273
|
+
const lowerPartial = partial.toLowerCase();
|
|
274
|
+
|
|
275
|
+
// Search all categories for matches using type-safe iteration
|
|
276
|
+
// For non-English locales, search translated values; for English, search keys
|
|
277
|
+
for (const category of DICTIONARY_CATEGORIES) {
|
|
278
|
+
const translations = dict[category];
|
|
279
|
+
if (translations) {
|
|
280
|
+
for (const [key, value] of Object.entries(translations)) {
|
|
281
|
+
// Search keys (English terms) and values (translated terms)
|
|
282
|
+
if (key.toLowerCase().startsWith(lowerPartial)) {
|
|
283
|
+
completions.push(key);
|
|
284
|
+
}
|
|
285
|
+
if (value.toLowerCase().startsWith(lowerPartial) && !completions.includes(value)) {
|
|
286
|
+
completions.push(value);
|
|
287
|
+
}
|
|
288
|
+
}
|
|
289
|
+
}
|
|
290
|
+
}
|
|
291
|
+
|
|
292
|
+
return completions;
|
|
293
|
+
}
|
|
294
|
+
}
|
|
295
|
+
|
|
296
|
+
// Export a singleton instance for convenience
|
|
297
|
+
export const translator = new HyperscriptTranslator({ locale: 'en' });
|
package/src/types.ts
ADDED
|
@@ -0,0 +1,209 @@
|
|
|
1
|
+
// packages/i18n/src/types.ts
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* Dictionary category names as a union type for type-safe access.
|
|
5
|
+
*/
|
|
6
|
+
export type DictionaryCategory =
|
|
7
|
+
| 'commands'
|
|
8
|
+
| 'modifiers'
|
|
9
|
+
| 'events'
|
|
10
|
+
| 'logical'
|
|
11
|
+
| 'temporal'
|
|
12
|
+
| 'values'
|
|
13
|
+
| 'attributes'
|
|
14
|
+
| 'expressions';
|
|
15
|
+
|
|
16
|
+
/**
|
|
17
|
+
* All valid dictionary categories.
|
|
18
|
+
*/
|
|
19
|
+
export const DICTIONARY_CATEGORIES: readonly DictionaryCategory[] = [
|
|
20
|
+
'commands',
|
|
21
|
+
'modifiers',
|
|
22
|
+
'events',
|
|
23
|
+
'logical',
|
|
24
|
+
'temporal',
|
|
25
|
+
'values',
|
|
26
|
+
'attributes',
|
|
27
|
+
'expressions',
|
|
28
|
+
] as const;
|
|
29
|
+
|
|
30
|
+
/**
|
|
31
|
+
* Dictionary structure for i18n translations.
|
|
32
|
+
* Maps English canonical keywords to locale-specific translations.
|
|
33
|
+
*
|
|
34
|
+
* Note: Index signature removed for stricter type safety.
|
|
35
|
+
* Use DICTIONARY_CATEGORIES to iterate over categories.
|
|
36
|
+
*/
|
|
37
|
+
export interface Dictionary {
|
|
38
|
+
commands: Record<string, string>;
|
|
39
|
+
modifiers: Record<string, string>;
|
|
40
|
+
events: Record<string, string>;
|
|
41
|
+
logical: Record<string, string>;
|
|
42
|
+
temporal: Record<string, string>;
|
|
43
|
+
values: Record<string, string>;
|
|
44
|
+
attributes: Record<string, string>;
|
|
45
|
+
expressions: Record<string, string>;
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
// =============================================================================
|
|
49
|
+
// Type Guards
|
|
50
|
+
// =============================================================================
|
|
51
|
+
|
|
52
|
+
/**
|
|
53
|
+
* Type guard to check if a string is a valid dictionary category.
|
|
54
|
+
*/
|
|
55
|
+
export function isDictionaryCategory(key: string): key is DictionaryCategory {
|
|
56
|
+
return DICTIONARY_CATEGORIES.includes(key as DictionaryCategory);
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
/**
|
|
60
|
+
* Safely get a category from a dictionary with type narrowing.
|
|
61
|
+
* Returns undefined if the category doesn't exist.
|
|
62
|
+
*/
|
|
63
|
+
export function getDictionaryCategory(
|
|
64
|
+
dict: Dictionary,
|
|
65
|
+
category: string
|
|
66
|
+
): Record<string, string> | undefined {
|
|
67
|
+
if (isDictionaryCategory(category)) {
|
|
68
|
+
return dict[category];
|
|
69
|
+
}
|
|
70
|
+
return undefined;
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
/**
|
|
74
|
+
* Iterate over all categories in a dictionary with proper typing.
|
|
75
|
+
*/
|
|
76
|
+
export function forEachCategory(
|
|
77
|
+
dict: Dictionary,
|
|
78
|
+
callback: (category: DictionaryCategory, entries: Record<string, string>) => void
|
|
79
|
+
): void {
|
|
80
|
+
for (const category of DICTIONARY_CATEGORIES) {
|
|
81
|
+
callback(category, dict[category]);
|
|
82
|
+
}
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
/**
|
|
86
|
+
* Find a translation in any category of a dictionary.
|
|
87
|
+
* Returns the English key if found, undefined otherwise.
|
|
88
|
+
*/
|
|
89
|
+
export function findInDictionary(
|
|
90
|
+
dict: Dictionary,
|
|
91
|
+
localizedWord: string
|
|
92
|
+
): { category: DictionaryCategory; englishKey: string } | undefined {
|
|
93
|
+
const normalized = localizedWord.toLowerCase();
|
|
94
|
+
for (const category of DICTIONARY_CATEGORIES) {
|
|
95
|
+
const entries = dict[category];
|
|
96
|
+
for (const [english, localized] of Object.entries(entries)) {
|
|
97
|
+
if (localized.toLowerCase() === normalized) {
|
|
98
|
+
return { category, englishKey: english };
|
|
99
|
+
}
|
|
100
|
+
}
|
|
101
|
+
}
|
|
102
|
+
return undefined;
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
/**
|
|
106
|
+
* Find a translation for an English word in any category.
|
|
107
|
+
* Returns the localized word if found, undefined otherwise.
|
|
108
|
+
*/
|
|
109
|
+
export function translateFromEnglish(dict: Dictionary, englishWord: string): string | undefined {
|
|
110
|
+
const normalized = englishWord.toLowerCase();
|
|
111
|
+
for (const category of DICTIONARY_CATEGORIES) {
|
|
112
|
+
const entries = dict[category];
|
|
113
|
+
const translated = entries[normalized];
|
|
114
|
+
if (translated) {
|
|
115
|
+
return translated;
|
|
116
|
+
}
|
|
117
|
+
}
|
|
118
|
+
return undefined;
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
export interface I18nConfig {
|
|
122
|
+
locale: string;
|
|
123
|
+
fallbackLocale?: string;
|
|
124
|
+
dictionaries?: Record<string, Dictionary>;
|
|
125
|
+
detectLocale?: boolean;
|
|
126
|
+
rtlLocales?: string[];
|
|
127
|
+
preserveOriginalAttribute?: string;
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
export interface TranslationOptions {
|
|
131
|
+
from?: string;
|
|
132
|
+
to: string;
|
|
133
|
+
preserveOriginal?: boolean;
|
|
134
|
+
validate?: boolean;
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
export interface ValidationResult {
|
|
138
|
+
valid: boolean;
|
|
139
|
+
errors: ValidationError[];
|
|
140
|
+
warnings: ValidationWarning[];
|
|
141
|
+
coverage: {
|
|
142
|
+
total: number;
|
|
143
|
+
translated: number;
|
|
144
|
+
missing: string[];
|
|
145
|
+
};
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
export interface ValidationError {
|
|
149
|
+
type: 'missing' | 'invalid' | 'duplicate';
|
|
150
|
+
key: string;
|
|
151
|
+
message: string;
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
export interface ValidationWarning {
|
|
155
|
+
type: 'unused' | 'deprecated' | 'inconsistent';
|
|
156
|
+
key: string;
|
|
157
|
+
message: string;
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
export interface LocaleMetadata {
|
|
161
|
+
code: string;
|
|
162
|
+
name: string;
|
|
163
|
+
nativeName: string;
|
|
164
|
+
rtl: boolean;
|
|
165
|
+
pluralRules?: (n: number) => string;
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
export interface TranslationContext {
|
|
169
|
+
locale: string;
|
|
170
|
+
direction: 'ltr' | 'rtl';
|
|
171
|
+
dictionary: Dictionary;
|
|
172
|
+
metadata: LocaleMetadata;
|
|
173
|
+
}
|
|
174
|
+
|
|
175
|
+
export type TokenType =
|
|
176
|
+
| 'command'
|
|
177
|
+
| 'modifier'
|
|
178
|
+
| 'event'
|
|
179
|
+
| 'logical'
|
|
180
|
+
| 'temporal'
|
|
181
|
+
| 'value'
|
|
182
|
+
| 'attribute'
|
|
183
|
+
| 'expression'
|
|
184
|
+
| 'identifier'
|
|
185
|
+
| 'operator'
|
|
186
|
+
| 'literal';
|
|
187
|
+
|
|
188
|
+
export interface Token {
|
|
189
|
+
type: TokenType;
|
|
190
|
+
value: string;
|
|
191
|
+
translated?: string;
|
|
192
|
+
position: {
|
|
193
|
+
start: number;
|
|
194
|
+
end: number;
|
|
195
|
+
line: number;
|
|
196
|
+
column: number;
|
|
197
|
+
};
|
|
198
|
+
}
|
|
199
|
+
|
|
200
|
+
export interface TranslationResult {
|
|
201
|
+
translated: string;
|
|
202
|
+
original?: string;
|
|
203
|
+
tokens: Token[];
|
|
204
|
+
locale: {
|
|
205
|
+
from: string;
|
|
206
|
+
to: string;
|
|
207
|
+
};
|
|
208
|
+
warnings?: string[];
|
|
209
|
+
}
|
|
@@ -0,0 +1,190 @@
|
|
|
1
|
+
// packages/i18n/src/utils/locale.ts
|
|
2
|
+
|
|
3
|
+
import { dictionaries } from '../dictionaries';
|
|
4
|
+
import { DICTIONARY_CATEGORIES } from '../types';
|
|
5
|
+
|
|
6
|
+
export interface LocaleInfo {
|
|
7
|
+
code: string;
|
|
8
|
+
language: string;
|
|
9
|
+
region?: string;
|
|
10
|
+
script?: string;
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
/**
|
|
14
|
+
* Parse a locale string into its components
|
|
15
|
+
* Examples: 'en-US', 'zh-Hans-CN', 'es-419'
|
|
16
|
+
*/
|
|
17
|
+
export function parseLocale(locale: string): LocaleInfo {
|
|
18
|
+
const parts = locale.split('-');
|
|
19
|
+
const info: LocaleInfo = {
|
|
20
|
+
code: locale,
|
|
21
|
+
language: parts[0].toLowerCase(),
|
|
22
|
+
};
|
|
23
|
+
|
|
24
|
+
if (parts.length > 1) {
|
|
25
|
+
// Check if second part is a script (4 letters, first capitalized)
|
|
26
|
+
if (parts[1].length === 4 && /^[A-Z][a-z]{3}$/.test(parts[1])) {
|
|
27
|
+
info.script = parts[1];
|
|
28
|
+
if (parts[2]) {
|
|
29
|
+
info.region = parts[2].toUpperCase();
|
|
30
|
+
}
|
|
31
|
+
} else {
|
|
32
|
+
info.region = parts[1].toUpperCase();
|
|
33
|
+
}
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
return info;
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
/**
|
|
40
|
+
* Detect the language of hyperscript text
|
|
41
|
+
*/
|
|
42
|
+
export function detectLocale(text: string, supportedLocales: string[]): string {
|
|
43
|
+
const scores = new Map<string, number>();
|
|
44
|
+
|
|
45
|
+
// Initialize scores
|
|
46
|
+
supportedLocales.forEach(locale => scores.set(locale, 0));
|
|
47
|
+
|
|
48
|
+
// Count keyword matches for each locale using type-safe iteration
|
|
49
|
+
for (const [locale, dictionary] of Object.entries(dictionaries)) {
|
|
50
|
+
if (!supportedLocales.includes(locale)) continue;
|
|
51
|
+
|
|
52
|
+
let score = 0;
|
|
53
|
+
|
|
54
|
+
// Check all categories
|
|
55
|
+
for (const category of DICTIONARY_CATEGORIES) {
|
|
56
|
+
const translations = dictionary[category];
|
|
57
|
+
for (const keyword of Object.values(translations)) {
|
|
58
|
+
// Skip empty or single-character keywords (too ambiguous for detection)
|
|
59
|
+
if (!keyword || keyword.length < 2) continue;
|
|
60
|
+
|
|
61
|
+
// Use word boundary matching for accuracy
|
|
62
|
+
const regex = new RegExp(`\\b${escapeRegex(keyword)}\\b`, 'gi');
|
|
63
|
+
const matches = text.match(regex);
|
|
64
|
+
if (matches) {
|
|
65
|
+
score += matches.length;
|
|
66
|
+
}
|
|
67
|
+
}
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
scores.set(locale, score);
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
// Find locale with highest score
|
|
74
|
+
let maxScore = 0;
|
|
75
|
+
let detectedLocale = 'en'; // Default to English
|
|
76
|
+
|
|
77
|
+
scores.forEach((score, locale) => {
|
|
78
|
+
if (score > maxScore) {
|
|
79
|
+
maxScore = score;
|
|
80
|
+
detectedLocale = locale;
|
|
81
|
+
}
|
|
82
|
+
});
|
|
83
|
+
|
|
84
|
+
return detectedLocale;
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
/**
|
|
88
|
+
* Get the best matching locale from available locales
|
|
89
|
+
*/
|
|
90
|
+
export function getBestMatchingLocale(
|
|
91
|
+
requestedLocale: string,
|
|
92
|
+
availableLocales: string[]
|
|
93
|
+
): string | null {
|
|
94
|
+
// Exact match
|
|
95
|
+
if (availableLocales.includes(requestedLocale)) {
|
|
96
|
+
return requestedLocale;
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
const requested = parseLocale(requestedLocale);
|
|
100
|
+
|
|
101
|
+
// Try language-only match
|
|
102
|
+
const languageMatch = availableLocales.find(locale => {
|
|
103
|
+
const available = parseLocale(locale);
|
|
104
|
+
return available.language === requested.language;
|
|
105
|
+
});
|
|
106
|
+
|
|
107
|
+
if (languageMatch) {
|
|
108
|
+
return languageMatch;
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
// Try language-script match
|
|
112
|
+
if (requested.script) {
|
|
113
|
+
const scriptMatch = availableLocales.find(locale => {
|
|
114
|
+
const available = parseLocale(locale);
|
|
115
|
+
return available.language === requested.language && available.script === requested.script;
|
|
116
|
+
});
|
|
117
|
+
|
|
118
|
+
if (scriptMatch) {
|
|
119
|
+
return scriptMatch;
|
|
120
|
+
}
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
return null;
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
/**
|
|
127
|
+
* Get user's preferred locales from browser
|
|
128
|
+
*/
|
|
129
|
+
export function getBrowserLocales(): string[] {
|
|
130
|
+
if (typeof window === 'undefined') {
|
|
131
|
+
return ['en'];
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
const locales: string[] = [];
|
|
135
|
+
|
|
136
|
+
// Modern browser API
|
|
137
|
+
if (navigator.languages && navigator.languages.length > 0) {
|
|
138
|
+
locales.push(...navigator.languages);
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
// Fallback to single language
|
|
142
|
+
if (navigator.language) {
|
|
143
|
+
locales.push(navigator.language);
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
// Legacy IE
|
|
147
|
+
if ((navigator as any).userLanguage) {
|
|
148
|
+
locales.push((navigator as any).userLanguage);
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
// Remove duplicates and return
|
|
152
|
+
return [...new Set(locales)];
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
/**
|
|
156
|
+
* Escape special regex characters
|
|
157
|
+
*/
|
|
158
|
+
function escapeRegex(str: string): string {
|
|
159
|
+
return str.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
/**
|
|
163
|
+
* Format a locale for display
|
|
164
|
+
*/
|
|
165
|
+
export function formatLocaleName(locale: string): string {
|
|
166
|
+
const names: Record<string, string> = {
|
|
167
|
+
en: 'English',
|
|
168
|
+
es: 'Español',
|
|
169
|
+
ko: '한국어',
|
|
170
|
+
zh: '中文',
|
|
171
|
+
'zh-TW': '繁體中文',
|
|
172
|
+
ja: '日本語',
|
|
173
|
+
fr: 'Français',
|
|
174
|
+
de: 'Deutsch',
|
|
175
|
+
pt: 'Português',
|
|
176
|
+
hi: 'हिन्दी',
|
|
177
|
+
ar: 'العربية',
|
|
178
|
+
};
|
|
179
|
+
|
|
180
|
+
return names[locale] || locale;
|
|
181
|
+
}
|
|
182
|
+
|
|
183
|
+
/**
|
|
184
|
+
* Check if a locale uses RTL writing
|
|
185
|
+
*/
|
|
186
|
+
export function isRTL(locale: string): boolean {
|
|
187
|
+
const rtlLocales = ['ar', 'he', 'fa', 'ur', 'yi', 'ji', 'ku', 'dv'];
|
|
188
|
+
const info = parseLocale(locale);
|
|
189
|
+
return rtlLocales.includes(info.language);
|
|
190
|
+
}
|