@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.
- package/dist/cjs/index.js +10 -10
- package/dist/cjs/localeRedirectPolicy.js +6 -6
- package/dist/cjs/native.js +438 -0
- package/dist/cjs/paths.js +11 -3
- package/dist/cjs/requestLanguage.js +195 -0
- package/dist/cjs/router-navigation/adapter.js +93 -0
- package/dist/cjs/{localisedUrls/config.js → router-navigation/index.js} +5 -14
- package/dist/cjs/ssrLanguageHandoff.js +94 -0
- package/dist/cjs/urlRewrite.js +64 -0
- package/dist/esm/index.mjs +1 -1
- package/dist/esm/localeRedirectPolicy.mjs +1 -1
- package/dist/esm/native.mjs +378 -0
- package/dist/esm/paths.mjs +7 -2
- package/dist/esm/requestLanguage.mjs +148 -0
- package/dist/esm/router-navigation/adapter.mjs +55 -0
- package/dist/esm/router-navigation/index.mjs +1 -0
- package/dist/esm/ssrLanguageHandoff.mjs +44 -0
- package/dist/esm/urlRewrite.mjs +26 -0
- package/dist/esm-node/index.mjs +1 -1
- package/dist/esm-node/localeRedirectPolicy.mjs +1 -1
- package/dist/esm-node/native.mjs +379 -0
- package/dist/esm-node/paths.mjs +7 -2
- package/dist/esm-node/requestLanguage.mjs +149 -0
- package/dist/esm-node/router-navigation/adapter.mjs +56 -0
- package/dist/esm-node/router-navigation/index.mjs +2 -0
- package/dist/esm-node/ssrLanguageHandoff.mjs +45 -0
- package/dist/esm-node/urlRewrite.mjs +27 -0
- package/dist/types/index.d.ts +1 -1
- package/dist/types/localeRedirectPolicy.d.ts +1 -1
- package/dist/types/native.d.ts +34 -0
- package/dist/types/paths.d.ts +8 -1
- package/dist/types/requestLanguage.d.ts +60 -0
- package/dist/types/router-navigation/adapter.d.ts +23 -0
- package/dist/types/router-navigation/index.d.ts +1 -0
- package/dist/types/ssrLanguageHandoff.d.ts +60 -0
- package/dist/types/urlRewrite.d.ts +60 -0
- package/package.json +102 -15
- package/src/index.ts +1 -1
- package/src/localeRedirectPolicy.ts +2 -2
- package/src/native.ts +624 -0
- package/src/paths.ts +23 -2
- package/src/requestLanguage.ts +311 -0
- package/src/router-navigation/adapter.tsx +120 -0
- package/src/router-navigation/index.ts +6 -0
- package/src/ssrLanguageHandoff.ts +138 -0
- package/src/urlRewrite.ts +107 -0
- package/dist/cjs/localisedUrls/index.js +0 -98
- package/dist/cjs/localisedUrls/normalise.js +0 -87
- package/dist/cjs/localisedUrls/pathname.js +0 -68
- package/dist/cjs/localisedUrls/patterns.js +0 -214
- package/dist/cjs/localisedUrls/redirect.js +0 -100
- package/dist/cjs/localisedUrls/resolve.js +0 -78
- package/dist/cjs/localisedUrls/routeIdentity.js +0 -118
- package/dist/cjs/localisedUrls/routes.js +0 -210
- package/dist/cjs/localisedUrls/types.js +0 -18
- package/dist/esm/localisedUrls/config.mjs +0 -11
- package/dist/esm/localisedUrls/index.mjs +0 -8
- package/dist/esm/localisedUrls/normalise.mjs +0 -37
- package/dist/esm/localisedUrls/pathname.mjs +0 -24
- package/dist/esm/localisedUrls/patterns.mjs +0 -152
- package/dist/esm/localisedUrls/redirect.mjs +0 -53
- package/dist/esm/localisedUrls/resolve.mjs +0 -37
- package/dist/esm/localisedUrls/routeIdentity.mjs +0 -77
- package/dist/esm/localisedUrls/routes.mjs +0 -169
- package/dist/esm/localisedUrls/types.mjs +0 -0
- package/dist/esm-node/localisedUrls/config.mjs +0 -12
- package/dist/esm-node/localisedUrls/index.mjs +0 -9
- package/dist/esm-node/localisedUrls/normalise.mjs +0 -38
- package/dist/esm-node/localisedUrls/pathname.mjs +0 -25
- package/dist/esm-node/localisedUrls/patterns.mjs +0 -153
- package/dist/esm-node/localisedUrls/redirect.mjs +0 -54
- package/dist/esm-node/localisedUrls/resolve.mjs +0 -38
- package/dist/esm-node/localisedUrls/routeIdentity.mjs +0 -78
- package/dist/esm-node/localisedUrls/routes.mjs +0 -170
- package/dist/esm-node/localisedUrls/types.mjs +0 -1
- package/dist/types/localisedUrls/config.d.ts +0 -9
- package/dist/types/localisedUrls/index.d.ts +0 -10
- package/dist/types/localisedUrls/normalise.d.ts +0 -10
- package/dist/types/localisedUrls/pathname.d.ts +0 -4
- package/dist/types/localisedUrls/patterns.d.ts +0 -11
- package/dist/types/localisedUrls/redirect.d.ts +0 -19
- package/dist/types/localisedUrls/resolve.d.ts +0 -7
- package/dist/types/localisedUrls/routeIdentity.d.ts +0 -24
- package/dist/types/localisedUrls/routes.d.ts +0 -3
- package/dist/types/localisedUrls/types.d.ts +0 -14
- package/rslib.config.mts +0 -4
- package/rstest.config.mts +0 -36
- package/src/localisedUrls/config.ts +0 -18
- package/src/localisedUrls/index.ts +0 -28
- package/src/localisedUrls/normalise.ts +0 -62
- package/src/localisedUrls/pathname.ts +0 -60
- package/src/localisedUrls/patterns.ts +0 -283
- package/src/localisedUrls/redirect.ts +0 -107
- package/src/localisedUrls/resolve.ts +0 -106
- package/src/localisedUrls/routeIdentity.ts +0 -107
- package/src/localisedUrls/routes.ts +0 -432
- package/src/localisedUrls/types.ts +0 -18
- package/tests/languageSyncController.test.ts +0 -136
- package/tests/localeRedirectPolicy.test.ts +0 -173
- package/tests/localisedUrls.test.ts +0 -532
- package/tests/paths.test.ts +0 -24
- package/tests/reactLanguageSync.test.tsx +0 -136
- package/tests/tsconfig.json +0 -11
- 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 '
|
|
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,
|