@bleedingdev/modern-js-i18n-runtime-extensions 3.9.0-ultramodern.9 → 3.9.3-ultramodern.10

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 (104) hide show
  1. package/dist/cjs/index.js +10 -10
  2. package/dist/cjs/localeRedirectPolicy.js +6 -6
  3. package/dist/cjs/native.js +438 -0
  4. package/dist/cjs/paths.js +11 -3
  5. package/dist/cjs/requestLanguage.js +195 -0
  6. package/dist/cjs/router-navigation/adapter.js +93 -0
  7. package/dist/cjs/{localisedUrls/config.js → router-navigation/index.js} +5 -14
  8. package/dist/cjs/ssrLanguageHandoff.js +94 -0
  9. package/dist/cjs/urlRewrite.js +64 -0
  10. package/dist/esm/index.mjs +1 -1
  11. package/dist/esm/localeRedirectPolicy.mjs +1 -1
  12. package/dist/esm/native.mjs +378 -0
  13. package/dist/esm/paths.mjs +7 -2
  14. package/dist/esm/requestLanguage.mjs +148 -0
  15. package/dist/esm/router-navigation/adapter.mjs +55 -0
  16. package/dist/esm/router-navigation/index.mjs +1 -0
  17. package/dist/esm/ssrLanguageHandoff.mjs +44 -0
  18. package/dist/esm/urlRewrite.mjs +26 -0
  19. package/dist/esm-node/index.mjs +1 -1
  20. package/dist/esm-node/localeRedirectPolicy.mjs +1 -1
  21. package/dist/esm-node/native.mjs +379 -0
  22. package/dist/esm-node/paths.mjs +7 -2
  23. package/dist/esm-node/requestLanguage.mjs +149 -0
  24. package/dist/esm-node/router-navigation/adapter.mjs +56 -0
  25. package/dist/esm-node/router-navigation/index.mjs +2 -0
  26. package/dist/esm-node/ssrLanguageHandoff.mjs +45 -0
  27. package/dist/esm-node/urlRewrite.mjs +27 -0
  28. package/dist/types/index.d.ts +1 -1
  29. package/dist/types/localeRedirectPolicy.d.ts +1 -1
  30. package/dist/types/native.d.ts +34 -0
  31. package/dist/types/paths.d.ts +8 -1
  32. package/dist/types/requestLanguage.d.ts +60 -0
  33. package/dist/types/router-navigation/adapter.d.ts +23 -0
  34. package/dist/types/router-navigation/index.d.ts +1 -0
  35. package/dist/types/ssrLanguageHandoff.d.ts +60 -0
  36. package/dist/types/urlRewrite.d.ts +60 -0
  37. package/package.json +102 -15
  38. package/src/index.ts +1 -1
  39. package/src/localeRedirectPolicy.ts +2 -2
  40. package/src/native.ts +624 -0
  41. package/src/paths.ts +23 -2
  42. package/src/requestLanguage.ts +311 -0
  43. package/src/router-navigation/adapter.tsx +120 -0
  44. package/src/router-navigation/index.ts +6 -0
  45. package/src/ssrLanguageHandoff.ts +138 -0
  46. package/src/urlRewrite.ts +107 -0
  47. package/dist/cjs/localisedUrls/index.js +0 -98
  48. package/dist/cjs/localisedUrls/normalise.js +0 -87
  49. package/dist/cjs/localisedUrls/pathname.js +0 -68
  50. package/dist/cjs/localisedUrls/patterns.js +0 -214
  51. package/dist/cjs/localisedUrls/redirect.js +0 -100
  52. package/dist/cjs/localisedUrls/resolve.js +0 -78
  53. package/dist/cjs/localisedUrls/routeIdentity.js +0 -118
  54. package/dist/cjs/localisedUrls/routes.js +0 -210
  55. package/dist/cjs/localisedUrls/types.js +0 -18
  56. package/dist/esm/localisedUrls/config.mjs +0 -11
  57. package/dist/esm/localisedUrls/index.mjs +0 -8
  58. package/dist/esm/localisedUrls/normalise.mjs +0 -37
  59. package/dist/esm/localisedUrls/pathname.mjs +0 -24
  60. package/dist/esm/localisedUrls/patterns.mjs +0 -152
  61. package/dist/esm/localisedUrls/redirect.mjs +0 -53
  62. package/dist/esm/localisedUrls/resolve.mjs +0 -37
  63. package/dist/esm/localisedUrls/routeIdentity.mjs +0 -77
  64. package/dist/esm/localisedUrls/routes.mjs +0 -169
  65. package/dist/esm/localisedUrls/types.mjs +0 -0
  66. package/dist/esm-node/localisedUrls/config.mjs +0 -12
  67. package/dist/esm-node/localisedUrls/index.mjs +0 -9
  68. package/dist/esm-node/localisedUrls/normalise.mjs +0 -38
  69. package/dist/esm-node/localisedUrls/pathname.mjs +0 -25
  70. package/dist/esm-node/localisedUrls/patterns.mjs +0 -153
  71. package/dist/esm-node/localisedUrls/redirect.mjs +0 -54
  72. package/dist/esm-node/localisedUrls/resolve.mjs +0 -38
  73. package/dist/esm-node/localisedUrls/routeIdentity.mjs +0 -78
  74. package/dist/esm-node/localisedUrls/routes.mjs +0 -170
  75. package/dist/esm-node/localisedUrls/types.mjs +0 -1
  76. package/dist/types/localisedUrls/config.d.ts +0 -9
  77. package/dist/types/localisedUrls/index.d.ts +0 -10
  78. package/dist/types/localisedUrls/normalise.d.ts +0 -10
  79. package/dist/types/localisedUrls/pathname.d.ts +0 -4
  80. package/dist/types/localisedUrls/patterns.d.ts +0 -11
  81. package/dist/types/localisedUrls/redirect.d.ts +0 -19
  82. package/dist/types/localisedUrls/resolve.d.ts +0 -7
  83. package/dist/types/localisedUrls/routeIdentity.d.ts +0 -24
  84. package/dist/types/localisedUrls/routes.d.ts +0 -3
  85. package/dist/types/localisedUrls/types.d.ts +0 -14
  86. package/rslib.config.mts +0 -4
  87. package/rstest.config.mts +0 -36
  88. package/src/localisedUrls/config.ts +0 -18
  89. package/src/localisedUrls/index.ts +0 -28
  90. package/src/localisedUrls/normalise.ts +0 -62
  91. package/src/localisedUrls/pathname.ts +0 -60
  92. package/src/localisedUrls/patterns.ts +0 -283
  93. package/src/localisedUrls/redirect.ts +0 -107
  94. package/src/localisedUrls/resolve.ts +0 -106
  95. package/src/localisedUrls/routeIdentity.ts +0 -107
  96. package/src/localisedUrls/routes.ts +0 -432
  97. package/src/localisedUrls/types.ts +0 -18
  98. package/tests/languageSyncController.test.ts +0 -136
  99. package/tests/localeRedirectPolicy.test.ts +0 -173
  100. package/tests/localisedUrls.test.ts +0 -532
  101. package/tests/paths.test.ts +0 -24
  102. package/tests/reactLanguageSync.test.tsx +0 -136
  103. package/tests/tsconfig.json +0 -11
  104. package/tsconfig.json +0 -11
package/src/native.ts ADDED
@@ -0,0 +1,624 @@
1
+ import type {
2
+ NativeEntryI18n,
3
+ NativeI18nResources,
4
+ } from '@modern-js/renderer-core/entry-server';
5
+ import type { LocalisedUrlsOption } from '@modern-js/runtime-extensions/localised-urls';
6
+ import i18next, {
7
+ type BackendModule,
8
+ type i18n as I18nInstance,
9
+ type InitOptions,
10
+ type Module,
11
+ type NewableModule,
12
+ } from 'i18next';
13
+ import { createLatestLanguageSyncBinding } from './language-sync/controller';
14
+ import {
15
+ createRequestLanguageRedirect,
16
+ languageFromPathname,
17
+ type ResolveRequestLanguageOptions,
18
+ resolveRequestLanguage,
19
+ } from './requestLanguage';
20
+ import {
21
+ createI18nSsrHandoffInlineData,
22
+ readI18nSsrHandoff,
23
+ } from './ssrLanguageHandoff';
24
+ import { createI18nUrlRewrite } from './urlRewrite';
25
+
26
+ export type { I18nInstance };
27
+
28
+ interface NativeSnapshotOrigin {
29
+ instance: I18nInstance;
30
+ original: InitOptions;
31
+ seed: InitOptions;
32
+ unchanged(): boolean;
33
+ }
34
+
35
+ const nativeSnapshotOrigins = new WeakMap<object, NativeSnapshotOrigin>();
36
+
37
+ /** Retire the native proof when its owning request or plugin contract ends. */
38
+ export function invalidateNativeI18nSnapshot(instance: unknown): void {
39
+ if (typeof instance === 'object' && instance !== null)
40
+ nativeSnapshotOrigins.delete(instance);
41
+ }
42
+
43
+ export interface NativeI18nRewriteSnapshot {
44
+ language: string;
45
+ languages: readonly string[];
46
+ localisedUrls?: LocalisedUrlsOption;
47
+ }
48
+
49
+ const nativeRewriteOrigins = new WeakMap<
50
+ object,
51
+ () => NativeI18nRewriteSnapshot
52
+ >();
53
+
54
+ /** Only rewrites created by the native entry have a portable routing profile. */
55
+ export function getNativeI18nRewriteSnapshot(
56
+ rewrite: unknown,
57
+ ): NativeI18nRewriteSnapshot | undefined {
58
+ return typeof rewrite === 'object' && rewrite !== null
59
+ ? nativeRewriteOrigins.get(rewrite)?.()
60
+ : undefined;
61
+ }
62
+
63
+ function detachLocalisedUrls(
64
+ value: LocalisedUrlsOption | undefined,
65
+ ): LocalisedUrlsOption | undefined {
66
+ if (value === undefined || typeof value === 'boolean') return value;
67
+ return Object.fromEntries(
68
+ Object.entries(value).map(([path, languages]) => [
69
+ path,
70
+ Object.fromEntries(Object.entries(languages)),
71
+ ]),
72
+ );
73
+ }
74
+
75
+ function plainRecord(value: unknown): value is Record<string, unknown> {
76
+ return (
77
+ typeof value === 'object' &&
78
+ value !== null &&
79
+ (Object.getPrototypeOf(value) === Object.prototype ||
80
+ Object.getPrototypeOf(value) === null)
81
+ );
82
+ }
83
+
84
+ /** Detach authored options without executing accessors or copying class state. */
85
+ function detachOption(value: unknown, ancestors = new Set<object>()): unknown {
86
+ if (value === null || typeof value !== 'object') return value;
87
+ if (
88
+ ancestors.has(value) ||
89
+ (Array.isArray(value)
90
+ ? Object.getPrototypeOf(value) !== Array.prototype
91
+ : !plainRecord(value))
92
+ )
93
+ throw new Error('Native i18n snapshot options must be plain acyclic data');
94
+ ancestors.add(value);
95
+ const copy: Record<string, unknown> | unknown[] = Array.isArray(value)
96
+ ? []
97
+ : {};
98
+ for (const key of Reflect.ownKeys(value)) {
99
+ if (Array.isArray(value) && key === 'length') continue;
100
+ const descriptor = Object.getOwnPropertyDescriptor(value, key);
101
+ if (typeof key !== 'string' || !descriptor || !('value' in descriptor))
102
+ throw new Error('Native i18n snapshot options cannot contain accessors');
103
+ Object.defineProperty(copy, key, {
104
+ value: detachOption(descriptor.value, ancestors),
105
+ enumerable: descriptor.enumerable,
106
+ writable: true,
107
+ configurable: true,
108
+ });
109
+ }
110
+ ancestors.delete(value);
111
+ return copy;
112
+ }
113
+
114
+ function detachInitOptions(options: InitOptions): InitOptions {
115
+ const copy = detachOption(options);
116
+ if (!plainRecord(copy)) throw new Error('Invalid native i18n options');
117
+ return copy;
118
+ }
119
+
120
+ /** Compare descriptors, including native default functions, without calling them. */
121
+ function descriptorProof(
122
+ object: object,
123
+ ignore: ReadonlySet<string> = new Set(),
124
+ ancestors = new Set<object>(),
125
+ ): () => boolean {
126
+ if (ancestors.has(object)) return () => true;
127
+ ancestors.add(object);
128
+ const keys = Reflect.ownKeys(object).filter(
129
+ key => typeof key !== 'string' || !ignore.has(key),
130
+ );
131
+ const proofs = keys.map(key => {
132
+ const descriptor = Object.getOwnPropertyDescriptor(object, key);
133
+ if (!descriptor || !('value' in descriptor))
134
+ throw new Error(
135
+ 'Native i18n snapshot provenance cannot contain accessors',
136
+ );
137
+ const value: unknown = descriptor.value;
138
+ const nested =
139
+ Array.isArray(value) || plainRecord(value)
140
+ ? descriptorProof(value, new Set(), ancestors)
141
+ : () => true;
142
+ return () => {
143
+ const current = Object.getOwnPropertyDescriptor(object, key);
144
+ return (
145
+ current !== undefined &&
146
+ 'value' in current &&
147
+ current.value === value &&
148
+ current.enumerable === descriptor.enumerable &&
149
+ current.configurable === descriptor.configurable &&
150
+ current.writable === descriptor.writable &&
151
+ nested()
152
+ );
153
+ };
154
+ });
155
+ ancestors.delete(object);
156
+ return () => {
157
+ const currentKeys = Reflect.ownKeys(object).filter(
158
+ key => typeof key !== 'string' || !ignore.has(key),
159
+ );
160
+ return (
161
+ currentKeys.length === keys.length &&
162
+ currentKeys.every((key, index) => key === keys[index]) &&
163
+ proofs.every(proof => proof())
164
+ );
165
+ };
166
+ }
167
+
168
+ function methodProof(object: object): () => boolean {
169
+ const prototype = Object.getPrototypeOf(object);
170
+ const methods = new Map<PropertyKey, unknown>();
171
+ for (
172
+ let current: object | null = object;
173
+ current && current !== Object.prototype;
174
+ current = Object.getPrototypeOf(current)
175
+ ) {
176
+ for (const key of Reflect.ownKeys(current)) {
177
+ const descriptor = Object.getOwnPropertyDescriptor(current, key);
178
+ if (!descriptor || !('value' in descriptor)) continue;
179
+ if (typeof descriptor.value === 'function' && !methods.has(key))
180
+ methods.set(key, descriptor.value);
181
+ }
182
+ }
183
+ return () => {
184
+ if (Object.getPrototypeOf(object) !== prototype) return false;
185
+ for (const [key, method] of methods) {
186
+ let current: object | null = object;
187
+ while (current) {
188
+ const descriptor = Object.getOwnPropertyDescriptor(current, key);
189
+ if (descriptor) {
190
+ if (!('value' in descriptor) || descriptor.value !== method)
191
+ return false;
192
+ break;
193
+ }
194
+ current = Object.getPrototypeOf(current);
195
+ }
196
+ if (!current) return false;
197
+ }
198
+ return true;
199
+ };
200
+ }
201
+
202
+ function ownValue(object: object, key: string): unknown {
203
+ const descriptor = Object.getOwnPropertyDescriptor(object, key);
204
+ if (descriptor && !('value' in descriptor))
205
+ throw new Error('Native i18n snapshot provenance cannot contain accessors');
206
+ return descriptor?.value;
207
+ }
208
+
209
+ function semanticProof(
210
+ object: object,
211
+ fields: readonly string[],
212
+ ): () => boolean {
213
+ const proofs = fields.map(key => {
214
+ const value = ownValue(object, key);
215
+ const nested =
216
+ Array.isArray(value) || plainRecord(value)
217
+ ? descriptorProof(value, new Set(['resources']))
218
+ : () => true;
219
+ if (value instanceof RegExp) {
220
+ const source = value.source;
221
+ const flags = value.flags;
222
+ return () => {
223
+ const current = ownValue(object, key);
224
+ return (
225
+ current instanceof RegExp &&
226
+ current.source === source &&
227
+ current.flags === flags
228
+ );
229
+ };
230
+ }
231
+ return () => ownValue(object, key) === value && nested();
232
+ });
233
+ return () => proofs.every(proof => proof());
234
+ }
235
+
236
+ function recordNativeSnapshotOrigin(
237
+ instance: I18nInstance,
238
+ original: InitOptions,
239
+ seed: InitOptions,
240
+ ) {
241
+ try {
242
+ const useDescriptor = Object.getOwnPropertyDescriptor(instance, 'use');
243
+ if (!useDescriptor || !('value' in useDescriptor))
244
+ throw new Error('Native plugin registration requires an own method');
245
+ const use = instance.use;
246
+ Object.defineProperty(instance, 'use', {
247
+ ...useDescriptor,
248
+ value: function registerPlugin<T extends Module>(
249
+ plugin: T | NewableModule<T>,
250
+ ) {
251
+ // Postprocessors register globally without appearing in `modules`.
252
+ // Record the public registration call instead of reading that registry.
253
+ invalidateNativeI18nSnapshot(instance);
254
+ return use(plugin);
255
+ },
256
+ });
257
+ const services: object = instance.services;
258
+ const store = instance.store;
259
+ const options = instance.options;
260
+ const modules = instance.modules;
261
+ const translator: unknown = Object.getOwnPropertyDescriptor(
262
+ instance,
263
+ 'translator',
264
+ )?.value;
265
+ const formatter: unknown = instance.services.formatter;
266
+ const formats: unknown =
267
+ formatter && typeof formatter === 'object'
268
+ ? Object.getOwnPropertyDescriptor(formatter, 'formats')?.value
269
+ : undefined;
270
+ const proofs = [
271
+ descriptorProof(instance.options, new Set(['resources'])),
272
+ descriptorProof(instance.modules),
273
+ descriptorProof(services),
274
+ methodProof(instance),
275
+ methodProof(instance.store),
276
+ ...Object.values(instance.services)
277
+ .filter(value => typeof value === 'object' && value !== null)
278
+ .map(value => methodProof(value)),
279
+ ...(translator && typeof translator === 'object'
280
+ ? [methodProof(translator)]
281
+ : []),
282
+ ...(formats && typeof formats === 'object'
283
+ ? [descriptorProof(formats)]
284
+ : []),
285
+ ];
286
+ const serviceFields: Record<string, readonly string[]> = {
287
+ languageUtils: ['options', 'supportedLngs'],
288
+ pluralResolver: ['languageUtils', 'options'],
289
+ interpolator: [
290
+ 'options',
291
+ 'escape',
292
+ 'escapeValue',
293
+ 'useRawValueToEscape',
294
+ 'prefix',
295
+ 'suffix',
296
+ 'format',
297
+ 'formatSeparator',
298
+ 'unescapePrefix',
299
+ 'unescapeSuffix',
300
+ 'nestingPrefix',
301
+ 'nestingSuffix',
302
+ 'nestingOptionsSeparator',
303
+ 'maxReplaces',
304
+ 'alwaysFormat',
305
+ 'regexp',
306
+ 'regexpUnescape',
307
+ 'nestingRegexp',
308
+ ],
309
+ formatter: ['formatSeparator'],
310
+ resourceStore: ['options'],
311
+ backendConnector: ['backend', 'store', 'services', 'options'],
312
+ };
313
+ const links = Object.entries(instance.services).map(([key, value]) => {
314
+ if (typeof value === 'object' && value !== null)
315
+ proofs.push(semanticProof(value, serviceFields[key] ?? []));
316
+ return () => ownValue(services, key) === value;
317
+ });
318
+ if (translator && typeof translator === 'object')
319
+ proofs.push(
320
+ semanticProof(translator, [
321
+ 'options',
322
+ 'resourceStore',
323
+ 'languageUtils',
324
+ 'pluralResolver',
325
+ 'interpolator',
326
+ 'backendConnector',
327
+ 'i18nFormat',
328
+ 'utils',
329
+ ]),
330
+ );
331
+ nativeSnapshotOrigins.set(instance, {
332
+ instance,
333
+ original: detachInitOptions(original),
334
+ seed: detachInitOptions(seed),
335
+ unchanged: () =>
336
+ ownValue(instance, 'services') === services &&
337
+ ownValue(instance, 'store') === store &&
338
+ ownValue(instance, 'options') === options &&
339
+ ownValue(instance, 'modules') === modules &&
340
+ ownValue(services, 'formatter') === formatter &&
341
+ (formatter && typeof formatter === 'object'
342
+ ? ownValue(formatter, 'formats') === formats
343
+ : true) &&
344
+ Object.getOwnPropertyDescriptor(instance, 'translator')?.value ===
345
+ translator &&
346
+ (translator && typeof translator === 'object'
347
+ ? ownValue(translator, 'language') === ownValue(instance, 'language')
348
+ : true) &&
349
+ links.every(proof => proof()) &&
350
+ proofs.every(proof => proof()),
351
+ });
352
+ } catch {
353
+ // Existing native applications may use opaque options. They need a named
354
+ // Worker profile, while ordinary rendering keeps its existing behavior.
355
+ }
356
+ }
357
+
358
+ /** A real fully bundled instance for request-local synchronous providers. */
359
+ export function createNativeI18nInstance(
360
+ initOptions: InitOptions,
361
+ ): I18nInstance {
362
+ const original = detachInitOptions(initOptions);
363
+ const seed: InitOptions = {
364
+ ...detachInitOptions(original),
365
+ initAsync: false,
366
+ partialBundledLanguages: false,
367
+ interpolation: { escapeValue: false, ...original.interpolation },
368
+ };
369
+ const instance = i18next.createInstance();
370
+ void instance.init(detachInitOptions(seed));
371
+ if (!instance.isInitialized || instance.isInitializing)
372
+ throw new Error('Native i18n instances require complete bundled resources');
373
+ recordNativeSnapshotOrigin(instance, original, seed);
374
+ return instance;
375
+ }
376
+
377
+ /** The original effective seed, only while the initialized native proof holds. */
378
+ export function getNativeI18nSnapshotSeed(instance: unknown): InitOptions {
379
+ const origin =
380
+ typeof instance === 'object' && instance !== null
381
+ ? nativeSnapshotOrigins.get(instance)
382
+ : undefined;
383
+ const services = origin ? ownValue(origin.instance, 'services') : undefined;
384
+ const connector =
385
+ services && typeof services === 'object'
386
+ ? ownValue(services, 'backendConnector')
387
+ : undefined;
388
+ const state =
389
+ connector && typeof connector === 'object'
390
+ ? ownValue(connector, 'state')
391
+ : undefined;
392
+ const pending =
393
+ plainRecord(state) &&
394
+ Object.values(Object.getOwnPropertyDescriptors(state)).some(
395
+ descriptor => !('value' in descriptor) || descriptor.value === 1,
396
+ );
397
+ if (
398
+ !origin ||
399
+ ownValue(origin.instance, 'isInitialized') !== true ||
400
+ ownValue(origin.instance, 'isInitializing') === true ||
401
+ ownValue(origin.instance, 'isLanguageChangingTo') ||
402
+ pending ||
403
+ !origin.unchanged()
404
+ )
405
+ throw new Error('This i18n instance requires an explicit Worker profile');
406
+ return detachInitOptions(origin.seed);
407
+ }
408
+
409
+ export interface NativeI18nOptions extends ResolveRequestLanguageOptions {
410
+ /**
411
+ * JSON-serializable i18next `init` options. `lng`, `fallbackLng`,
412
+ * `supportedLngs`, `resources` and `ns` are owned by the native entry.
413
+ */
414
+ initOptions?: InitOptions;
415
+ }
416
+
417
+ /** language -> namespace -> lazily imported translation bundle. */
418
+ export type NativeI18nLoaders = Readonly<
419
+ Record<string, Readonly<Record<string, () => Promise<unknown>>>>
420
+ >;
421
+
422
+ export type NativeI18n = NativeEntryI18n<I18nInstance, LocalisedUrlsOption>;
423
+
424
+ function unwrapResource(module: unknown): Record<string, unknown> {
425
+ const resource =
426
+ typeof module === 'object' && module !== null && 'default' in module
427
+ ? module.default
428
+ : module;
429
+ return resource as Record<string, unknown>;
430
+ }
431
+
432
+ /**
433
+ * The localized routing and per-request i18next instances of a native
434
+ * Solid or Octane entry. Each language's bundles load on demand; an instance
435
+ * is never a module singleton.
436
+ */
437
+ export function createNativeI18n(
438
+ options: NativeI18nOptions,
439
+ loaders: NativeI18nLoaders,
440
+ ): NativeI18n {
441
+ const { initOptions = {}, ...routing } = options;
442
+ const namespaces = [
443
+ ...new Set(Object.values(loaders).flatMap(Object.keys)),
444
+ ].sort();
445
+ const defaultNamespace =
446
+ typeof initOptions.defaultNS === 'string'
447
+ ? initOptions.defaultNS
448
+ : namespaces.includes('translation')
449
+ ? 'translation'
450
+ : (namespaces[0] ?? 'translation');
451
+ const cookie = routing.detection?.lookupCookie ?? 'i18next';
452
+ const backend: BackendModule = {
453
+ type: 'backend',
454
+ init() {},
455
+ read(language, namespace, callback) {
456
+ // Own keys only: `constructor` or `__proto__` never reaches Object.
457
+ const bundles = Object.hasOwn(loaders, language)
458
+ ? loaders[language]
459
+ : undefined;
460
+ const load =
461
+ bundles && Object.hasOwn(bundles, namespace)
462
+ ? bundles[namespace]
463
+ : undefined;
464
+ if (!load) {
465
+ callback(null, {});
466
+ return;
467
+ }
468
+ load().then(
469
+ module => callback(null, unwrapResource(module)),
470
+ error => callback(error as Error, false),
471
+ );
472
+ },
473
+ };
474
+ const currentLanguage = () =>
475
+ languageFromPathname(
476
+ window.location.pathname,
477
+ routing.languages,
478
+ routing.basePath,
479
+ );
480
+
481
+ return {
482
+ languages: routing.languages,
483
+ ...(routing.localisedUrls === undefined
484
+ ? {}
485
+ : { localisedUrls: routing.localisedUrls }),
486
+ resolveRequest: request => resolveRequestLanguage(request, routing),
487
+ redirect: createRequestLanguageRedirect,
488
+ async create(language, resources) {
489
+ // The default export resolves under both i18next module formats.
490
+ const instance = i18next.createInstance();
491
+ instance.use(backend);
492
+ const seed: InitOptions = {
493
+ ...initOptions,
494
+ lng: language,
495
+ fallbackLng: routing.fallbackLanguage,
496
+ supportedLngs: [...routing.languages],
497
+ ns: namespaces,
498
+ defaultNS: defaultNamespace,
499
+ partialBundledLanguages: true,
500
+ resources: resources ? { [language]: resources } : {},
501
+ interpolation: { escapeValue: false, ...initOptions.interpolation },
502
+ };
503
+ let original: InitOptions | undefined;
504
+ let detachedSeed: InitOptions | undefined;
505
+ try {
506
+ original = detachInitOptions(initOptions);
507
+ detachedSeed = detachInitOptions(seed);
508
+ } catch {
509
+ // Opaque custom options remain supported by normal native rendering.
510
+ }
511
+ await instance.init(seed);
512
+ if (original && detachedSeed)
513
+ recordNativeSnapshotOrigin(instance, original, detachedSeed);
514
+ return instance;
515
+ },
516
+ rewrite: getLanguage => {
517
+ const languages = [...routing.languages];
518
+ const localisedUrls = detachLocalisedUrls(routing.localisedUrls);
519
+ const rewrite = createI18nUrlRewrite({
520
+ languages,
521
+ getLanguage,
522
+ localisedUrls,
523
+ });
524
+ const unchanged = descriptorProof(rewrite);
525
+ nativeRewriteOrigins.set(rewrite, () => {
526
+ if (!unchanged()) throw new Error('Native i18n rewrite was modified');
527
+ const language = getLanguage();
528
+ if (!languages.includes(language))
529
+ throw new Error('Native i18n rewrite language is unsupported');
530
+ return {
531
+ language,
532
+ languages: [...languages],
533
+ ...(localisedUrls === undefined
534
+ ? {}
535
+ : { localisedUrls: detachLocalisedUrls(localisedUrls) }),
536
+ };
537
+ });
538
+ return rewrite;
539
+ },
540
+ handoff(language, instance) {
541
+ return createI18nSsrHandoffInlineData({
542
+ language,
543
+ ...(instance
544
+ ? {
545
+ resources: Object.fromEntries(
546
+ namespaces.map(namespace => [
547
+ namespace,
548
+ (instance.getResourceBundle(language, namespace) ??
549
+ {}) as Record<string, unknown>,
550
+ ]),
551
+ ) satisfies NativeI18nResources,
552
+ }
553
+ : {}),
554
+ });
555
+ },
556
+ clientHandoff() {
557
+ const handoff = readI18nSsrHandoff();
558
+ if (handoff && routing.languages.includes(handoff.language))
559
+ return handoff;
560
+ return { language: currentLanguage() ?? routing.fallbackLanguage };
561
+ },
562
+ syncWithRouter(router, instance) {
563
+ // Navigation to another language prefix switches the instance. A
564
+ // masked location (LocalizedLink to another language) publishes its
565
+ // language on the mask; the unmasked location stays canonical. Switches
566
+ // run through the shared sync policy: the latest URL language wins and
567
+ // rejected loads retry. When retries run out the router has already
568
+ // committed the URL, so a reload lets the server render that language.
569
+ const sync = createLatestLanguageSyncBinding<I18nInstance>();
570
+ sync.updateCallbacks({
571
+ changeLanguage: (target, language) => target.changeLanguage(language),
572
+ commitLanguage() {},
573
+ readLanguage: target => target.language,
574
+ reportFailure: () => window.location.reload(),
575
+ });
576
+ sync.activate(instance);
577
+ const unsubscribe = (router as LanguageRouter).subscribe(
578
+ 'onBeforeLoad',
579
+ ({ toLocation }) => {
580
+ const publicHref =
581
+ toLocation.maskedLocation?.publicHref ?? toLocation.publicHref;
582
+ const pathname = new URL(publicHref, window.location.origin).pathname;
583
+ const language = languageFromPathname(
584
+ pathname,
585
+ routing.languages,
586
+ routing.basePath,
587
+ );
588
+ if (language) sync.request(language);
589
+ },
590
+ );
591
+ // A retired entry stops listening and retrying.
592
+ return () => {
593
+ unsubscribe();
594
+ sync.deactivate();
595
+ };
596
+ },
597
+ persist(instance) {
598
+ // Every language change reaches the document and the server's
599
+ // language detector, with or without a router.
600
+ const persist = (language: string) => {
601
+ document.documentElement.lang = language;
602
+ // biome-ignore lint/suspicious/noDocumentCookie: the server's language detector reads this cookie; CookieStore is not in every browser.
603
+ document.cookie = `${cookie}=${encodeURIComponent(language)}; path=/; max-age=31536000; samesite=lax`;
604
+ };
605
+ // The client starts in its URL's language, which a stale cookie from
606
+ // an earlier visit may not match; persist it before any later change.
607
+ if (instance.language) persist(instance.language);
608
+ instance.on('languageChanged', persist);
609
+ return () => instance.off('languageChanged', persist);
610
+ },
611
+ };
612
+ }
613
+
614
+ interface LanguageRouter {
615
+ subscribe(
616
+ event: 'onBeforeLoad',
617
+ listener: (event: {
618
+ toLocation: {
619
+ publicHref: string;
620
+ maskedLocation?: { publicHref: string };
621
+ };
622
+ }) => void,
623
+ ): () => void;
624
+ }
package/src/paths.ts CHANGED
@@ -1,15 +1,36 @@
1
- import { splitUrlTarget } from '@modern-js/runtime-utils/url';
2
1
  import {
3
2
  canonicalTargetPathname,
4
3
  type LocalisedUrlsOption,
5
4
  localiseTargetPathname,
6
- } from './localisedUrls';
5
+ } from '@modern-js/runtime-extensions/localised-urls';
6
+ import { splitUrlTarget } from '@modern-js/runtime-utils/url';
7
+
8
+ export type { LocalisedUrlsOption };
7
9
 
8
10
  export interface LocalizedPathsConfig {
9
11
  languages: string[];
10
12
  localisedUrls?: LocalisedUrlsOption;
11
13
  }
12
14
 
15
+ /**
16
+ * The configured spelling of a requested language, matched case-insensitively.
17
+ * Only a configured language has a locale prefix, so anything else throws
18
+ * rather than becoming an unknown path segment.
19
+ */
20
+ export const configuredLanguage = (
21
+ requested: string,
22
+ languages: readonly string[],
23
+ ): string => {
24
+ const language = languages.find(
25
+ candidate => candidate.toLowerCase() === String(requested).toLowerCase(),
26
+ );
27
+ if (language === undefined)
28
+ throw new RangeError(
29
+ `Unsupported language "${requested}"; expected one of: ${languages.join(', ')}`,
30
+ );
31
+ return language;
32
+ };
33
+
13
34
  /** Synchronous mapped URL construction, independent of React/plugin setup. */
14
35
  export const buildLocalizedUrl = (
15
36
  target: string,