@od-oneapp/internationalization 2026.1.1301

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.
Files changed (80) hide show
  1. package/README.md +164 -0
  2. package/dist/client-next.d.mts +4 -0
  3. package/dist/client-next.mjs +4 -0
  4. package/dist/client.d.mts +24 -0
  5. package/dist/client.d.mts.map +1 -0
  6. package/dist/client.mjs +16 -0
  7. package/dist/client.mjs.map +1 -0
  8. package/dist/en-BG_-Eo3m.mjs +192 -0
  9. package/dist/en-BG_-Eo3m.mjs.map +1 -0
  10. package/dist/middleware-DgXJ2JFB.mjs +10 -0
  11. package/dist/middleware-DgXJ2JFB.mjs.map +1 -0
  12. package/dist/middleware.d.mts +10 -0
  13. package/dist/middleware.d.mts.map +1 -0
  14. package/dist/middleware.mjs +17 -0
  15. package/dist/middleware.mjs.map +1 -0
  16. package/dist/navigation-BCo6Vugt.mjs +39 -0
  17. package/dist/navigation-BCo6Vugt.mjs.map +1 -0
  18. package/dist/navigation-BSuDX6-H.d.mts +20 -0
  19. package/dist/navigation-BSuDX6-H.d.mts.map +1 -0
  20. package/dist/navigation-Di2SkyuD.mjs +20 -0
  21. package/dist/navigation-Di2SkyuD.mjs.map +1 -0
  22. package/dist/next-intl-server-CnBEO3Z3.mjs +25 -0
  23. package/dist/next-intl-server-CnBEO3Z3.mjs.map +1 -0
  24. package/dist/react19-cache-DHL04P0L.mjs +485 -0
  25. package/dist/react19-cache-DHL04P0L.mjs.map +1 -0
  26. package/dist/request.d.mts +5 -0
  27. package/dist/request.d.mts.map +1 -0
  28. package/dist/request.mjs +114 -0
  29. package/dist/request.mjs.map +1 -0
  30. package/dist/routing-CK71AHzC.mjs +80 -0
  31. package/dist/routing-CK71AHzC.mjs.map +1 -0
  32. package/dist/routing-Cqn-FuJK.mjs +9 -0
  33. package/dist/routing-Cqn-FuJK.mjs.map +1 -0
  34. package/dist/routing.d.mts +13 -0
  35. package/dist/routing.d.mts.map +1 -0
  36. package/dist/routing.mjs +3 -0
  37. package/dist/server-CiAHG84s.d.mts +167 -0
  38. package/dist/server-CiAHG84s.d.mts.map +1 -0
  39. package/dist/server-next.d.mts +37 -0
  40. package/dist/server-next.d.mts.map +1 -0
  41. package/dist/server-next.mjs +52 -0
  42. package/dist/server-next.mjs.map +1 -0
  43. package/dist/server.d.mts +3 -0
  44. package/dist/server.mjs +12 -0
  45. package/dist/server.mjs.map +1 -0
  46. package/package.json +104 -0
  47. package/src/__tests__/client.test.ts +37 -0
  48. package/src/__tests__/dictionary-loader.test.ts +228 -0
  49. package/src/__tests__/exports.test.ts +76 -0
  50. package/src/__tests__/extend.test.ts +55 -0
  51. package/src/__tests__/integration.test.ts +341 -0
  52. package/src/__tests__/middleware.test.ts +38 -0
  53. package/src/__tests__/navigation.test.ts +64 -0
  54. package/src/__tests__/request.test.ts +151 -0
  55. package/src/__tests__/routing.test.ts +39 -0
  56. package/src/__tests__/security.test.ts +293 -0
  57. package/src/__tests__/test-utils.ts +28 -0
  58. package/src/__tests__/vitest.d.ts +13 -0
  59. package/src/client-next.ts +31 -0
  60. package/src/client.ts +27 -0
  61. package/src/dictionaries/de.json +193 -0
  62. package/src/dictionaries/en.json +193 -0
  63. package/src/dictionaries/es.json +193 -0
  64. package/src/dictionaries/fr.json +193 -0
  65. package/src/dictionaries/pt.json +193 -0
  66. package/src/index.ts +24 -0
  67. package/src/middleware.ts +27 -0
  68. package/src/navigation.ts +91 -0
  69. package/src/request.ts +126 -0
  70. package/src/routing.export.ts +11 -0
  71. package/src/routing.ts +63 -0
  72. package/src/server-next.ts +57 -0
  73. package/src/server.ts +39 -0
  74. package/src/shared/constants.ts +42 -0
  75. package/src/shared/dictionary-loader.ts +599 -0
  76. package/src/shared/next-intl-adapter.ts +80 -0
  77. package/src/shared/next-intl-server.ts +45 -0
  78. package/src/shared/react19-cache.ts +132 -0
  79. package/src/types.ts +55 -0
  80. package/src/utils/extend.ts +48 -0
@@ -0,0 +1,599 @@
1
+ /**
2
+ * @fileoverview Shared dictionary loading utility
3
+ *
4
+ * This utility provides type-safe dictionary loading with proper error handling
5
+ * and fallback mechanisms. It eliminates code duplication between server.ts and index.ts.
6
+ *
7
+ * Features:
8
+ * - In-memory LRU cache for loaded dictionaries
9
+ * - Cache statistics for monitoring
10
+ * - Error handling with fallback to default locale
11
+ * - Support for multiple locales
12
+ *
13
+ * @module @repo/internationalization/shared/dictionary-loader
14
+ */
15
+
16
+ import { logDebug, logError, logWarn } from '@repo/shared/logger';
17
+
18
+ import languine from '../../languine.json';
19
+
20
+ import type en from '../dictionaries/en.json';
21
+
22
+ const locales = [languine.locale.source, ...languine.locale.targets] as const;
23
+
24
+ export type Locale = (typeof locales)[number];
25
+ export type Dictionary = typeof en;
26
+
27
+ // In-memory cache for loaded dictionaries with LRU eviction
28
+ // Limited to prevent memory bloat in long-running Node 22 processes
29
+ // Can be configured via environment variable for different deployment scenarios
30
+ const MAX_CACHE_SIZE = Number.parseInt(process.env.I18N_CACHE_MAX_SIZE ?? '10', 10) ?? 10;
31
+ const dictionaryCache = new Map<Locale, Promise<Dictionary>>();
32
+
33
+ // Cache statistics for monitoring (Node 22 performance tracking)
34
+ // Use Number.MAX_SAFE_INTEGER to prevent overflow in long-running processes
35
+ const MAX_SAFE_COUNT = Number.MAX_SAFE_INTEGER;
36
+ const cacheStats = {
37
+ hits: 0,
38
+ misses: 0,
39
+ evictions: 0,
40
+ errors: 0,
41
+ };
42
+
43
+ /**
44
+ * Gets current cache statistics for monitoring and debugging.
45
+ * Useful for production performance analysis.
46
+ *
47
+ * @returns Cache statistics object with hits, misses, evictions, and errors
48
+ */
49
+ export function getCacheStats() {
50
+ return {
51
+ ...cacheStats,
52
+ size: dictionaryCache.size,
53
+ maxSize: MAX_CACHE_SIZE,
54
+ hitRate:
55
+ cacheStats.hits + cacheStats.misses > 0
56
+ ? `${((cacheStats.hits / (cacheStats.hits + cacheStats.misses)) * 100).toFixed(2)}%`
57
+ : '0%',
58
+ };
59
+ }
60
+
61
+ /**
62
+ * Resets cache statistics. Useful for testing or periodic reset.
63
+ * Also prevents overflow in long-running processes.
64
+ *
65
+ * Note: This only resets statistics counters, not the cache itself.
66
+ * To clear the cache entries, create a new dictionary loader instance.
67
+ */
68
+ export function resetCacheStats(): void {
69
+ cacheStats.hits = 0;
70
+ cacheStats.misses = 0;
71
+ cacheStats.evictions = 0;
72
+ cacheStats.errors = 0;
73
+ }
74
+
75
+ /**
76
+ * Implements LRU (Least Recently Used) cache eviction.
77
+ * Moves accessed item to end of Map (most recent).
78
+ * Node 22 Map maintains insertion order.
79
+ *
80
+ * @param locale - The locale to mark as recently used
81
+ */
82
+ function touchCache(locale: Locale): void {
83
+ const value = dictionaryCache.get(locale);
84
+ if (value) {
85
+ // Remove and re-add to move to end (most recent)
86
+ dictionaryCache.delete(locale);
87
+ dictionaryCache.set(locale, value);
88
+ }
89
+ }
90
+
91
+ /**
92
+ * Removes the least-recently-used dictionary from the in-memory cache when the cache size reaches the configured maximum.
93
+ *
94
+ * If an entry is evicted, increments the `cacheStats.evictions` counter with overflow protection and emits a debug log entry with eviction metadata when a logger is available.
95
+ */
96
+ function evictOldestIfNeeded(): void {
97
+ if (dictionaryCache.size >= MAX_CACHE_SIZE) {
98
+ // First key is oldest (Map maintains insertion order)
99
+ const oldestKey = dictionaryCache.keys().next().value;
100
+ if (oldestKey) {
101
+ dictionaryCache.delete(oldestKey);
102
+ // Atomic increment with overflow protection
103
+ cacheStats.evictions = Math.min((cacheStats.evictions ?? 0) + 1, MAX_SAFE_COUNT);
104
+ logDebug('Dictionary cache eviction', {
105
+ evictedLocale: oldestKey,
106
+ cacheSize: dictionaryCache.size,
107
+ totalEvictions: cacheStats.evictions,
108
+ context: {
109
+ function: 'evictOldestIfNeeded',
110
+ package: '@repo/internationalization',
111
+ },
112
+ });
113
+ }
114
+ }
115
+ }
116
+
117
+ // Minimal valid dictionary structure for fallback
118
+ // Matches the complete structure of en.json to prevent runtime errors
119
+ const EMPTY_DICTIONARY: Dictionary = {
120
+ common: {
121
+ locale: '',
122
+ language: '',
123
+ },
124
+ web: {
125
+ global: {
126
+ primaryCta: '',
127
+ secondaryCta: '',
128
+ },
129
+ header: {
130
+ home: '',
131
+ product: {
132
+ title: '',
133
+ description: '',
134
+ pricing: '',
135
+ },
136
+ blog: '',
137
+ docs: '',
138
+ contact: '',
139
+ signIn: '',
140
+ signUp: '',
141
+ },
142
+ home: {
143
+ meta: {
144
+ title: '',
145
+ description: '',
146
+ },
147
+ hero: {
148
+ announcement: '',
149
+ },
150
+ cases: {
151
+ title: '',
152
+ },
153
+ features: {
154
+ title: '',
155
+ description: '',
156
+ items: [],
157
+ },
158
+ stats: {
159
+ title: '',
160
+ description: '',
161
+ items: [],
162
+ },
163
+ testimonials: {
164
+ title: '',
165
+ items: [],
166
+ },
167
+ faq: {
168
+ title: '',
169
+ description: '',
170
+ cta: '',
171
+ items: [],
172
+ },
173
+ cta: {
174
+ title: '',
175
+ description: '',
176
+ },
177
+ },
178
+ blog: {
179
+ meta: {
180
+ title: '',
181
+ description: '',
182
+ },
183
+ },
184
+ contact: {
185
+ meta: {
186
+ title: '',
187
+ description: '',
188
+ },
189
+ hero: {
190
+ benefits: [],
191
+ form: {
192
+ title: '',
193
+ date: '',
194
+ firstName: '',
195
+ lastName: '',
196
+ resume: '',
197
+ cta: '',
198
+ },
199
+ },
200
+ },
201
+ },
202
+ };
203
+
204
+ // Use the structured empty dictionary for type-safe fallback
205
+ // EMPTY_DICTIONARY already provides the correct shape
206
+
207
+ // Type-safe error for dictionary loading (reserved for future use)
208
+ export interface _DictionaryLoadError {
209
+ locale: string;
210
+ message: string;
211
+ originalError: unknown;
212
+ }
213
+
214
+ /**
215
+ * Import a module with a timeout to prevent indefinite hangs.
216
+ * Uses Promise.race for timeout handling (import() cannot be cancelled).
217
+ *
218
+ * @param path - The module path to import
219
+ * @param timeoutMs - Timeout in milliseconds (default: 5000ms)
220
+ * @returns Promise that resolves to the imported module
221
+ * @throws Error if import times out or fails
222
+ */
223
+ async function importWithTimeout<T>(path: string, timeoutMs = 5000): Promise<T> {
224
+ let timeoutId: ReturnType<typeof setTimeout> | undefined;
225
+
226
+ try {
227
+ // Race between import and timeout
228
+ // Note: import() cannot be cancelled, but we can reject the promise on timeout
229
+ const result = await Promise.race([
230
+ import(path) as Promise<T>,
231
+ new Promise<never>((_resolve, reject) => {
232
+ timeoutId = setTimeout(() => {
233
+ reject(new Error(`Import timeout after ${timeoutMs}ms: ${path}`));
234
+ }, timeoutMs);
235
+ }),
236
+ ]);
237
+
238
+ // Clear timeout if import succeeded before timeout
239
+ if (timeoutId !== undefined) {
240
+ clearTimeout(timeoutId);
241
+ }
242
+
243
+ return result;
244
+ } catch (error: unknown) {
245
+ // Ensure timeout is cleared on error
246
+ if (timeoutId !== undefined) {
247
+ clearTimeout(timeoutId);
248
+ }
249
+ throw error;
250
+ }
251
+ }
252
+
253
+ /**
254
+ * Validates and normalizes a locale string to prevent path traversal attacks.
255
+ *
256
+ * @param locale - The locale string to validate
257
+ * @returns The normalized locale if valid, null otherwise
258
+ */
259
+ function validateAndNormalizeLocale(locale: string): Locale | null {
260
+ // Validate input length and type
261
+ if (!locale || typeof locale !== 'string' || locale.length > 20) {
262
+ return null;
263
+ }
264
+
265
+ // Validate pattern (alphanumeric and hyphens only, BCP 47 format)
266
+ // Safe regex: bounded quantifiers ({2}, {2,4}), input length pre-validated (max 20), no catastrophic backtracking
267
+ // eslint-disable-next-line regexp/no-unused-capturing-group, security/detect-unsafe-regex -- Capturing group needed for validation; regex is safe (bounded quantifiers, input length limited)
268
+ if (!/^[a-z]{2}(-[a-z]{2,4})?$/i.test(locale)) {
269
+ return null;
270
+ }
271
+
272
+ // Normalize to base locale (e.g., 'en-US' -> 'en')
273
+ const localeParts = locale.split('-');
274
+ const normalizedLocale = (localeParts[0] ?? locale).toLowerCase();
275
+
276
+ // Validate normalized locale exists in supported locales
277
+ if (!locales.includes(normalizedLocale)) {
278
+ return null;
279
+ }
280
+
281
+ return normalizedLocale;
282
+ }
283
+
284
+ /**
285
+ * Creates a type-safe dictionary loader with proper error handling
286
+ *
287
+ * @returns An object with methods to load dictionaries and check locale support
288
+ */
289
+ export function createDictionaryLoader() {
290
+ const dictionaries: Record<Locale, () => Promise<Dictionary>> = Object.fromEntries(
291
+ locales.map(locale => [
292
+ locale,
293
+ (): Promise<Dictionary> => {
294
+ // Check cache first (LRU-aware)
295
+ if (dictionaryCache.has(locale)) {
296
+ touchCache(locale); // Mark as recently used
297
+ // Atomic increment for cache hit statistics with overflow protection
298
+ cacheStats.hits = Math.min((cacheStats.hits ?? 0) + 1, MAX_SAFE_COUNT);
299
+ const cached = dictionaryCache.get(locale);
300
+ if (!cached) {
301
+ throw new Error(
302
+ `Cache inconsistency: locale ${locale} was in cache but get returned undefined`,
303
+ );
304
+ }
305
+ // Log cache hit without sensitive performance data in production
306
+ if (process.env.NODE_ENV === 'development') {
307
+ logDebug('Dictionary cache hit', {
308
+ locale,
309
+ cached: true,
310
+ cacheSize: dictionaryCache.size,
311
+ hitRate: getCacheStats().hitRate,
312
+ context: {
313
+ function: 'createDictionaryLoader',
314
+ package: '@repo/internationalization',
315
+ },
316
+ });
317
+ }
318
+ return cached;
319
+ }
320
+
321
+ // Cache miss - atomic increment with overflow protection
322
+ cacheStats.misses = Math.min((cacheStats.misses ?? 0) + 1, MAX_SAFE_COUNT);
323
+
324
+ // Evict oldest entry if cache is full
325
+ evictOldestIfNeeded();
326
+
327
+ // Load and cache dictionary
328
+ // Track whether the original locale load succeeded (not a fallback)
329
+ let originalLoadSucceeded = false;
330
+ const loadPromise = (async (): Promise<Dictionary> => {
331
+ const startTime = performance.now();
332
+
333
+ try {
334
+ // Locale is guaranteed safe here as it comes from the locales array
335
+ // Use timeout to prevent indefinite hangs
336
+ const mod = await importWithTimeout<{ default: Dictionary }>(
337
+ `../dictionaries/${locale}.json`,
338
+ 5000,
339
+ );
340
+
341
+ // Mark that original load succeeded (not a fallback)
342
+ originalLoadSucceeded = true;
343
+ const duration = performance.now() - startTime;
344
+
345
+ const dictionary = mod.default;
346
+
347
+ logDebug('Dictionary loaded successfully', {
348
+ locale,
349
+ duration: `${duration.toFixed(2)}ms`,
350
+ cached: false,
351
+ timestamp: new Date().toISOString(),
352
+ context: {
353
+ function: 'createDictionaryLoader',
354
+ package: '@repo/internationalization',
355
+ },
356
+ });
357
+
358
+ return dictionary;
359
+ } catch (error: unknown) {
360
+ const duration = performance.now() - startTime;
361
+ // Atomic increment for error statistics with overflow protection
362
+ cacheStats.errors = Math.min((cacheStats.errors ?? 0) + 1, MAX_SAFE_COUNT);
363
+ const errorMessage =
364
+ error instanceof Error
365
+ ? error.message
366
+ : typeof error === 'string'
367
+ ? error
368
+ : 'Unknown error';
369
+ const errorStack = error instanceof Error ? error.stack : undefined;
370
+
371
+ logError(`Failed to load dictionary for locale: ${locale}`, {
372
+ error: errorMessage,
373
+ stack: errorStack,
374
+ locale,
375
+ duration: `${duration.toFixed(2)}ms`,
376
+ totalErrors: cacheStats.errors,
377
+ context: {
378
+ function: 'createDictionaryLoader',
379
+ package: '@repo/internationalization',
380
+ },
381
+ });
382
+
383
+ // Fallback to English dictionary
384
+ // IMPORTANT: Do not cache fallback results - they should not be cached
385
+ // under the original locale key to prevent incorrect caching
386
+ try {
387
+ const fallbackStartTime = performance.now();
388
+ const fallbackMod = await importWithTimeout<{ default: Dictionary }>(
389
+ '../dictionaries/en.json',
390
+ 5000,
391
+ );
392
+ const fallbackDuration = performance.now() - fallbackStartTime;
393
+
394
+ const fallbackDictionary = fallbackMod.default;
395
+
396
+ logDebug('English fallback dictionary loaded', {
397
+ originalLocale: locale,
398
+ fallbackLocale: 'en',
399
+ duration: `${fallbackDuration.toFixed(2)}ms`,
400
+ context: {
401
+ function: 'createDictionaryLoader',
402
+ package: '@repo/internationalization',
403
+ },
404
+ });
405
+
406
+ // Return fallback but don't cache it - let it fail again next time
407
+ // This ensures we don't cache incorrect locale mappings
408
+ return fallbackDictionary;
409
+ } catch (fallbackError: unknown) {
410
+ logError('Critical: English fallback dictionary failed to load', {
411
+ error:
412
+ fallbackError instanceof Error
413
+ ? fallbackError.message
414
+ : typeof fallbackError === 'string'
415
+ ? fallbackError
416
+ : 'Unknown error',
417
+ stack: fallbackError instanceof Error ? fallbackError.stack : undefined,
418
+ locale,
419
+ });
420
+ // Bubble up failure so callers can log additional context
421
+ throw fallbackError;
422
+ }
423
+ }
424
+ })();
425
+
426
+ // Wrap promise to check if original load succeeded before caching
427
+ const cachedPromiseWrapper = async (): Promise<Dictionary> => {
428
+ try {
429
+ const result = await loadPromise;
430
+ // Only keep in cache if original load succeeded (not a fallback)
431
+ // If original load failed and we used fallback, remove from cache
432
+ const cached = dictionaryCache.get(locale);
433
+ if (!originalLoadSucceeded && cached === cachedPromise) {
434
+ dictionaryCache.delete(locale);
435
+ }
436
+ return result;
437
+ } catch (error: unknown) {
438
+ // If promise rejects, remove from cache
439
+ const cached = dictionaryCache.get(locale);
440
+ if (cached === cachedPromise) {
441
+ dictionaryCache.delete(locale);
442
+ }
443
+ throw error;
444
+ }
445
+ };
446
+
447
+ // Create the promise and cache it
448
+ const cachedPromise = cachedPromiseWrapper();
449
+
450
+ // Cache the promise initially - we'll remove it later if it was a fallback
451
+ dictionaryCache.set(locale, cachedPromise);
452
+
453
+ return cachedPromise;
454
+ },
455
+ ]) satisfies Array<[Locale, () => Promise<Dictionary>]>,
456
+ ) as Record<Locale, () => Promise<Dictionary>>;
457
+
458
+ return {
459
+ /**
460
+ * Loads a dictionary for the specified locale with proper type safety and fallbacks.
461
+ *
462
+ * @param locale - The locale string to load (e.g., 'en', 'en-US', 'es')
463
+ * @returns A promise that resolves to the dictionary for the locale
464
+ *
465
+ * @example
466
+ * ```typescript
467
+ * const loader = createDictionaryLoader();
468
+ * const dict = await loader.getDictionary('en');
469
+ * ```
470
+ */
471
+ async getDictionary(locale: string): Promise<Dictionary> {
472
+ const startTime = performance.now();
473
+
474
+ // Validate and normalize locale to prevent path traversal
475
+ const validatedLocale = validateAndNormalizeLocale(locale);
476
+
477
+ if (!validatedLocale) {
478
+ logWarn(`Invalid or unsupported locale "${locale}", defaulting to "en"`, {
479
+ locale,
480
+ context: {
481
+ function: 'getDictionary',
482
+ package: '@repo/internationalization',
483
+ },
484
+ });
485
+ // Safely attempt English fallback
486
+ try {
487
+ const result = await (dictionaries.en?.() ?? Promise.resolve(EMPTY_DICTIONARY));
488
+ const resolvedDictionary = result;
489
+ const duration = performance.now() - startTime;
490
+
491
+ logDebug('Dictionary loaded via fallback', {
492
+ requestedLocale: locale,
493
+ actualLocale: 'en',
494
+ duration: `${duration.toFixed(2)}ms`,
495
+ cached: dictionaryCache.has('en'),
496
+ context: {
497
+ function: 'getDictionary',
498
+ package: '@repo/internationalization',
499
+ },
500
+ });
501
+
502
+ return resolvedDictionary;
503
+ } catch (error: unknown) {
504
+ const duration = performance.now() - startTime;
505
+
506
+ logError('Failed to load English fallback dictionary', {
507
+ error:
508
+ error instanceof Error
509
+ ? error.message
510
+ : typeof error === 'string'
511
+ ? error
512
+ : 'Unknown error',
513
+ stack: error instanceof Error ? error.stack : undefined,
514
+ duration: `${duration.toFixed(2)}ms`,
515
+ });
516
+ return EMPTY_DICTIONARY;
517
+ }
518
+ }
519
+
520
+ try {
521
+ const result = await (dictionaries[validatedLocale]?.() ??
522
+ Promise.resolve(EMPTY_DICTIONARY));
523
+ const resolvedDictionary = result;
524
+ const duration = performance.now() - startTime;
525
+
526
+ logDebug('Dictionary retrieved', {
527
+ locale: validatedLocale,
528
+ duration: `${duration.toFixed(2)}ms`,
529
+ cached: dictionaryCache.has(validatedLocale),
530
+ context: {
531
+ function: 'getDictionary',
532
+ package: '@repo/internationalization',
533
+ },
534
+ });
535
+
536
+ return resolvedDictionary;
537
+ } catch (error: unknown) {
538
+ const duration = performance.now() - startTime;
539
+
540
+ logError(`Error loading dictionary for locale "${validatedLocale}", falling back to "en"`, {
541
+ error:
542
+ error instanceof Error
543
+ ? error.message
544
+ : typeof error === 'string'
545
+ ? error
546
+ : 'Unknown error',
547
+ stack: error instanceof Error ? error.stack : undefined,
548
+ locale: validatedLocale,
549
+ duration: `${duration.toFixed(2)}ms`,
550
+ context: {
551
+ function: 'getDictionary',
552
+ package: '@repo/internationalization',
553
+ },
554
+ });
555
+ // Safely attempt English fallback
556
+ try {
557
+ const fallbackResult = await (dictionaries.en?.() ?? Promise.resolve(EMPTY_DICTIONARY));
558
+ return fallbackResult;
559
+ } catch (fallbackError: unknown) {
560
+ logError('Failed to load English fallback dictionary', {
561
+ error:
562
+ fallbackError instanceof Error
563
+ ? fallbackError.message
564
+ : typeof fallbackError === 'string'
565
+ ? fallbackError
566
+ : 'Unknown error',
567
+ stack: fallbackError instanceof Error ? fallbackError.stack : undefined,
568
+ });
569
+ return EMPTY_DICTIONARY;
570
+ }
571
+ }
572
+ },
573
+
574
+ /**
575
+ * Get all available locales.
576
+ *
577
+ * @returns An array of all supported locale codes
578
+ */
579
+ getLocales: () => locales,
580
+
581
+ /**
582
+ * Check if a locale is supported.
583
+ *
584
+ * @param locale - The locale string to check
585
+ * @returns True if the locale is supported, false otherwise
586
+ *
587
+ * @example
588
+ * ```typescript
589
+ * const loader = createDictionaryLoader();
590
+ * if (loader.isLocaleSupported('en-US')) {
591
+ * // Load dictionary
592
+ * }
593
+ * ```
594
+ */
595
+ isLocaleSupported: (locale: string): boolean => {
596
+ return validateAndNormalizeLocale(locale) !== null;
597
+ },
598
+ };
599
+ }
@@ -0,0 +1,80 @@
1
+ /**
2
+ * @fileoverview next-intl-adapter.ts
3
+ *
4
+ * Provides statically analyzable imports for next-intl modules.
5
+ * In test environments, falls back to mock modules.
6
+ */
7
+
8
+ const isTestEnv = process.env.NODE_ENV === 'test' || process.env.VITEST === 'true';
9
+
10
+ // Type declarations for the modules
11
+ // eslint-disable-next-line @typescript-eslint/consistent-type-imports -- Using typeof import() for module type inference
12
+ type MiddlewareModule = typeof import('next-intl/middleware');
13
+ // eslint-disable-next-line @typescript-eslint/consistent-type-imports -- Using typeof import() for module type inference
14
+ type NavigationModule = typeof import('next-intl/navigation');
15
+ // eslint-disable-next-line @typescript-eslint/consistent-type-imports -- Using typeof import() for module type inference
16
+ type RoutingModule = typeof import('next-intl/routing');
17
+
18
+ type LoadedModules = {
19
+ createMiddleware: MiddlewareModule['default'];
20
+ createNavigation: NavigationModule['createNavigation'];
21
+ defineRouting: RoutingModule['defineRouting'];
22
+ };
23
+
24
+ /**
25
+ * Load next-intl modules using static import paths so bundlers (Turbopack/webpack)
26
+ * can resolve them at build time. Variable-path dynamic imports break bundlers.
27
+ */
28
+ const loadModules = async (): Promise<LoadedModules> => {
29
+ if (isTestEnv) {
30
+ // Test environment: use mocks
31
+ try {
32
+ const [
33
+ { default: mockMiddleware },
34
+ { createNavigation: mockNavigation },
35
+ { defineRouting: mockRouting },
36
+ ] = await Promise.all([
37
+ import('../../test/mocks/next-intl/middleware'),
38
+ import('../../test/mocks/next-intl/navigation'),
39
+ import('../../test/mocks/next-intl/routing'),
40
+ ]);
41
+ return {
42
+ createMiddleware: mockMiddleware as unknown as MiddlewareModule['default'],
43
+ createNavigation: mockNavigation as unknown as NavigationModule['createNavigation'],
44
+ defineRouting: mockRouting as unknown as RoutingModule['defineRouting'],
45
+ };
46
+ } catch (mockError) {
47
+ const msg =
48
+ mockError instanceof Error
49
+ ? mockError.message
50
+ : typeof mockError === 'string'
51
+ ? mockError
52
+ : 'Unknown error';
53
+ throw new Error(`Failed to load next-intl test mocks: ${msg}. Tests cannot continue.`);
54
+ }
55
+ }
56
+
57
+ // Production: static imports so bundlers can resolve them
58
+ try {
59
+ const [middlewareMod, navigationMod, routingMod] = await Promise.all([
60
+ import('next-intl/middleware'),
61
+ import('next-intl/navigation'),
62
+ import('next-intl/routing'),
63
+ ]);
64
+ return {
65
+ createMiddleware: middlewareMod.default,
66
+ createNavigation: navigationMod.createNavigation,
67
+ defineRouting: routingMod.defineRouting,
68
+ };
69
+ } catch (error) {
70
+ const msg =
71
+ error instanceof Error ? error.message : typeof error === 'string' ? error : 'Unknown error';
72
+ throw new Error(
73
+ `Failed to load next-intl modules in production: ${msg}. This is a critical error and the application cannot continue.`,
74
+ );
75
+ }
76
+ };
77
+
78
+ const { createMiddleware, createNavigation, defineRouting } = await loadModules();
79
+
80
+ export { createMiddleware, createNavigation, defineRouting };