@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
package/src/runtime.ts
ADDED
|
@@ -0,0 +1,441 @@
|
|
|
1
|
+
// packages/i18n/src/runtime.ts
|
|
2
|
+
|
|
3
|
+
import { HyperscriptTranslator } from './translator';
|
|
4
|
+
import { Dictionary, I18nConfig, TranslationOptions } from './types';
|
|
5
|
+
import { getBrowserLocales, isRTL } from './utils/locale';
|
|
6
|
+
import { getFormatter } from './formatting';
|
|
7
|
+
|
|
8
|
+
/**
|
|
9
|
+
* Runtime i18n manager for client-side locale switching
|
|
10
|
+
*/
|
|
11
|
+
export interface RuntimeI18nOptions extends I18nConfig {
|
|
12
|
+
autoDetect?: boolean;
|
|
13
|
+
storageKey?: string;
|
|
14
|
+
urlParam?: string;
|
|
15
|
+
cookieName?: string;
|
|
16
|
+
updateURL?: boolean;
|
|
17
|
+
updateTitle?: boolean;
|
|
18
|
+
updateLang?: boolean;
|
|
19
|
+
updateDir?: boolean;
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
export class RuntimeI18nManager {
|
|
23
|
+
private translator: HyperscriptTranslator;
|
|
24
|
+
private options: Required<RuntimeI18nOptions>;
|
|
25
|
+
private currentLocale: string;
|
|
26
|
+
private observers: Set<(locale: string) => void> = new Set();
|
|
27
|
+
private initialized = false;
|
|
28
|
+
|
|
29
|
+
constructor(options: Partial<RuntimeI18nOptions> = {}) {
|
|
30
|
+
this.options = {
|
|
31
|
+
locale: options.locale || 'en',
|
|
32
|
+
fallbackLocale: options.fallbackLocale || 'en',
|
|
33
|
+
dictionaries: options.dictionaries || {},
|
|
34
|
+
detectLocale: options.detectLocale ?? true,
|
|
35
|
+
rtlLocales: options.rtlLocales || ['ar', 'he', 'fa', 'ur'],
|
|
36
|
+
preserveOriginalAttribute: options.preserveOriginalAttribute || 'data-i18n-original',
|
|
37
|
+
autoDetect: options.autoDetect ?? true,
|
|
38
|
+
storageKey: options.storageKey || 'hyperfixi-locale',
|
|
39
|
+
urlParam: options.urlParam || 'lang',
|
|
40
|
+
cookieName: options.cookieName || 'hyperfixi-locale',
|
|
41
|
+
updateURL: options.updateURL ?? false,
|
|
42
|
+
updateTitle: options.updateTitle ?? true,
|
|
43
|
+
updateLang: options.updateLang ?? true,
|
|
44
|
+
updateDir: options.updateDir ?? true,
|
|
45
|
+
};
|
|
46
|
+
|
|
47
|
+
this.translator = new HyperscriptTranslator(this.options);
|
|
48
|
+
this.currentLocale = this.options.locale;
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
/**
|
|
52
|
+
* Initialize the runtime i18n system
|
|
53
|
+
*/
|
|
54
|
+
async initialize(): Promise<void> {
|
|
55
|
+
if (this.initialized) return;
|
|
56
|
+
|
|
57
|
+
if (typeof window !== 'undefined') {
|
|
58
|
+
// Detect initial locale
|
|
59
|
+
const detectedLocale = this.detectInitialLocale();
|
|
60
|
+
if (detectedLocale !== this.currentLocale) {
|
|
61
|
+
await this.setLocale(detectedLocale, false);
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
// Set up event listeners
|
|
65
|
+
this.setupEventListeners();
|
|
66
|
+
|
|
67
|
+
// Translate existing elements
|
|
68
|
+
this.translatePage();
|
|
69
|
+
|
|
70
|
+
// Update document attributes
|
|
71
|
+
this.updateDocumentAttributes();
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
this.initialized = true;
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
/**
|
|
78
|
+
* Set the current locale
|
|
79
|
+
*/
|
|
80
|
+
async setLocale(locale: string, updateStorage = true): Promise<void> {
|
|
81
|
+
if (locale === this.currentLocale) return;
|
|
82
|
+
|
|
83
|
+
const oldLocale = this.currentLocale;
|
|
84
|
+
this.currentLocale = locale;
|
|
85
|
+
|
|
86
|
+
// Update storage if requested
|
|
87
|
+
if (updateStorage && typeof window !== 'undefined') {
|
|
88
|
+
this.saveLocaleToStorage(locale);
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
// Update URL if requested
|
|
92
|
+
if (this.options.updateURL && typeof window !== 'undefined') {
|
|
93
|
+
this.updateURL(locale);
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
// Update document attributes
|
|
97
|
+
if (typeof document !== 'undefined') {
|
|
98
|
+
this.updateDocumentAttributes();
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
// Translate the page
|
|
102
|
+
if (typeof document !== 'undefined') {
|
|
103
|
+
this.translatePage(oldLocale);
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
// Notify observers
|
|
107
|
+
this.notifyObservers(locale);
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
/**
|
|
111
|
+
* Get the current locale
|
|
112
|
+
*/
|
|
113
|
+
getLocale(): string {
|
|
114
|
+
return this.currentLocale;
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
/**
|
|
118
|
+
* Get supported locales
|
|
119
|
+
*/
|
|
120
|
+
getSupportedLocales(): string[] {
|
|
121
|
+
return this.translator.getSupportedLocales();
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
/**
|
|
125
|
+
* Check if a locale is RTL
|
|
126
|
+
*/
|
|
127
|
+
isRTL(locale?: string): boolean {
|
|
128
|
+
return isRTL(locale || this.currentLocale);
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
/**
|
|
132
|
+
* Translate a hyperscript string
|
|
133
|
+
*/
|
|
134
|
+
translate(text: string, options: Partial<TranslationOptions> = {}): string {
|
|
135
|
+
return this.translator.translate(text, {
|
|
136
|
+
from: options.from || 'en',
|
|
137
|
+
to: options.to || this.currentLocale,
|
|
138
|
+
...options,
|
|
139
|
+
});
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
/**
|
|
143
|
+
* Format a value according to current locale
|
|
144
|
+
*/
|
|
145
|
+
format(value: any, type?: string): string {
|
|
146
|
+
return getFormatter(this.currentLocale).formatHyperscriptValue(value, type);
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
/**
|
|
150
|
+
* Add dictionary for a locale
|
|
151
|
+
*/
|
|
152
|
+
addDictionary(locale: string, dictionary: Dictionary): void {
|
|
153
|
+
this.translator.addDictionary(locale, dictionary);
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
/**
|
|
157
|
+
* Subscribe to locale changes
|
|
158
|
+
*/
|
|
159
|
+
onLocaleChange(callback: (locale: string) => void): () => void {
|
|
160
|
+
this.observers.add(callback);
|
|
161
|
+
return () => this.observers.delete(callback);
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
/**
|
|
165
|
+
* Create a locale switcher element
|
|
166
|
+
*/
|
|
167
|
+
createLocaleSwitcher(
|
|
168
|
+
options: {
|
|
169
|
+
type?: 'dropdown' | 'buttons';
|
|
170
|
+
className?: string;
|
|
171
|
+
showNativeNames?: boolean;
|
|
172
|
+
} = {}
|
|
173
|
+
): HTMLElement {
|
|
174
|
+
const { type = 'dropdown', className = 'locale-switcher', showNativeNames = true } = options;
|
|
175
|
+
const supportedLocales = this.getSupportedLocales();
|
|
176
|
+
|
|
177
|
+
if (type === 'dropdown') {
|
|
178
|
+
const select = document.createElement('select');
|
|
179
|
+
select.className = className;
|
|
180
|
+
|
|
181
|
+
supportedLocales.forEach(locale => {
|
|
182
|
+
const option = document.createElement('option');
|
|
183
|
+
option.value = locale;
|
|
184
|
+
option.textContent = this.getLocaleDisplayName(locale, showNativeNames);
|
|
185
|
+
option.selected = locale === this.currentLocale;
|
|
186
|
+
select.appendChild(option);
|
|
187
|
+
});
|
|
188
|
+
|
|
189
|
+
select.addEventListener('change', e => {
|
|
190
|
+
const target = e.target as HTMLSelectElement;
|
|
191
|
+
this.setLocale(target.value);
|
|
192
|
+
});
|
|
193
|
+
|
|
194
|
+
return select;
|
|
195
|
+
} else {
|
|
196
|
+
const container = document.createElement('div');
|
|
197
|
+
container.className = className;
|
|
198
|
+
|
|
199
|
+
supportedLocales.forEach(locale => {
|
|
200
|
+
const button = document.createElement('button');
|
|
201
|
+
button.textContent = this.getLocaleDisplayName(locale, showNativeNames);
|
|
202
|
+
button.dataset.locale = locale;
|
|
203
|
+
button.className = locale === this.currentLocale ? 'active' : '';
|
|
204
|
+
|
|
205
|
+
button.addEventListener('click', () => {
|
|
206
|
+
this.setLocale(locale);
|
|
207
|
+
});
|
|
208
|
+
|
|
209
|
+
container.appendChild(button);
|
|
210
|
+
});
|
|
211
|
+
|
|
212
|
+
return container;
|
|
213
|
+
}
|
|
214
|
+
}
|
|
215
|
+
|
|
216
|
+
/**
|
|
217
|
+
* Detect initial locale from various sources
|
|
218
|
+
*/
|
|
219
|
+
private detectInitialLocale(): string {
|
|
220
|
+
if (!this.options.autoDetect || typeof window === 'undefined') {
|
|
221
|
+
return this.options.locale;
|
|
222
|
+
}
|
|
223
|
+
|
|
224
|
+
// 1. URL parameter
|
|
225
|
+
const urlParams = new URLSearchParams(window.location.search);
|
|
226
|
+
const urlLocale = urlParams.get(this.options.urlParam);
|
|
227
|
+
if (urlLocale && this.translator.getSupportedLocales().includes(urlLocale)) {
|
|
228
|
+
return urlLocale;
|
|
229
|
+
}
|
|
230
|
+
|
|
231
|
+
// 2. Local storage
|
|
232
|
+
const storageLocale = localStorage.getItem(this.options.storageKey);
|
|
233
|
+
if (storageLocale && this.translator.getSupportedLocales().includes(storageLocale)) {
|
|
234
|
+
return storageLocale;
|
|
235
|
+
}
|
|
236
|
+
|
|
237
|
+
// 3. Cookie
|
|
238
|
+
const cookieLocale = this.getCookie(this.options.cookieName);
|
|
239
|
+
if (cookieLocale && this.translator.getSupportedLocales().includes(cookieLocale)) {
|
|
240
|
+
return cookieLocale;
|
|
241
|
+
}
|
|
242
|
+
|
|
243
|
+
// 4. Browser languages
|
|
244
|
+
const browserLocales = getBrowserLocales();
|
|
245
|
+
for (const locale of browserLocales) {
|
|
246
|
+
if (this.translator.getSupportedLocales().includes(locale)) {
|
|
247
|
+
return locale;
|
|
248
|
+
}
|
|
249
|
+
// Try language-only match
|
|
250
|
+
const lang = locale.split('-')[0];
|
|
251
|
+
if (this.translator.getSupportedLocales().includes(lang)) {
|
|
252
|
+
return lang;
|
|
253
|
+
}
|
|
254
|
+
}
|
|
255
|
+
|
|
256
|
+
return this.options.fallbackLocale;
|
|
257
|
+
}
|
|
258
|
+
|
|
259
|
+
/**
|
|
260
|
+
* Save locale to storage
|
|
261
|
+
*/
|
|
262
|
+
private saveLocaleToStorage(locale: string): void {
|
|
263
|
+
try {
|
|
264
|
+
localStorage.setItem(this.options.storageKey, locale);
|
|
265
|
+
this.setCookie(this.options.cookieName, locale, 365);
|
|
266
|
+
} catch (error) {
|
|
267
|
+
console.warn('Failed to save locale to storage:', error);
|
|
268
|
+
}
|
|
269
|
+
}
|
|
270
|
+
|
|
271
|
+
/**
|
|
272
|
+
* Update URL with current locale
|
|
273
|
+
*/
|
|
274
|
+
private updateURL(locale: string): void {
|
|
275
|
+
const url = new URL(window.location.href);
|
|
276
|
+
url.searchParams.set(this.options.urlParam, locale);
|
|
277
|
+
window.history.replaceState({}, '', url.toString());
|
|
278
|
+
}
|
|
279
|
+
|
|
280
|
+
/**
|
|
281
|
+
* Update document attributes
|
|
282
|
+
*/
|
|
283
|
+
private updateDocumentAttributes(): void {
|
|
284
|
+
if (typeof document === 'undefined') return;
|
|
285
|
+
|
|
286
|
+
if (this.options.updateLang) {
|
|
287
|
+
document.documentElement.lang = this.currentLocale;
|
|
288
|
+
}
|
|
289
|
+
|
|
290
|
+
if (this.options.updateDir) {
|
|
291
|
+
document.documentElement.dir = this.isRTL() ? 'rtl' : 'ltr';
|
|
292
|
+
}
|
|
293
|
+
|
|
294
|
+
if (this.options.updateTitle && document.title) {
|
|
295
|
+
// Optionally translate the title if it contains hyperscript-like content
|
|
296
|
+
// This is a simple implementation - could be enhanced
|
|
297
|
+
document.title = this.translate(document.title);
|
|
298
|
+
}
|
|
299
|
+
}
|
|
300
|
+
|
|
301
|
+
/**
|
|
302
|
+
* Translate the entire page
|
|
303
|
+
*/
|
|
304
|
+
private translatePage(fromLocale?: string): void {
|
|
305
|
+
if (typeof document === 'undefined') return;
|
|
306
|
+
|
|
307
|
+
// Find all elements with hyperscript attributes
|
|
308
|
+
const attributes = ['_', 'data-script', 'script'];
|
|
309
|
+
|
|
310
|
+
attributes.forEach(attr => {
|
|
311
|
+
const elements = document.querySelectorAll(`[${attr}]`);
|
|
312
|
+
elements.forEach(element => {
|
|
313
|
+
const original = element.getAttribute(attr);
|
|
314
|
+
if (!original) return;
|
|
315
|
+
|
|
316
|
+
try {
|
|
317
|
+
const translated = this.translator.translate(original, {
|
|
318
|
+
from: fromLocale || 'en',
|
|
319
|
+
to: this.currentLocale,
|
|
320
|
+
});
|
|
321
|
+
|
|
322
|
+
if (translated !== original) {
|
|
323
|
+
// Preserve original if configured
|
|
324
|
+
if (this.options.preserveOriginalAttribute) {
|
|
325
|
+
element.setAttribute(this.options.preserveOriginalAttribute, original);
|
|
326
|
+
}
|
|
327
|
+
|
|
328
|
+
element.setAttribute(attr, translated);
|
|
329
|
+
}
|
|
330
|
+
} catch (error) {
|
|
331
|
+
console.warn(`Failed to translate ${attr} attribute:`, error);
|
|
332
|
+
}
|
|
333
|
+
});
|
|
334
|
+
});
|
|
335
|
+
}
|
|
336
|
+
|
|
337
|
+
/**
|
|
338
|
+
* Set up event listeners
|
|
339
|
+
*/
|
|
340
|
+
private setupEventListeners(): void {
|
|
341
|
+
// Listen for language change events
|
|
342
|
+
window.addEventListener('languagechange', () => {
|
|
343
|
+
if (this.options.autoDetect) {
|
|
344
|
+
const newLocale = this.detectInitialLocale();
|
|
345
|
+
if (newLocale !== this.currentLocale) {
|
|
346
|
+
this.setLocale(newLocale);
|
|
347
|
+
}
|
|
348
|
+
}
|
|
349
|
+
});
|
|
350
|
+
|
|
351
|
+
// Listen for popstate events (back/forward navigation)
|
|
352
|
+
window.addEventListener('popstate', () => {
|
|
353
|
+
if (this.options.updateURL) {
|
|
354
|
+
const urlParams = new URLSearchParams(window.location.search);
|
|
355
|
+
const urlLocale = urlParams.get(this.options.urlParam);
|
|
356
|
+
if (urlLocale && urlLocale !== this.currentLocale) {
|
|
357
|
+
this.setLocale(urlLocale, false);
|
|
358
|
+
}
|
|
359
|
+
}
|
|
360
|
+
});
|
|
361
|
+
}
|
|
362
|
+
|
|
363
|
+
/**
|
|
364
|
+
* Notify observers of locale change
|
|
365
|
+
*/
|
|
366
|
+
private notifyObservers(locale: string): void {
|
|
367
|
+
this.observers.forEach(callback => {
|
|
368
|
+
try {
|
|
369
|
+
callback(locale);
|
|
370
|
+
} catch (error) {
|
|
371
|
+
console.warn('Locale change observer error:', error);
|
|
372
|
+
}
|
|
373
|
+
});
|
|
374
|
+
}
|
|
375
|
+
|
|
376
|
+
/**
|
|
377
|
+
* Get locale display name
|
|
378
|
+
*/
|
|
379
|
+
private getLocaleDisplayName(locale: string, showNative: boolean): string {
|
|
380
|
+
const names: Record<string, { english: string; native: string }> = {
|
|
381
|
+
en: { english: 'English', native: 'English' },
|
|
382
|
+
es: { english: 'Spanish', native: 'Español' },
|
|
383
|
+
fr: { english: 'French', native: 'Français' },
|
|
384
|
+
de: { english: 'German', native: 'Deutsch' },
|
|
385
|
+
ja: { english: 'Japanese', native: '日本語' },
|
|
386
|
+
ko: { english: 'Korean', native: '한국어' },
|
|
387
|
+
zh: { english: 'Chinese', native: '中文' },
|
|
388
|
+
ar: { english: 'Arabic', native: 'العربية' },
|
|
389
|
+
};
|
|
390
|
+
|
|
391
|
+
const info = names[locale];
|
|
392
|
+
if (!info) return locale;
|
|
393
|
+
|
|
394
|
+
return showNative ? info.native : info.english;
|
|
395
|
+
}
|
|
396
|
+
|
|
397
|
+
/**
|
|
398
|
+
* Cookie utilities
|
|
399
|
+
*/
|
|
400
|
+
private getCookie(name: string): string | null {
|
|
401
|
+
if (typeof document === 'undefined') return null;
|
|
402
|
+
|
|
403
|
+
const value = `; ${document.cookie}`;
|
|
404
|
+
const parts = value.split(`; ${name}=`);
|
|
405
|
+
if (parts.length === 2) {
|
|
406
|
+
return parts.pop()?.split(';').shift() || null;
|
|
407
|
+
}
|
|
408
|
+
return null;
|
|
409
|
+
}
|
|
410
|
+
|
|
411
|
+
private setCookie(name: string, value: string, days: number): void {
|
|
412
|
+
if (typeof document === 'undefined') return;
|
|
413
|
+
|
|
414
|
+
const expires = new Date();
|
|
415
|
+
expires.setTime(expires.getTime() + days * 24 * 60 * 60 * 1000);
|
|
416
|
+
document.cookie = `${name}=${value};expires=${expires.toUTCString()};path=/`;
|
|
417
|
+
}
|
|
418
|
+
}
|
|
419
|
+
|
|
420
|
+
/**
|
|
421
|
+
* Global runtime instance
|
|
422
|
+
*/
|
|
423
|
+
export let runtimeI18n: RuntimeI18nManager | null = null;
|
|
424
|
+
|
|
425
|
+
/**
|
|
426
|
+
* Initialize global runtime i18n
|
|
427
|
+
*/
|
|
428
|
+
export function initializeI18n(options?: RuntimeI18nOptions): RuntimeI18nManager {
|
|
429
|
+
runtimeI18n = new RuntimeI18nManager(options);
|
|
430
|
+
return runtimeI18n;
|
|
431
|
+
}
|
|
432
|
+
|
|
433
|
+
/**
|
|
434
|
+
* Get global runtime i18n instance
|
|
435
|
+
*/
|
|
436
|
+
export function getI18n(): RuntimeI18nManager {
|
|
437
|
+
if (!runtimeI18n) {
|
|
438
|
+
throw new Error('I18n not initialized. Call initializeI18n() first.');
|
|
439
|
+
}
|
|
440
|
+
return runtimeI18n;
|
|
441
|
+
}
|
|
@@ -0,0 +1,225 @@
|
|
|
1
|
+
// packages/i18n/src/ssr-integration.ts
|
|
2
|
+
|
|
3
|
+
import { HyperscriptTranslator } from './translator';
|
|
4
|
+
import type { TranslationOptions } from './types';
|
|
5
|
+
|
|
6
|
+
/**
|
|
7
|
+
* SSR integration for i18n support
|
|
8
|
+
*/
|
|
9
|
+
export interface SSRLocaleContext {
|
|
10
|
+
locale: string;
|
|
11
|
+
direction: 'ltr' | 'rtl';
|
|
12
|
+
preferredLocales: string[];
|
|
13
|
+
userAgent?: string;
|
|
14
|
+
acceptLanguage?: string;
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
export interface SSRLocaleOptions {
|
|
18
|
+
detectFromHeaders?: boolean;
|
|
19
|
+
detectFromUrl?: boolean;
|
|
20
|
+
fallbackLocale?: string;
|
|
21
|
+
supportedLocales?: string[];
|
|
22
|
+
urlPattern?: RegExp;
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
export class SSRLocaleManager {
|
|
26
|
+
private translator: HyperscriptTranslator;
|
|
27
|
+
private options: Required<SSRLocaleOptions>;
|
|
28
|
+
|
|
29
|
+
constructor(translator: HyperscriptTranslator, options: SSRLocaleOptions = {}) {
|
|
30
|
+
this.translator = translator;
|
|
31
|
+
this.options = {
|
|
32
|
+
detectFromHeaders: options.detectFromHeaders ?? true,
|
|
33
|
+
detectFromUrl: options.detectFromUrl ?? true,
|
|
34
|
+
fallbackLocale: options.fallbackLocale ?? 'en',
|
|
35
|
+
supportedLocales: options.supportedLocales ?? translator.getSupportedLocales(),
|
|
36
|
+
urlPattern: options.urlPattern ?? /^\/([a-z]{2}(-[A-Z]{2})?)(\/|$)/,
|
|
37
|
+
};
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
/**
|
|
41
|
+
* Extract locale from SSR request
|
|
42
|
+
*/
|
|
43
|
+
extractLocale(request: {
|
|
44
|
+
url?: string;
|
|
45
|
+
headers?: Record<string, string>;
|
|
46
|
+
userAgent?: string;
|
|
47
|
+
}): SSRLocaleContext {
|
|
48
|
+
let locale = this.options.fallbackLocale;
|
|
49
|
+
const preferredLocales: string[] = [];
|
|
50
|
+
|
|
51
|
+
// Extract from URL first (highest priority)
|
|
52
|
+
if (this.options.detectFromUrl && request.url) {
|
|
53
|
+
const urlLocale = this.extractLocaleFromUrl(request.url);
|
|
54
|
+
if (urlLocale && this.options.supportedLocales.includes(urlLocale)) {
|
|
55
|
+
locale = urlLocale;
|
|
56
|
+
}
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
// Extract from Accept-Language header
|
|
60
|
+
if (this.options.detectFromHeaders && request.headers?.['accept-language']) {
|
|
61
|
+
const headerLocales = this.parseAcceptLanguage(request.headers['accept-language']);
|
|
62
|
+
preferredLocales.push(...headerLocales);
|
|
63
|
+
|
|
64
|
+
// Use first supported locale from header if URL didn't provide one
|
|
65
|
+
if (locale === this.options.fallbackLocale) {
|
|
66
|
+
const supportedHeaderLocale = headerLocales.find(loc =>
|
|
67
|
+
this.options.supportedLocales.includes(loc)
|
|
68
|
+
);
|
|
69
|
+
if (supportedHeaderLocale) {
|
|
70
|
+
locale = supportedHeaderLocale;
|
|
71
|
+
}
|
|
72
|
+
}
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
const context: SSRLocaleContext = {
|
|
76
|
+
locale,
|
|
77
|
+
direction: this.translator.isRTL(locale) ? 'rtl' : 'ltr',
|
|
78
|
+
preferredLocales,
|
|
79
|
+
};
|
|
80
|
+
if (request.userAgent) {
|
|
81
|
+
context.userAgent = request.userAgent;
|
|
82
|
+
}
|
|
83
|
+
if (request.headers?.['accept-language']) {
|
|
84
|
+
context.acceptLanguage = request.headers['accept-language'];
|
|
85
|
+
}
|
|
86
|
+
return context;
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
/**
|
|
90
|
+
* Generate HTML lang and dir attributes
|
|
91
|
+
*/
|
|
92
|
+
generateHtmlAttributes(context: SSRLocaleContext): string {
|
|
93
|
+
return `lang="${context.locale}" dir="${context.direction}"`;
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
/**
|
|
97
|
+
* Generate meta tags for SEO
|
|
98
|
+
*/
|
|
99
|
+
generateMetaTags(context: SSRLocaleContext, alternateUrls?: Record<string, string>): string[] {
|
|
100
|
+
const tags: string[] = [];
|
|
101
|
+
|
|
102
|
+
// Content language
|
|
103
|
+
tags.push(`<meta http-equiv="content-language" content="${context.locale}" />`);
|
|
104
|
+
|
|
105
|
+
// Alternate languages for SEO
|
|
106
|
+
if (alternateUrls) {
|
|
107
|
+
Object.entries(alternateUrls).forEach(([locale, url]) => {
|
|
108
|
+
tags.push(`<link rel="alternate" hreflang="${locale}" href="${url}" />`);
|
|
109
|
+
});
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
return tags;
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
/**
|
|
116
|
+
* Translate hyperscript code for SSR
|
|
117
|
+
*/
|
|
118
|
+
translateForSSR(
|
|
119
|
+
hyperscriptCode: string,
|
|
120
|
+
targetLocale: string,
|
|
121
|
+
options: Partial<Omit<TranslationOptions, 'to'>> = {}
|
|
122
|
+
): string {
|
|
123
|
+
return this.translator.translate(hyperscriptCode, {
|
|
124
|
+
from: 'en',
|
|
125
|
+
...options,
|
|
126
|
+
to: targetLocale,
|
|
127
|
+
});
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
/**
|
|
131
|
+
* Generate client-side hydration data
|
|
132
|
+
*/
|
|
133
|
+
generateHydrationData(context: SSRLocaleContext): object {
|
|
134
|
+
return {
|
|
135
|
+
__HYPERFIXI_I18N__: {
|
|
136
|
+
locale: context.locale,
|
|
137
|
+
direction: context.direction,
|
|
138
|
+
preferredLocales: context.preferredLocales,
|
|
139
|
+
supportedLocales: this.options.supportedLocales,
|
|
140
|
+
fallbackLocale: this.options.fallbackLocale,
|
|
141
|
+
},
|
|
142
|
+
};
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
private extractLocaleFromUrl(url: string): string | null {
|
|
146
|
+
const match = url.match(this.options.urlPattern);
|
|
147
|
+
return match ? match[1] : null;
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
private parseAcceptLanguage(acceptLanguage: string): string[] {
|
|
151
|
+
return acceptLanguage
|
|
152
|
+
.split(',')
|
|
153
|
+
.map(lang => {
|
|
154
|
+
const [locale, q] = lang.trim().split(';q=');
|
|
155
|
+
return {
|
|
156
|
+
locale: locale.trim(),
|
|
157
|
+
quality: q ? parseFloat(q) : 1.0,
|
|
158
|
+
};
|
|
159
|
+
})
|
|
160
|
+
.sort((a, b) => b.quality - a.quality)
|
|
161
|
+
.map(item => item.locale);
|
|
162
|
+
}
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
/**
|
|
166
|
+
* Express middleware for SSR i18n
|
|
167
|
+
*/
|
|
168
|
+
export function createExpressI18nMiddleware(
|
|
169
|
+
translator: HyperscriptTranslator,
|
|
170
|
+
options?: SSRLocaleOptions
|
|
171
|
+
) {
|
|
172
|
+
const localeManager = new SSRLocaleManager(translator, options);
|
|
173
|
+
|
|
174
|
+
return (req: any, _res: any, next: any) => {
|
|
175
|
+
const localeContext = localeManager.extractLocale({
|
|
176
|
+
url: req.originalUrl || req.url,
|
|
177
|
+
headers: req.headers,
|
|
178
|
+
userAgent: req.get('User-Agent'),
|
|
179
|
+
});
|
|
180
|
+
|
|
181
|
+
// Add to request for later use
|
|
182
|
+
req.localeContext = localeContext;
|
|
183
|
+
req.i18n = {
|
|
184
|
+
translate: (code: string, targetLocale?: string) =>
|
|
185
|
+
localeManager.translateForSSR(code, targetLocale || localeContext.locale),
|
|
186
|
+
generateHtmlAttributes: () => localeManager.generateHtmlAttributes(localeContext),
|
|
187
|
+
generateMetaTags: (alternateUrls?: Record<string, string>) =>
|
|
188
|
+
localeManager.generateMetaTags(localeContext, alternateUrls),
|
|
189
|
+
generateHydrationData: () => localeManager.generateHydrationData(localeContext),
|
|
190
|
+
};
|
|
191
|
+
|
|
192
|
+
next();
|
|
193
|
+
};
|
|
194
|
+
}
|
|
195
|
+
|
|
196
|
+
/**
|
|
197
|
+
* Next.js API for SSR i18n
|
|
198
|
+
*/
|
|
199
|
+
export function withI18n(
|
|
200
|
+
handler: any,
|
|
201
|
+
translator: HyperscriptTranslator,
|
|
202
|
+
options?: SSRLocaleOptions
|
|
203
|
+
) {
|
|
204
|
+
const localeManager = new SSRLocaleManager(translator, options);
|
|
205
|
+
|
|
206
|
+
return async (req: any, res: any) => {
|
|
207
|
+
const localeContext = localeManager.extractLocale({
|
|
208
|
+
url: req.url,
|
|
209
|
+
headers: req.headers,
|
|
210
|
+
userAgent: req.headers['user-agent'],
|
|
211
|
+
});
|
|
212
|
+
|
|
213
|
+
req.localeContext = localeContext;
|
|
214
|
+
req.i18n = {
|
|
215
|
+
translate: (code: string, targetLocale?: string) =>
|
|
216
|
+
localeManager.translateForSSR(code, targetLocale || localeContext.locale),
|
|
217
|
+
generateHtmlAttributes: () => localeManager.generateHtmlAttributes(localeContext),
|
|
218
|
+
generateMetaTags: (alternateUrls?: Record<string, string>) =>
|
|
219
|
+
localeManager.generateMetaTags(localeContext, alternateUrls),
|
|
220
|
+
generateHydrationData: () => localeManager.generateHydrationData(localeContext),
|
|
221
|
+
};
|
|
222
|
+
|
|
223
|
+
return handler(req, res);
|
|
224
|
+
};
|
|
225
|
+
}
|