@bleedingdev/modern-js-app-tools 3.5.0-ultramodern.17 → 3.5.0-ultramodern.18
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/plugins/deploy/platforms/cloudflare.js +99 -2
- package/dist/cjs/plugins/deploy/platforms/templates/cloudflare-entry.mjs +251 -0
- package/dist/esm/plugins/deploy/platforms/cloudflare.mjs +99 -2
- package/dist/esm/plugins/deploy/platforms/templates/cloudflare-entry.mjs +251 -0
- package/dist/esm-node/plugins/deploy/platforms/cloudflare.mjs +99 -2
- package/dist/esm-node/plugins/deploy/platforms/templates/cloudflare-entry.mjs +251 -0
- package/package.json +11 -11
|
@@ -151,6 +151,102 @@ const DEFAULT_CSP_DIRECTIVES = {
|
|
|
151
151
|
'blob:'
|
|
152
152
|
]
|
|
153
153
|
};
|
|
154
|
+
const I18N_SERVER_PLUGIN_NAME = 'plugin-i18n/server';
|
|
155
|
+
const DEFAULT_I18NEXT_DETECTION_OPTIONS = {
|
|
156
|
+
order: [
|
|
157
|
+
'querystring',
|
|
158
|
+
'cookie',
|
|
159
|
+
'localStorage',
|
|
160
|
+
'header',
|
|
161
|
+
'navigator',
|
|
162
|
+
'htmlTag',
|
|
163
|
+
'path',
|
|
164
|
+
'subdomain'
|
|
165
|
+
],
|
|
166
|
+
lookupQuerystring: 'lng',
|
|
167
|
+
lookupCookie: 'i18next',
|
|
168
|
+
lookupHeader: 'accept-language'
|
|
169
|
+
};
|
|
170
|
+
const SERVER_SIDE_I18N_DETECTORS = new Set([
|
|
171
|
+
'querystring',
|
|
172
|
+
'cookie',
|
|
173
|
+
'header'
|
|
174
|
+
]);
|
|
175
|
+
const isRecord = (value)=>null !== value && 'object' == typeof value && !Array.isArray(value);
|
|
176
|
+
const toStringArray = (value)=>Array.isArray(value) ? value.filter((entry)=>'string' == typeof entry) : [];
|
|
177
|
+
const getI18nServerPlugin = (appContext)=>appContext.serverPlugins?.find((plugin)=>plugin.name.includes(I18N_SERVER_PLUGIN_NAME));
|
|
178
|
+
const removeLocaleDetectionByEntry = (config)=>{
|
|
179
|
+
const { localeDetectionByEntry: _localeDetectionByEntry, ...rest } = config;
|
|
180
|
+
return rest;
|
|
181
|
+
};
|
|
182
|
+
const getEntryLocaleDetection = (entryName, localeDetection)=>{
|
|
183
|
+
const byEntry = isRecord(localeDetection.localeDetectionByEntry) ? localeDetection.localeDetectionByEntry : void 0;
|
|
184
|
+
const entryConfig = byEntry?.[entryName];
|
|
185
|
+
const globalConfig = removeLocaleDetectionByEntry(localeDetection);
|
|
186
|
+
return isRecord(entryConfig) ? {
|
|
187
|
+
...globalConfig,
|
|
188
|
+
...entryConfig
|
|
189
|
+
} : globalConfig;
|
|
190
|
+
};
|
|
191
|
+
const normalizeI18nDetection = (detection)=>{
|
|
192
|
+
const configured = isRecord(detection) ? detection : {};
|
|
193
|
+
const rawOrder = Array.isArray(configured.order) ? configured.order : DEFAULT_I18NEXT_DETECTION_OPTIONS.order;
|
|
194
|
+
const order = rawOrder.filter((entry)=>'string' == typeof entry && SERVER_SIDE_I18N_DETECTORS.has(entry));
|
|
195
|
+
return {
|
|
196
|
+
order: order.length > 0 ? order : [
|
|
197
|
+
'querystring',
|
|
198
|
+
'cookie',
|
|
199
|
+
'header'
|
|
200
|
+
],
|
|
201
|
+
lookupQuerystring: 'string' == typeof configured.lookupQuerystring && configured.lookupQuerystring ? configured.lookupQuerystring : DEFAULT_I18NEXT_DETECTION_OPTIONS.lookupQuerystring,
|
|
202
|
+
lookupCookie: 'string' == typeof configured.lookupCookie && configured.lookupCookie ? configured.lookupCookie : DEFAULT_I18NEXT_DETECTION_OPTIONS.lookupCookie,
|
|
203
|
+
lookupHeader: 'string' == typeof configured.lookupHeader && configured.lookupHeader ? configured.lookupHeader : DEFAULT_I18NEXT_DETECTION_OPTIONS.lookupHeader
|
|
204
|
+
};
|
|
205
|
+
};
|
|
206
|
+
const normalizeLocalisedUrls = (localisedUrls)=>{
|
|
207
|
+
if (!isRecord(localisedUrls) || 0 === Object.keys(localisedUrls).length) return;
|
|
208
|
+
const normalized = {};
|
|
209
|
+
for (const [canonicalPath, languageMap] of Object.entries(localisedUrls)){
|
|
210
|
+
if (!isRecord(languageMap)) continue;
|
|
211
|
+
const normalizedLanguageMap = Object.fromEntries(Object.entries(languageMap).filter((entry)=>'string' == typeof entry[1] && entry[1].length > 0));
|
|
212
|
+
if (Object.keys(normalizedLanguageMap).length > 0) normalized[canonicalPath] = normalizedLanguageMap;
|
|
213
|
+
}
|
|
214
|
+
return Object.keys(normalized).length > 0 ? normalized : void 0;
|
|
215
|
+
};
|
|
216
|
+
const normalizeWorkerI18nEntry = (entryName, localeDetection, staticRoutePrefixes)=>{
|
|
217
|
+
const entryConfig = getEntryLocaleDetection(entryName, localeDetection);
|
|
218
|
+
const languages = toStringArray(entryConfig.languages);
|
|
219
|
+
if (true !== entryConfig.localePathRedirect || 0 === languages.length) return;
|
|
220
|
+
const fallbackLanguage = 'string' == typeof entryConfig.fallbackLanguage && entryConfig.fallbackLanguage ? entryConfig.fallbackLanguage : 'en';
|
|
221
|
+
const ignoreRedirectRoutes = Array.isArray(entryConfig.ignoreRedirectRoutes) ? toStringArray(entryConfig.ignoreRedirectRoutes) : [];
|
|
222
|
+
const localisedUrls = normalizeLocalisedUrls(entryConfig.localisedUrls);
|
|
223
|
+
return {
|
|
224
|
+
i18nextDetector: false !== entryConfig.i18nextDetector,
|
|
225
|
+
languages,
|
|
226
|
+
fallbackLanguage,
|
|
227
|
+
detection: normalizeI18nDetection(entryConfig.detection),
|
|
228
|
+
ignoreRedirectRoutes,
|
|
229
|
+
staticRoutePrefixes,
|
|
230
|
+
...localisedUrls ? {
|
|
231
|
+
localisedUrls
|
|
232
|
+
} : {}
|
|
233
|
+
};
|
|
234
|
+
};
|
|
235
|
+
const createI18nWorkerManifest = (routeSpec, appContext)=>{
|
|
236
|
+
const i18nServerPlugin = getI18nServerPlugin(appContext);
|
|
237
|
+
const localeDetection = i18nServerPlugin?.options?.localeDetection;
|
|
238
|
+
if (!isRecord(localeDetection)) return;
|
|
239
|
+
const staticRoutePrefixes = toStringArray(i18nServerPlugin?.options?.staticRoutePrefixes);
|
|
240
|
+
const entries = {};
|
|
241
|
+
for (const route of routeSpec.routes){
|
|
242
|
+
if ('string' != typeof route.entryName || entries[route.entryName]) continue;
|
|
243
|
+
const entry = normalizeWorkerI18nEntry(route.entryName, localeDetection, staticRoutePrefixes);
|
|
244
|
+
if (entry) entries[route.entryName] = entry;
|
|
245
|
+
}
|
|
246
|
+
return Object.keys(entries).length > 0 ? {
|
|
247
|
+
entries
|
|
248
|
+
} : void 0;
|
|
249
|
+
};
|
|
154
250
|
const getCompatibilityDate = (modernConfig)=>{
|
|
155
251
|
const configuredDate = modernConfig.deploy?.worker?.compatibilityDate?.trim();
|
|
156
252
|
const compatibilityDate = configuredDate || DEFAULT_COMPATIBILITY_DATE;
|
|
@@ -381,7 +477,7 @@ const readRouteSpec = async (outputDirectory)=>{
|
|
|
381
477
|
};
|
|
382
478
|
};
|
|
383
479
|
const createMissingEffectBffWorkerError = (outputDirectory, worker)=>new Error(`Cloudflare Effect API runtime is configured, but the BFF worker bundle is missing: ${external_node_path_default().join(outputDirectory, worker)}. ${EFFECT_BFF_CLOUDFLARE_IMPORT_GUIDANCE}`);
|
|
384
|
-
const createWorkerManifest = async (outputDirectory, modernConfig)=>{
|
|
480
|
+
const createWorkerManifest = async (outputDirectory, modernConfig, appContext)=>{
|
|
385
481
|
const routeSpec = await readRouteSpec(outputDirectory);
|
|
386
482
|
const routes = await Promise.all(routeSpec.routes.map(async (route)=>{
|
|
387
483
|
const worker = 'string' == typeof route.worker ? route.worker : void 0;
|
|
@@ -427,6 +523,7 @@ const createWorkerManifest = async (outputDirectory, modernConfig)=>{
|
|
|
427
523
|
routeManifest: ROUTE_MANIFEST_FILE
|
|
428
524
|
},
|
|
429
525
|
security: createCloudflareWorkerSecurityPolicy(modernConfig),
|
|
526
|
+
i18n: createI18nWorkerManifest(routeSpec, appContext),
|
|
430
527
|
bff: isEffectApi && primaryBffPrefix && effectApiWorkerExists ? {
|
|
431
528
|
runtimeFramework: 'effect',
|
|
432
529
|
prefix: primaryBffPrefix,
|
|
@@ -517,7 +614,7 @@ const createCloudflarePreset = ({ appContext, modernConfig })=>{
|
|
|
517
614
|
await utils_namespaceObject.fs.writeJSON(wranglerConfigPath, createWranglerConfig(appDirectory, modernConfig), {
|
|
518
615
|
spaces: 2
|
|
519
616
|
});
|
|
520
|
-
await utils_namespaceObject.fs.writeJSON(workerManifestPath, await createWorkerManifest(outputDirectory, modernConfig), {
|
|
617
|
+
await utils_namespaceObject.fs.writeJSON(workerManifestPath, await createWorkerManifest(outputDirectory, modernConfig, appContext), {
|
|
521
618
|
spaces: 2
|
|
522
619
|
});
|
|
523
620
|
await utils_namespaceObject.fs.writeJSON(external_node_path_default().join(outputDirectory, OUTPUT_PACKAGE_FILE), {
|
|
@@ -223,6 +223,255 @@ function routeMatches(route, pathname) {
|
|
|
223
223
|
const requestPath = normalizeRoutePath(pathname);
|
|
224
224
|
return routePath === requestPath || '/' === routePath && route.isSSR || requestPath.startsWith(`${routePath}/`);
|
|
225
225
|
}
|
|
226
|
+
function getRouteI18nEntry(route) {
|
|
227
|
+
if (!route || 'string' != typeof route.entryName) return;
|
|
228
|
+
return MODERN_WORKER_MANIFEST.i18n?.entries?.[route.entryName];
|
|
229
|
+
}
|
|
230
|
+
function getRouteBasePath(route) {
|
|
231
|
+
const routePath = 'string' == typeof route?.urlPath ? normalizeRoutePath(route.urlPath) : '/';
|
|
232
|
+
return routePath || '/';
|
|
233
|
+
}
|
|
234
|
+
function getRouteRemainingPathname(route, pathname) {
|
|
235
|
+
const basePath = getRouteBasePath(route);
|
|
236
|
+
if ('/' === basePath) return pathname || '/';
|
|
237
|
+
if (!matchesPrefix(pathname, basePath)) return pathname || '/';
|
|
238
|
+
const remaining = pathname.slice(basePath.length);
|
|
239
|
+
return remaining ? remaining.startsWith('/') ? remaining : `/${remaining}` : '/';
|
|
240
|
+
}
|
|
241
|
+
function normalizeLanguage(value, languages) {
|
|
242
|
+
if ('string' != typeof value || '' === value.trim()) return null;
|
|
243
|
+
const normalized = value.trim().toLowerCase();
|
|
244
|
+
const exact = languages.find((language)=>language.toLowerCase() === normalized);
|
|
245
|
+
if (exact) return exact;
|
|
246
|
+
const baseLanguage = normalized.split(/[-_]/u)[0];
|
|
247
|
+
return languages.find((language)=>language.toLowerCase() === baseLanguage) || null;
|
|
248
|
+
}
|
|
249
|
+
function getLanguageFromPath(route, pathname, languages) {
|
|
250
|
+
const remainingPathname = getRouteRemainingPathname(route, pathname);
|
|
251
|
+
const firstSegment = remainingPathname.split('/').filter(Boolean)[0];
|
|
252
|
+
return normalizeLanguage(firstSegment, languages);
|
|
253
|
+
}
|
|
254
|
+
function safeDecodeURIComponent(value) {
|
|
255
|
+
try {
|
|
256
|
+
return decodeURIComponent(value);
|
|
257
|
+
} catch {
|
|
258
|
+
return value;
|
|
259
|
+
}
|
|
260
|
+
}
|
|
261
|
+
function detectLanguageFromCookie(cookieHeader, lookupCookie, languages) {
|
|
262
|
+
if (!cookieHeader) return null;
|
|
263
|
+
for (const cookiePart of cookieHeader.split(';')){
|
|
264
|
+
const [rawName, ...rawValue] = cookiePart.trim().split('=');
|
|
265
|
+
if (rawName === lookupCookie && 0 !== rawValue.length) return normalizeLanguage(safeDecodeURIComponent(rawValue.join('=')), languages);
|
|
266
|
+
}
|
|
267
|
+
return null;
|
|
268
|
+
}
|
|
269
|
+
function detectLanguageFromAcceptLanguage(acceptLanguage, languages) {
|
|
270
|
+
if (!acceptLanguage) return null;
|
|
271
|
+
return acceptLanguage.split(',').map((entry, index)=>{
|
|
272
|
+
const [rawRange, ...rawParameters] = entry.trim().split(';');
|
|
273
|
+
const qualityParameter = rawParameters.find((parameter)=>parameter.trim().startsWith('q='));
|
|
274
|
+
const quality = qualityParameter ? Number.parseFloat(qualityParameter.split('=')[1] || '') : 1;
|
|
275
|
+
return {
|
|
276
|
+
index,
|
|
277
|
+
language: normalizeLanguage(rawRange, languages),
|
|
278
|
+
quality: Number.isFinite(quality) ? quality : 1
|
|
279
|
+
};
|
|
280
|
+
}).filter((candidate)=>candidate.language && candidate.quality > 0).sort((left, right)=>right.quality - left.quality || left.index - right.index)[0]?.language || null;
|
|
281
|
+
}
|
|
282
|
+
function detectRedirectLanguage(request, entry) {
|
|
283
|
+
if (!entry.i18nextDetector) return null;
|
|
284
|
+
const url = new URL(request.url);
|
|
285
|
+
const detection = entry.detection || {};
|
|
286
|
+
const order = Array.isArray(detection.order) ? detection.order : [
|
|
287
|
+
'querystring',
|
|
288
|
+
'cookie',
|
|
289
|
+
'header'
|
|
290
|
+
];
|
|
291
|
+
for (const method of order){
|
|
292
|
+
const language = 'querystring' === method ? normalizeLanguage(url.searchParams.get(detection.lookupQuerystring || 'lng'), entry.languages) : 'cookie' === method ? detectLanguageFromCookie(request.headers.get('cookie'), detection.lookupCookie || 'i18next', entry.languages) : 'header' === method ? detectLanguageFromAcceptLanguage(request.headers.get(detection.lookupHeader || 'accept-language'), entry.languages) : null;
|
|
293
|
+
if (language) return language;
|
|
294
|
+
}
|
|
295
|
+
return null;
|
|
296
|
+
}
|
|
297
|
+
function normaliseSlashes(pathname) {
|
|
298
|
+
const withoutDuplicateSlashes = pathname.replace(/\/+/g, '/');
|
|
299
|
+
const withLeadingSlash = withoutDuplicateSlashes.startsWith('/') ? withoutDuplicateSlashes : `/${withoutDuplicateSlashes}`;
|
|
300
|
+
return withLeadingSlash.length > 1 ? withLeadingSlash.replace(/\/+$/u, '') : withLeadingSlash;
|
|
301
|
+
}
|
|
302
|
+
function normalisePathPattern(pathname) {
|
|
303
|
+
return normaliseSlashes(pathname).replace(/\[(.+?)\]/g, ':$1');
|
|
304
|
+
}
|
|
305
|
+
function stripLanguagePrefix(pathname, languages) {
|
|
306
|
+
const segments = pathname.split('/').filter(Boolean);
|
|
307
|
+
const firstSegment = segments[0];
|
|
308
|
+
if (firstSegment && normalizeLanguage(firstSegment, languages)) return `/${segments.slice(1).join('/')}` || '/';
|
|
309
|
+
return pathname || '/';
|
|
310
|
+
}
|
|
311
|
+
function escapeRegExp(value) {
|
|
312
|
+
return value.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
|
|
313
|
+
}
|
|
314
|
+
function getPatternParamName(segment) {
|
|
315
|
+
return segment.slice(1).replace(/\?$/, '');
|
|
316
|
+
}
|
|
317
|
+
function compilePathPattern(pattern) {
|
|
318
|
+
const names = [];
|
|
319
|
+
const segments = normalisePathPattern(pattern).split('/').filter(Boolean);
|
|
320
|
+
const source = segments.map((segment)=>{
|
|
321
|
+
if (segment.startsWith(':')) {
|
|
322
|
+
names.push(getPatternParamName(segment));
|
|
323
|
+
return segment.endsWith('?') ? '(?:/([^/]+))?' : '/([^/]+)';
|
|
324
|
+
}
|
|
325
|
+
if ('*' === segment) {
|
|
326
|
+
names.push('*');
|
|
327
|
+
return '/(.*)';
|
|
328
|
+
}
|
|
329
|
+
return `/${escapeRegExp(segment)}`;
|
|
330
|
+
}).join('');
|
|
331
|
+
return {
|
|
332
|
+
names,
|
|
333
|
+
regexp: new RegExp(`^${source || '/'}$`)
|
|
334
|
+
};
|
|
335
|
+
}
|
|
336
|
+
function matchPathPattern(pathname, pattern) {
|
|
337
|
+
const { names, regexp } = compilePathPattern(pattern);
|
|
338
|
+
const match = regexp.exec(normaliseSlashes(pathname));
|
|
339
|
+
if (!match) return null;
|
|
340
|
+
const params = {};
|
|
341
|
+
for(let index = 0; index < names.length; index++)try {
|
|
342
|
+
params[names[index]] = decodeURIComponent(match[index + 1] || '');
|
|
343
|
+
} catch {
|
|
344
|
+
return null;
|
|
345
|
+
}
|
|
346
|
+
return params;
|
|
347
|
+
}
|
|
348
|
+
function buildPathFromPattern(pattern, params) {
|
|
349
|
+
const path = normalisePathPattern(pattern).split('/').filter(Boolean).map((segment)=>{
|
|
350
|
+
if (segment.startsWith(':')) {
|
|
351
|
+
const param = params[getPatternParamName(segment)];
|
|
352
|
+
return param ? encodeURIComponent(param) : '';
|
|
353
|
+
}
|
|
354
|
+
if ('*' === segment) return params['*'] || '';
|
|
355
|
+
return segment;
|
|
356
|
+
}).filter(Boolean).join('/');
|
|
357
|
+
return `/${path}`;
|
|
358
|
+
}
|
|
359
|
+
function patternSpecificity(pattern) {
|
|
360
|
+
const segments = normalisePathPattern(pattern).split('/').filter(Boolean);
|
|
361
|
+
let staticSegments = 0;
|
|
362
|
+
let dynamicSegments = 0;
|
|
363
|
+
let splatSegments = 0;
|
|
364
|
+
for (const segment of segments)if ('*' === segment) splatSegments++;
|
|
365
|
+
else if (segment.startsWith(':')) dynamicSegments++;
|
|
366
|
+
else staticSegments++;
|
|
367
|
+
return {
|
|
368
|
+
dynamicSegments,
|
|
369
|
+
splatSegments,
|
|
370
|
+
staticSegments,
|
|
371
|
+
totalSegments: segments.length
|
|
372
|
+
};
|
|
373
|
+
}
|
|
374
|
+
function comparePatternSpecificity(left, right) {
|
|
375
|
+
const leftSpecificity = patternSpecificity(left);
|
|
376
|
+
const rightSpecificity = patternSpecificity(right);
|
|
377
|
+
return rightSpecificity.staticSegments - leftSpecificity.staticSegments || rightSpecificity.totalSegments - leftSpecificity.totalSegments || leftSpecificity.splatSegments - rightSpecificity.splatSegments || leftSpecificity.dynamicSegments - rightSpecificity.dynamicSegments;
|
|
378
|
+
}
|
|
379
|
+
function sortLocalisedPatterns(patterns) {
|
|
380
|
+
return patterns.map((pattern, index)=>({
|
|
381
|
+
pattern,
|
|
382
|
+
index
|
|
383
|
+
})).sort((left, right)=>comparePatternSpecificity(left.pattern.pattern, right.pattern.pattern) || left.index - right.index).map(({ pattern })=>pattern);
|
|
384
|
+
}
|
|
385
|
+
function resolveLocalisedPath(pathname, targetLanguage, languages, localisedUrls) {
|
|
386
|
+
const normalizedPathname = normaliseSlashes(pathname);
|
|
387
|
+
const canonicalCandidates = sortLocalisedPatterns(Object.entries(localisedUrls || {}).map(([canonicalPattern, localisedUrlEntry])=>({
|
|
388
|
+
canonicalPattern,
|
|
389
|
+
localisedUrlEntry,
|
|
390
|
+
pattern: canonicalPattern
|
|
391
|
+
})));
|
|
392
|
+
for (const { canonicalPattern, localisedUrlEntry } of canonicalCandidates){
|
|
393
|
+
const targetPattern = localisedUrlEntry[targetLanguage];
|
|
394
|
+
const params = targetPattern ? matchPathPattern(normalizedPathname, canonicalPattern) : null;
|
|
395
|
+
if (params) return buildPathFromPattern(targetPattern, params);
|
|
396
|
+
}
|
|
397
|
+
const localisedCandidates = sortLocalisedPatterns(Object.values(localisedUrls || {}).flatMap((localisedUrlEntry)=>{
|
|
398
|
+
const targetPattern = localisedUrlEntry[targetLanguage];
|
|
399
|
+
if (!targetPattern) return [];
|
|
400
|
+
return languages.map((language)=>localisedUrlEntry[language]).filter(Boolean).map((sourcePattern)=>({
|
|
401
|
+
pattern: sourcePattern,
|
|
402
|
+
sourcePattern,
|
|
403
|
+
targetPattern
|
|
404
|
+
}));
|
|
405
|
+
}));
|
|
406
|
+
for (const { sourcePattern, targetPattern } of localisedCandidates){
|
|
407
|
+
const params = matchPathPattern(normalizedPathname, sourcePattern);
|
|
408
|
+
if (params) return buildPathFromPattern(targetPattern, params);
|
|
409
|
+
}
|
|
410
|
+
return normalizedPathname;
|
|
411
|
+
}
|
|
412
|
+
function localiseTargetPathname(pathname, language, languages, localisedUrls) {
|
|
413
|
+
const pathWithoutLanguage = stripLanguagePrefix(pathname, languages);
|
|
414
|
+
const resolvedPath = localisedUrls ? resolveLocalisedPath(pathWithoutLanguage, language, languages, localisedUrls) : pathWithoutLanguage;
|
|
415
|
+
const resolvedSegments = resolvedPath.split('/').filter(Boolean);
|
|
416
|
+
return `/${[
|
|
417
|
+
language,
|
|
418
|
+
...resolvedSegments
|
|
419
|
+
].join('/')}`;
|
|
420
|
+
}
|
|
421
|
+
function shouldIgnoreLocaleRedirect(pathname, route, entry) {
|
|
422
|
+
const remainingPathname = getRouteRemainingPathname(route, pathname);
|
|
423
|
+
const normalizedPathname = remainingPathname.startsWith('/') ? remainingPathname : `/${remainingPathname}`;
|
|
424
|
+
const ignoredPrefixes = [
|
|
425
|
+
...entry.ignoreRedirectRoutes || [],
|
|
426
|
+
...entry.staticRoutePrefixes || [],
|
|
427
|
+
'/static',
|
|
428
|
+
'/upload'
|
|
429
|
+
];
|
|
430
|
+
return ignoredPrefixes.some((prefix)=>{
|
|
431
|
+
const normalizedPrefix = normaliseSlashes(prefix);
|
|
432
|
+
return normalizedPathname === normalizedPrefix || normalizedPathname.startsWith(`${normalizedPrefix}/`);
|
|
433
|
+
});
|
|
434
|
+
}
|
|
435
|
+
function buildLocalizedUrl(route, request, language, entry) {
|
|
436
|
+
const url = new URL(request.url);
|
|
437
|
+
const basePath = getRouteBasePath(route);
|
|
438
|
+
const remainingPathname = getRouteRemainingPathname(route, url.pathname);
|
|
439
|
+
const localizedPathname = localiseTargetPathname(remainingPathname, language, entry.languages, entry.localisedUrls);
|
|
440
|
+
const nextPathname = '/' === basePath ? localizedPathname : normaliseSlashes(`${basePath}/${localizedPathname}`);
|
|
441
|
+
return `${nextPathname}${url.search}`;
|
|
442
|
+
}
|
|
443
|
+
function createLocaleRedirectResponse(location, entry) {
|
|
444
|
+
const headers = new Headers({
|
|
445
|
+
'cache-control': 'private, no-store',
|
|
446
|
+
location
|
|
447
|
+
});
|
|
448
|
+
const vary = [];
|
|
449
|
+
const detectionOrder = entry.i18nextDetector ? entry.detection?.order || [] : [];
|
|
450
|
+
if (detectionOrder.includes('header')) vary.push('Accept-Language');
|
|
451
|
+
if (detectionOrder.includes('cookie')) vary.push('Cookie');
|
|
452
|
+
if (vary.length > 0) headers.set('vary', vary.join(', '));
|
|
453
|
+
return new Response(null, {
|
|
454
|
+
headers,
|
|
455
|
+
status: 302
|
|
456
|
+
});
|
|
457
|
+
}
|
|
458
|
+
function createLocaleRedirectResponseForRequest(route, request) {
|
|
459
|
+
const entry = getRouteI18nEntry(route);
|
|
460
|
+
if (!entry || !Array.isArray(entry.languages) || 0 === entry.languages.length) return null;
|
|
461
|
+
const { pathname, search } = new URL(request.url);
|
|
462
|
+
if (isAssetLikePathname(pathname)) return null;
|
|
463
|
+
if (shouldIgnoreLocaleRedirect(pathname, route, entry)) return null;
|
|
464
|
+
const language = getLanguageFromPath(route, pathname, entry.languages);
|
|
465
|
+
if (!language) {
|
|
466
|
+
const targetLanguage = detectRedirectLanguage(request, entry) || entry.fallbackLanguage;
|
|
467
|
+
return createLocaleRedirectResponse(buildLocalizedUrl(route, request, targetLanguage, entry), entry);
|
|
468
|
+
}
|
|
469
|
+
if (entry.localisedUrls) {
|
|
470
|
+
const expectedUrl = buildLocalizedUrl(route, request, language, entry);
|
|
471
|
+
if (expectedUrl !== `${pathname}${search}`) return createLocaleRedirectResponse(expectedUrl, entry);
|
|
472
|
+
}
|
|
473
|
+
return null;
|
|
474
|
+
}
|
|
226
475
|
function findRoute(request) {
|
|
227
476
|
const { pathname } = new URL(request.url);
|
|
228
477
|
const routes = MODERN_WORKER_MANIFEST.routeSpec.routes;
|
|
@@ -565,6 +814,8 @@ export default {
|
|
|
565
814
|
status: 404
|
|
566
815
|
}), request), request);
|
|
567
816
|
}
|
|
817
|
+
const localeRedirectResponse = createLocaleRedirectResponseForRequest(route, request);
|
|
818
|
+
if (localeRedirectResponse) return finalizeResponseForRequest(withAppCorsHeaders(localeRedirectResponse, request), request);
|
|
568
819
|
if (route?.worker) {
|
|
569
820
|
const renderableRequest = createRenderableRequest(request);
|
|
570
821
|
return finalizeResponseForRequest(withAppCorsHeaders(await dispatchRouteWorker(route, renderableRequest, env, ctx), request), request);
|
|
@@ -109,6 +109,102 @@ const DEFAULT_CSP_DIRECTIVES = {
|
|
|
109
109
|
'blob:'
|
|
110
110
|
]
|
|
111
111
|
};
|
|
112
|
+
const I18N_SERVER_PLUGIN_NAME = 'plugin-i18n/server';
|
|
113
|
+
const DEFAULT_I18NEXT_DETECTION_OPTIONS = {
|
|
114
|
+
order: [
|
|
115
|
+
'querystring',
|
|
116
|
+
'cookie',
|
|
117
|
+
'localStorage',
|
|
118
|
+
'header',
|
|
119
|
+
'navigator',
|
|
120
|
+
'htmlTag',
|
|
121
|
+
'path',
|
|
122
|
+
'subdomain'
|
|
123
|
+
],
|
|
124
|
+
lookupQuerystring: 'lng',
|
|
125
|
+
lookupCookie: 'i18next',
|
|
126
|
+
lookupHeader: 'accept-language'
|
|
127
|
+
};
|
|
128
|
+
const SERVER_SIDE_I18N_DETECTORS = new Set([
|
|
129
|
+
'querystring',
|
|
130
|
+
'cookie',
|
|
131
|
+
'header'
|
|
132
|
+
]);
|
|
133
|
+
const isRecord = (value)=>null !== value && 'object' == typeof value && !Array.isArray(value);
|
|
134
|
+
const toStringArray = (value)=>Array.isArray(value) ? value.filter((entry)=>'string' == typeof entry) : [];
|
|
135
|
+
const getI18nServerPlugin = (appContext)=>appContext.serverPlugins?.find((plugin)=>plugin.name.includes(I18N_SERVER_PLUGIN_NAME));
|
|
136
|
+
const removeLocaleDetectionByEntry = (config)=>{
|
|
137
|
+
const { localeDetectionByEntry: _localeDetectionByEntry, ...rest } = config;
|
|
138
|
+
return rest;
|
|
139
|
+
};
|
|
140
|
+
const getEntryLocaleDetection = (entryName, localeDetection)=>{
|
|
141
|
+
const byEntry = isRecord(localeDetection.localeDetectionByEntry) ? localeDetection.localeDetectionByEntry : void 0;
|
|
142
|
+
const entryConfig = byEntry?.[entryName];
|
|
143
|
+
const globalConfig = removeLocaleDetectionByEntry(localeDetection);
|
|
144
|
+
return isRecord(entryConfig) ? {
|
|
145
|
+
...globalConfig,
|
|
146
|
+
...entryConfig
|
|
147
|
+
} : globalConfig;
|
|
148
|
+
};
|
|
149
|
+
const normalizeI18nDetection = (detection)=>{
|
|
150
|
+
const configured = isRecord(detection) ? detection : {};
|
|
151
|
+
const rawOrder = Array.isArray(configured.order) ? configured.order : DEFAULT_I18NEXT_DETECTION_OPTIONS.order;
|
|
152
|
+
const order = rawOrder.filter((entry)=>'string' == typeof entry && SERVER_SIDE_I18N_DETECTORS.has(entry));
|
|
153
|
+
return {
|
|
154
|
+
order: order.length > 0 ? order : [
|
|
155
|
+
'querystring',
|
|
156
|
+
'cookie',
|
|
157
|
+
'header'
|
|
158
|
+
],
|
|
159
|
+
lookupQuerystring: 'string' == typeof configured.lookupQuerystring && configured.lookupQuerystring ? configured.lookupQuerystring : DEFAULT_I18NEXT_DETECTION_OPTIONS.lookupQuerystring,
|
|
160
|
+
lookupCookie: 'string' == typeof configured.lookupCookie && configured.lookupCookie ? configured.lookupCookie : DEFAULT_I18NEXT_DETECTION_OPTIONS.lookupCookie,
|
|
161
|
+
lookupHeader: 'string' == typeof configured.lookupHeader && configured.lookupHeader ? configured.lookupHeader : DEFAULT_I18NEXT_DETECTION_OPTIONS.lookupHeader
|
|
162
|
+
};
|
|
163
|
+
};
|
|
164
|
+
const normalizeLocalisedUrls = (localisedUrls)=>{
|
|
165
|
+
if (!isRecord(localisedUrls) || 0 === Object.keys(localisedUrls).length) return;
|
|
166
|
+
const normalized = {};
|
|
167
|
+
for (const [canonicalPath, languageMap] of Object.entries(localisedUrls)){
|
|
168
|
+
if (!isRecord(languageMap)) continue;
|
|
169
|
+
const normalizedLanguageMap = Object.fromEntries(Object.entries(languageMap).filter((entry)=>'string' == typeof entry[1] && entry[1].length > 0));
|
|
170
|
+
if (Object.keys(normalizedLanguageMap).length > 0) normalized[canonicalPath] = normalizedLanguageMap;
|
|
171
|
+
}
|
|
172
|
+
return Object.keys(normalized).length > 0 ? normalized : void 0;
|
|
173
|
+
};
|
|
174
|
+
const normalizeWorkerI18nEntry = (entryName, localeDetection, staticRoutePrefixes)=>{
|
|
175
|
+
const entryConfig = getEntryLocaleDetection(entryName, localeDetection);
|
|
176
|
+
const languages = toStringArray(entryConfig.languages);
|
|
177
|
+
if (true !== entryConfig.localePathRedirect || 0 === languages.length) return;
|
|
178
|
+
const fallbackLanguage = 'string' == typeof entryConfig.fallbackLanguage && entryConfig.fallbackLanguage ? entryConfig.fallbackLanguage : 'en';
|
|
179
|
+
const ignoreRedirectRoutes = Array.isArray(entryConfig.ignoreRedirectRoutes) ? toStringArray(entryConfig.ignoreRedirectRoutes) : [];
|
|
180
|
+
const localisedUrls = normalizeLocalisedUrls(entryConfig.localisedUrls);
|
|
181
|
+
return {
|
|
182
|
+
i18nextDetector: false !== entryConfig.i18nextDetector,
|
|
183
|
+
languages,
|
|
184
|
+
fallbackLanguage,
|
|
185
|
+
detection: normalizeI18nDetection(entryConfig.detection),
|
|
186
|
+
ignoreRedirectRoutes,
|
|
187
|
+
staticRoutePrefixes,
|
|
188
|
+
...localisedUrls ? {
|
|
189
|
+
localisedUrls
|
|
190
|
+
} : {}
|
|
191
|
+
};
|
|
192
|
+
};
|
|
193
|
+
const createI18nWorkerManifest = (routeSpec, appContext)=>{
|
|
194
|
+
const i18nServerPlugin = getI18nServerPlugin(appContext);
|
|
195
|
+
const localeDetection = i18nServerPlugin?.options?.localeDetection;
|
|
196
|
+
if (!isRecord(localeDetection)) return;
|
|
197
|
+
const staticRoutePrefixes = toStringArray(i18nServerPlugin?.options?.staticRoutePrefixes);
|
|
198
|
+
const entries = {};
|
|
199
|
+
for (const route of routeSpec.routes){
|
|
200
|
+
if ('string' != typeof route.entryName || entries[route.entryName]) continue;
|
|
201
|
+
const entry = normalizeWorkerI18nEntry(route.entryName, localeDetection, staticRoutePrefixes);
|
|
202
|
+
if (entry) entries[route.entryName] = entry;
|
|
203
|
+
}
|
|
204
|
+
return Object.keys(entries).length > 0 ? {
|
|
205
|
+
entries
|
|
206
|
+
} : void 0;
|
|
207
|
+
};
|
|
112
208
|
const getCompatibilityDate = (modernConfig)=>{
|
|
113
209
|
const configuredDate = modernConfig.deploy?.worker?.compatibilityDate?.trim();
|
|
114
210
|
const compatibilityDate = configuredDate || DEFAULT_COMPATIBILITY_DATE;
|
|
@@ -339,7 +435,7 @@ const readRouteSpec = async (outputDirectory)=>{
|
|
|
339
435
|
};
|
|
340
436
|
};
|
|
341
437
|
const createMissingEffectBffWorkerError = (outputDirectory, worker)=>new Error(`Cloudflare Effect API runtime is configured, but the BFF worker bundle is missing: ${node_path.join(outputDirectory, worker)}. ${EFFECT_BFF_CLOUDFLARE_IMPORT_GUIDANCE}`);
|
|
342
|
-
const createWorkerManifest = async (outputDirectory, modernConfig)=>{
|
|
438
|
+
const createWorkerManifest = async (outputDirectory, modernConfig, appContext)=>{
|
|
343
439
|
const routeSpec = await readRouteSpec(outputDirectory);
|
|
344
440
|
const routes = await Promise.all(routeSpec.routes.map(async (route)=>{
|
|
345
441
|
const worker = 'string' == typeof route.worker ? route.worker : void 0;
|
|
@@ -385,6 +481,7 @@ const createWorkerManifest = async (outputDirectory, modernConfig)=>{
|
|
|
385
481
|
routeManifest: ROUTE_MANIFEST_FILE
|
|
386
482
|
},
|
|
387
483
|
security: createCloudflareWorkerSecurityPolicy(modernConfig),
|
|
484
|
+
i18n: createI18nWorkerManifest(routeSpec, appContext),
|
|
388
485
|
bff: isEffectApi && primaryBffPrefix && effectApiWorkerExists ? {
|
|
389
486
|
runtimeFramework: 'effect',
|
|
390
487
|
prefix: primaryBffPrefix,
|
|
@@ -475,7 +572,7 @@ const createCloudflarePreset = ({ appContext, modernConfig })=>{
|
|
|
475
572
|
await fs.writeJSON(wranglerConfigPath, createWranglerConfig(appDirectory, modernConfig), {
|
|
476
573
|
spaces: 2
|
|
477
574
|
});
|
|
478
|
-
await fs.writeJSON(workerManifestPath, await createWorkerManifest(outputDirectory, modernConfig), {
|
|
575
|
+
await fs.writeJSON(workerManifestPath, await createWorkerManifest(outputDirectory, modernConfig, appContext), {
|
|
479
576
|
spaces: 2
|
|
480
577
|
});
|
|
481
578
|
await fs.writeJSON(node_path.join(outputDirectory, OUTPUT_PACKAGE_FILE), {
|
|
@@ -223,6 +223,255 @@ function routeMatches(route, pathname) {
|
|
|
223
223
|
const requestPath = normalizeRoutePath(pathname);
|
|
224
224
|
return routePath === requestPath || '/' === routePath && route.isSSR || requestPath.startsWith(`${routePath}/`);
|
|
225
225
|
}
|
|
226
|
+
function getRouteI18nEntry(route) {
|
|
227
|
+
if (!route || 'string' != typeof route.entryName) return;
|
|
228
|
+
return MODERN_WORKER_MANIFEST.i18n?.entries?.[route.entryName];
|
|
229
|
+
}
|
|
230
|
+
function getRouteBasePath(route) {
|
|
231
|
+
const routePath = 'string' == typeof route?.urlPath ? normalizeRoutePath(route.urlPath) : '/';
|
|
232
|
+
return routePath || '/';
|
|
233
|
+
}
|
|
234
|
+
function getRouteRemainingPathname(route, pathname) {
|
|
235
|
+
const basePath = getRouteBasePath(route);
|
|
236
|
+
if ('/' === basePath) return pathname || '/';
|
|
237
|
+
if (!matchesPrefix(pathname, basePath)) return pathname || '/';
|
|
238
|
+
const remaining = pathname.slice(basePath.length);
|
|
239
|
+
return remaining ? remaining.startsWith('/') ? remaining : `/${remaining}` : '/';
|
|
240
|
+
}
|
|
241
|
+
function normalizeLanguage(value, languages) {
|
|
242
|
+
if ('string' != typeof value || '' === value.trim()) return null;
|
|
243
|
+
const normalized = value.trim().toLowerCase();
|
|
244
|
+
const exact = languages.find((language)=>language.toLowerCase() === normalized);
|
|
245
|
+
if (exact) return exact;
|
|
246
|
+
const baseLanguage = normalized.split(/[-_]/u)[0];
|
|
247
|
+
return languages.find((language)=>language.toLowerCase() === baseLanguage) || null;
|
|
248
|
+
}
|
|
249
|
+
function getLanguageFromPath(route, pathname, languages) {
|
|
250
|
+
const remainingPathname = getRouteRemainingPathname(route, pathname);
|
|
251
|
+
const firstSegment = remainingPathname.split('/').filter(Boolean)[0];
|
|
252
|
+
return normalizeLanguage(firstSegment, languages);
|
|
253
|
+
}
|
|
254
|
+
function safeDecodeURIComponent(value) {
|
|
255
|
+
try {
|
|
256
|
+
return decodeURIComponent(value);
|
|
257
|
+
} catch {
|
|
258
|
+
return value;
|
|
259
|
+
}
|
|
260
|
+
}
|
|
261
|
+
function detectLanguageFromCookie(cookieHeader, lookupCookie, languages) {
|
|
262
|
+
if (!cookieHeader) return null;
|
|
263
|
+
for (const cookiePart of cookieHeader.split(';')){
|
|
264
|
+
const [rawName, ...rawValue] = cookiePart.trim().split('=');
|
|
265
|
+
if (rawName === lookupCookie && 0 !== rawValue.length) return normalizeLanguage(safeDecodeURIComponent(rawValue.join('=')), languages);
|
|
266
|
+
}
|
|
267
|
+
return null;
|
|
268
|
+
}
|
|
269
|
+
function detectLanguageFromAcceptLanguage(acceptLanguage, languages) {
|
|
270
|
+
if (!acceptLanguage) return null;
|
|
271
|
+
return acceptLanguage.split(',').map((entry, index)=>{
|
|
272
|
+
const [rawRange, ...rawParameters] = entry.trim().split(';');
|
|
273
|
+
const qualityParameter = rawParameters.find((parameter)=>parameter.trim().startsWith('q='));
|
|
274
|
+
const quality = qualityParameter ? Number.parseFloat(qualityParameter.split('=')[1] || '') : 1;
|
|
275
|
+
return {
|
|
276
|
+
index,
|
|
277
|
+
language: normalizeLanguage(rawRange, languages),
|
|
278
|
+
quality: Number.isFinite(quality) ? quality : 1
|
|
279
|
+
};
|
|
280
|
+
}).filter((candidate)=>candidate.language && candidate.quality > 0).sort((left, right)=>right.quality - left.quality || left.index - right.index)[0]?.language || null;
|
|
281
|
+
}
|
|
282
|
+
function detectRedirectLanguage(request, entry) {
|
|
283
|
+
if (!entry.i18nextDetector) return null;
|
|
284
|
+
const url = new URL(request.url);
|
|
285
|
+
const detection = entry.detection || {};
|
|
286
|
+
const order = Array.isArray(detection.order) ? detection.order : [
|
|
287
|
+
'querystring',
|
|
288
|
+
'cookie',
|
|
289
|
+
'header'
|
|
290
|
+
];
|
|
291
|
+
for (const method of order){
|
|
292
|
+
const language = 'querystring' === method ? normalizeLanguage(url.searchParams.get(detection.lookupQuerystring || 'lng'), entry.languages) : 'cookie' === method ? detectLanguageFromCookie(request.headers.get('cookie'), detection.lookupCookie || 'i18next', entry.languages) : 'header' === method ? detectLanguageFromAcceptLanguage(request.headers.get(detection.lookupHeader || 'accept-language'), entry.languages) : null;
|
|
293
|
+
if (language) return language;
|
|
294
|
+
}
|
|
295
|
+
return null;
|
|
296
|
+
}
|
|
297
|
+
function normaliseSlashes(pathname) {
|
|
298
|
+
const withoutDuplicateSlashes = pathname.replace(/\/+/g, '/');
|
|
299
|
+
const withLeadingSlash = withoutDuplicateSlashes.startsWith('/') ? withoutDuplicateSlashes : `/${withoutDuplicateSlashes}`;
|
|
300
|
+
return withLeadingSlash.length > 1 ? withLeadingSlash.replace(/\/+$/u, '') : withLeadingSlash;
|
|
301
|
+
}
|
|
302
|
+
function normalisePathPattern(pathname) {
|
|
303
|
+
return normaliseSlashes(pathname).replace(/\[(.+?)\]/g, ':$1');
|
|
304
|
+
}
|
|
305
|
+
function stripLanguagePrefix(pathname, languages) {
|
|
306
|
+
const segments = pathname.split('/').filter(Boolean);
|
|
307
|
+
const firstSegment = segments[0];
|
|
308
|
+
if (firstSegment && normalizeLanguage(firstSegment, languages)) return `/${segments.slice(1).join('/')}` || '/';
|
|
309
|
+
return pathname || '/';
|
|
310
|
+
}
|
|
311
|
+
function escapeRegExp(value) {
|
|
312
|
+
return value.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
|
|
313
|
+
}
|
|
314
|
+
function getPatternParamName(segment) {
|
|
315
|
+
return segment.slice(1).replace(/\?$/, '');
|
|
316
|
+
}
|
|
317
|
+
function compilePathPattern(pattern) {
|
|
318
|
+
const names = [];
|
|
319
|
+
const segments = normalisePathPattern(pattern).split('/').filter(Boolean);
|
|
320
|
+
const source = segments.map((segment)=>{
|
|
321
|
+
if (segment.startsWith(':')) {
|
|
322
|
+
names.push(getPatternParamName(segment));
|
|
323
|
+
return segment.endsWith('?') ? '(?:/([^/]+))?' : '/([^/]+)';
|
|
324
|
+
}
|
|
325
|
+
if ('*' === segment) {
|
|
326
|
+
names.push('*');
|
|
327
|
+
return '/(.*)';
|
|
328
|
+
}
|
|
329
|
+
return `/${escapeRegExp(segment)}`;
|
|
330
|
+
}).join('');
|
|
331
|
+
return {
|
|
332
|
+
names,
|
|
333
|
+
regexp: new RegExp(`^${source || '/'}$`)
|
|
334
|
+
};
|
|
335
|
+
}
|
|
336
|
+
function matchPathPattern(pathname, pattern) {
|
|
337
|
+
const { names, regexp } = compilePathPattern(pattern);
|
|
338
|
+
const match = regexp.exec(normaliseSlashes(pathname));
|
|
339
|
+
if (!match) return null;
|
|
340
|
+
const params = {};
|
|
341
|
+
for(let index = 0; index < names.length; index++)try {
|
|
342
|
+
params[names[index]] = decodeURIComponent(match[index + 1] || '');
|
|
343
|
+
} catch {
|
|
344
|
+
return null;
|
|
345
|
+
}
|
|
346
|
+
return params;
|
|
347
|
+
}
|
|
348
|
+
function buildPathFromPattern(pattern, params) {
|
|
349
|
+
const path = normalisePathPattern(pattern).split('/').filter(Boolean).map((segment)=>{
|
|
350
|
+
if (segment.startsWith(':')) {
|
|
351
|
+
const param = params[getPatternParamName(segment)];
|
|
352
|
+
return param ? encodeURIComponent(param) : '';
|
|
353
|
+
}
|
|
354
|
+
if ('*' === segment) return params['*'] || '';
|
|
355
|
+
return segment;
|
|
356
|
+
}).filter(Boolean).join('/');
|
|
357
|
+
return `/${path}`;
|
|
358
|
+
}
|
|
359
|
+
function patternSpecificity(pattern) {
|
|
360
|
+
const segments = normalisePathPattern(pattern).split('/').filter(Boolean);
|
|
361
|
+
let staticSegments = 0;
|
|
362
|
+
let dynamicSegments = 0;
|
|
363
|
+
let splatSegments = 0;
|
|
364
|
+
for (const segment of segments)if ('*' === segment) splatSegments++;
|
|
365
|
+
else if (segment.startsWith(':')) dynamicSegments++;
|
|
366
|
+
else staticSegments++;
|
|
367
|
+
return {
|
|
368
|
+
dynamicSegments,
|
|
369
|
+
splatSegments,
|
|
370
|
+
staticSegments,
|
|
371
|
+
totalSegments: segments.length
|
|
372
|
+
};
|
|
373
|
+
}
|
|
374
|
+
function comparePatternSpecificity(left, right) {
|
|
375
|
+
const leftSpecificity = patternSpecificity(left);
|
|
376
|
+
const rightSpecificity = patternSpecificity(right);
|
|
377
|
+
return rightSpecificity.staticSegments - leftSpecificity.staticSegments || rightSpecificity.totalSegments - leftSpecificity.totalSegments || leftSpecificity.splatSegments - rightSpecificity.splatSegments || leftSpecificity.dynamicSegments - rightSpecificity.dynamicSegments;
|
|
378
|
+
}
|
|
379
|
+
function sortLocalisedPatterns(patterns) {
|
|
380
|
+
return patterns.map((pattern, index)=>({
|
|
381
|
+
pattern,
|
|
382
|
+
index
|
|
383
|
+
})).sort((left, right)=>comparePatternSpecificity(left.pattern.pattern, right.pattern.pattern) || left.index - right.index).map(({ pattern })=>pattern);
|
|
384
|
+
}
|
|
385
|
+
function resolveLocalisedPath(pathname, targetLanguage, languages, localisedUrls) {
|
|
386
|
+
const normalizedPathname = normaliseSlashes(pathname);
|
|
387
|
+
const canonicalCandidates = sortLocalisedPatterns(Object.entries(localisedUrls || {}).map(([canonicalPattern, localisedUrlEntry])=>({
|
|
388
|
+
canonicalPattern,
|
|
389
|
+
localisedUrlEntry,
|
|
390
|
+
pattern: canonicalPattern
|
|
391
|
+
})));
|
|
392
|
+
for (const { canonicalPattern, localisedUrlEntry } of canonicalCandidates){
|
|
393
|
+
const targetPattern = localisedUrlEntry[targetLanguage];
|
|
394
|
+
const params = targetPattern ? matchPathPattern(normalizedPathname, canonicalPattern) : null;
|
|
395
|
+
if (params) return buildPathFromPattern(targetPattern, params);
|
|
396
|
+
}
|
|
397
|
+
const localisedCandidates = sortLocalisedPatterns(Object.values(localisedUrls || {}).flatMap((localisedUrlEntry)=>{
|
|
398
|
+
const targetPattern = localisedUrlEntry[targetLanguage];
|
|
399
|
+
if (!targetPattern) return [];
|
|
400
|
+
return languages.map((language)=>localisedUrlEntry[language]).filter(Boolean).map((sourcePattern)=>({
|
|
401
|
+
pattern: sourcePattern,
|
|
402
|
+
sourcePattern,
|
|
403
|
+
targetPattern
|
|
404
|
+
}));
|
|
405
|
+
}));
|
|
406
|
+
for (const { sourcePattern, targetPattern } of localisedCandidates){
|
|
407
|
+
const params = matchPathPattern(normalizedPathname, sourcePattern);
|
|
408
|
+
if (params) return buildPathFromPattern(targetPattern, params);
|
|
409
|
+
}
|
|
410
|
+
return normalizedPathname;
|
|
411
|
+
}
|
|
412
|
+
function localiseTargetPathname(pathname, language, languages, localisedUrls) {
|
|
413
|
+
const pathWithoutLanguage = stripLanguagePrefix(pathname, languages);
|
|
414
|
+
const resolvedPath = localisedUrls ? resolveLocalisedPath(pathWithoutLanguage, language, languages, localisedUrls) : pathWithoutLanguage;
|
|
415
|
+
const resolvedSegments = resolvedPath.split('/').filter(Boolean);
|
|
416
|
+
return `/${[
|
|
417
|
+
language,
|
|
418
|
+
...resolvedSegments
|
|
419
|
+
].join('/')}`;
|
|
420
|
+
}
|
|
421
|
+
function shouldIgnoreLocaleRedirect(pathname, route, entry) {
|
|
422
|
+
const remainingPathname = getRouteRemainingPathname(route, pathname);
|
|
423
|
+
const normalizedPathname = remainingPathname.startsWith('/') ? remainingPathname : `/${remainingPathname}`;
|
|
424
|
+
const ignoredPrefixes = [
|
|
425
|
+
...entry.ignoreRedirectRoutes || [],
|
|
426
|
+
...entry.staticRoutePrefixes || [],
|
|
427
|
+
'/static',
|
|
428
|
+
'/upload'
|
|
429
|
+
];
|
|
430
|
+
return ignoredPrefixes.some((prefix)=>{
|
|
431
|
+
const normalizedPrefix = normaliseSlashes(prefix);
|
|
432
|
+
return normalizedPathname === normalizedPrefix || normalizedPathname.startsWith(`${normalizedPrefix}/`);
|
|
433
|
+
});
|
|
434
|
+
}
|
|
435
|
+
function buildLocalizedUrl(route, request, language, entry) {
|
|
436
|
+
const url = new URL(request.url);
|
|
437
|
+
const basePath = getRouteBasePath(route);
|
|
438
|
+
const remainingPathname = getRouteRemainingPathname(route, url.pathname);
|
|
439
|
+
const localizedPathname = localiseTargetPathname(remainingPathname, language, entry.languages, entry.localisedUrls);
|
|
440
|
+
const nextPathname = '/' === basePath ? localizedPathname : normaliseSlashes(`${basePath}/${localizedPathname}`);
|
|
441
|
+
return `${nextPathname}${url.search}`;
|
|
442
|
+
}
|
|
443
|
+
function createLocaleRedirectResponse(location, entry) {
|
|
444
|
+
const headers = new Headers({
|
|
445
|
+
'cache-control': 'private, no-store',
|
|
446
|
+
location
|
|
447
|
+
});
|
|
448
|
+
const vary = [];
|
|
449
|
+
const detectionOrder = entry.i18nextDetector ? entry.detection?.order || [] : [];
|
|
450
|
+
if (detectionOrder.includes('header')) vary.push('Accept-Language');
|
|
451
|
+
if (detectionOrder.includes('cookie')) vary.push('Cookie');
|
|
452
|
+
if (vary.length > 0) headers.set('vary', vary.join(', '));
|
|
453
|
+
return new Response(null, {
|
|
454
|
+
headers,
|
|
455
|
+
status: 302
|
|
456
|
+
});
|
|
457
|
+
}
|
|
458
|
+
function createLocaleRedirectResponseForRequest(route, request) {
|
|
459
|
+
const entry = getRouteI18nEntry(route);
|
|
460
|
+
if (!entry || !Array.isArray(entry.languages) || 0 === entry.languages.length) return null;
|
|
461
|
+
const { pathname, search } = new URL(request.url);
|
|
462
|
+
if (isAssetLikePathname(pathname)) return null;
|
|
463
|
+
if (shouldIgnoreLocaleRedirect(pathname, route, entry)) return null;
|
|
464
|
+
const language = getLanguageFromPath(route, pathname, entry.languages);
|
|
465
|
+
if (!language) {
|
|
466
|
+
const targetLanguage = detectRedirectLanguage(request, entry) || entry.fallbackLanguage;
|
|
467
|
+
return createLocaleRedirectResponse(buildLocalizedUrl(route, request, targetLanguage, entry), entry);
|
|
468
|
+
}
|
|
469
|
+
if (entry.localisedUrls) {
|
|
470
|
+
const expectedUrl = buildLocalizedUrl(route, request, language, entry);
|
|
471
|
+
if (expectedUrl !== `${pathname}${search}`) return createLocaleRedirectResponse(expectedUrl, entry);
|
|
472
|
+
}
|
|
473
|
+
return null;
|
|
474
|
+
}
|
|
226
475
|
function findRoute(request) {
|
|
227
476
|
const { pathname } = new URL(request.url);
|
|
228
477
|
const routes = MODERN_WORKER_MANIFEST.routeSpec.routes;
|
|
@@ -565,6 +814,8 @@ export default {
|
|
|
565
814
|
status: 404
|
|
566
815
|
}), request), request);
|
|
567
816
|
}
|
|
817
|
+
const localeRedirectResponse = createLocaleRedirectResponseForRequest(route, request);
|
|
818
|
+
if (localeRedirectResponse) return finalizeResponseForRequest(withAppCorsHeaders(localeRedirectResponse, request), request);
|
|
568
819
|
if (route?.worker) {
|
|
569
820
|
const renderableRequest = createRenderableRequest(request);
|
|
570
821
|
return finalizeResponseForRequest(withAppCorsHeaders(await dispatchRouteWorker(route, renderableRequest, env, ctx), request), request);
|
|
@@ -110,6 +110,102 @@ const DEFAULT_CSP_DIRECTIVES = {
|
|
|
110
110
|
'blob:'
|
|
111
111
|
]
|
|
112
112
|
};
|
|
113
|
+
const I18N_SERVER_PLUGIN_NAME = 'plugin-i18n/server';
|
|
114
|
+
const DEFAULT_I18NEXT_DETECTION_OPTIONS = {
|
|
115
|
+
order: [
|
|
116
|
+
'querystring',
|
|
117
|
+
'cookie',
|
|
118
|
+
'localStorage',
|
|
119
|
+
'header',
|
|
120
|
+
'navigator',
|
|
121
|
+
'htmlTag',
|
|
122
|
+
'path',
|
|
123
|
+
'subdomain'
|
|
124
|
+
],
|
|
125
|
+
lookupQuerystring: 'lng',
|
|
126
|
+
lookupCookie: 'i18next',
|
|
127
|
+
lookupHeader: 'accept-language'
|
|
128
|
+
};
|
|
129
|
+
const SERVER_SIDE_I18N_DETECTORS = new Set([
|
|
130
|
+
'querystring',
|
|
131
|
+
'cookie',
|
|
132
|
+
'header'
|
|
133
|
+
]);
|
|
134
|
+
const isRecord = (value)=>null !== value && 'object' == typeof value && !Array.isArray(value);
|
|
135
|
+
const toStringArray = (value)=>Array.isArray(value) ? value.filter((entry)=>'string' == typeof entry) : [];
|
|
136
|
+
const getI18nServerPlugin = (appContext)=>appContext.serverPlugins?.find((plugin)=>plugin.name.includes(I18N_SERVER_PLUGIN_NAME));
|
|
137
|
+
const removeLocaleDetectionByEntry = (config)=>{
|
|
138
|
+
const { localeDetectionByEntry: _localeDetectionByEntry, ...rest } = config;
|
|
139
|
+
return rest;
|
|
140
|
+
};
|
|
141
|
+
const getEntryLocaleDetection = (entryName, localeDetection)=>{
|
|
142
|
+
const byEntry = isRecord(localeDetection.localeDetectionByEntry) ? localeDetection.localeDetectionByEntry : void 0;
|
|
143
|
+
const entryConfig = byEntry?.[entryName];
|
|
144
|
+
const globalConfig = removeLocaleDetectionByEntry(localeDetection);
|
|
145
|
+
return isRecord(entryConfig) ? {
|
|
146
|
+
...globalConfig,
|
|
147
|
+
...entryConfig
|
|
148
|
+
} : globalConfig;
|
|
149
|
+
};
|
|
150
|
+
const normalizeI18nDetection = (detection)=>{
|
|
151
|
+
const configured = isRecord(detection) ? detection : {};
|
|
152
|
+
const rawOrder = Array.isArray(configured.order) ? configured.order : DEFAULT_I18NEXT_DETECTION_OPTIONS.order;
|
|
153
|
+
const order = rawOrder.filter((entry)=>'string' == typeof entry && SERVER_SIDE_I18N_DETECTORS.has(entry));
|
|
154
|
+
return {
|
|
155
|
+
order: order.length > 0 ? order : [
|
|
156
|
+
'querystring',
|
|
157
|
+
'cookie',
|
|
158
|
+
'header'
|
|
159
|
+
],
|
|
160
|
+
lookupQuerystring: 'string' == typeof configured.lookupQuerystring && configured.lookupQuerystring ? configured.lookupQuerystring : DEFAULT_I18NEXT_DETECTION_OPTIONS.lookupQuerystring,
|
|
161
|
+
lookupCookie: 'string' == typeof configured.lookupCookie && configured.lookupCookie ? configured.lookupCookie : DEFAULT_I18NEXT_DETECTION_OPTIONS.lookupCookie,
|
|
162
|
+
lookupHeader: 'string' == typeof configured.lookupHeader && configured.lookupHeader ? configured.lookupHeader : DEFAULT_I18NEXT_DETECTION_OPTIONS.lookupHeader
|
|
163
|
+
};
|
|
164
|
+
};
|
|
165
|
+
const normalizeLocalisedUrls = (localisedUrls)=>{
|
|
166
|
+
if (!isRecord(localisedUrls) || 0 === Object.keys(localisedUrls).length) return;
|
|
167
|
+
const normalized = {};
|
|
168
|
+
for (const [canonicalPath, languageMap] of Object.entries(localisedUrls)){
|
|
169
|
+
if (!isRecord(languageMap)) continue;
|
|
170
|
+
const normalizedLanguageMap = Object.fromEntries(Object.entries(languageMap).filter((entry)=>'string' == typeof entry[1] && entry[1].length > 0));
|
|
171
|
+
if (Object.keys(normalizedLanguageMap).length > 0) normalized[canonicalPath] = normalizedLanguageMap;
|
|
172
|
+
}
|
|
173
|
+
return Object.keys(normalized).length > 0 ? normalized : void 0;
|
|
174
|
+
};
|
|
175
|
+
const normalizeWorkerI18nEntry = (entryName, localeDetection, staticRoutePrefixes)=>{
|
|
176
|
+
const entryConfig = getEntryLocaleDetection(entryName, localeDetection);
|
|
177
|
+
const languages = toStringArray(entryConfig.languages);
|
|
178
|
+
if (true !== entryConfig.localePathRedirect || 0 === languages.length) return;
|
|
179
|
+
const fallbackLanguage = 'string' == typeof entryConfig.fallbackLanguage && entryConfig.fallbackLanguage ? entryConfig.fallbackLanguage : 'en';
|
|
180
|
+
const ignoreRedirectRoutes = Array.isArray(entryConfig.ignoreRedirectRoutes) ? toStringArray(entryConfig.ignoreRedirectRoutes) : [];
|
|
181
|
+
const localisedUrls = normalizeLocalisedUrls(entryConfig.localisedUrls);
|
|
182
|
+
return {
|
|
183
|
+
i18nextDetector: false !== entryConfig.i18nextDetector,
|
|
184
|
+
languages,
|
|
185
|
+
fallbackLanguage,
|
|
186
|
+
detection: normalizeI18nDetection(entryConfig.detection),
|
|
187
|
+
ignoreRedirectRoutes,
|
|
188
|
+
staticRoutePrefixes,
|
|
189
|
+
...localisedUrls ? {
|
|
190
|
+
localisedUrls
|
|
191
|
+
} : {}
|
|
192
|
+
};
|
|
193
|
+
};
|
|
194
|
+
const createI18nWorkerManifest = (routeSpec, appContext)=>{
|
|
195
|
+
const i18nServerPlugin = getI18nServerPlugin(appContext);
|
|
196
|
+
const localeDetection = i18nServerPlugin?.options?.localeDetection;
|
|
197
|
+
if (!isRecord(localeDetection)) return;
|
|
198
|
+
const staticRoutePrefixes = toStringArray(i18nServerPlugin?.options?.staticRoutePrefixes);
|
|
199
|
+
const entries = {};
|
|
200
|
+
for (const route of routeSpec.routes){
|
|
201
|
+
if ('string' != typeof route.entryName || entries[route.entryName]) continue;
|
|
202
|
+
const entry = normalizeWorkerI18nEntry(route.entryName, localeDetection, staticRoutePrefixes);
|
|
203
|
+
if (entry) entries[route.entryName] = entry;
|
|
204
|
+
}
|
|
205
|
+
return Object.keys(entries).length > 0 ? {
|
|
206
|
+
entries
|
|
207
|
+
} : void 0;
|
|
208
|
+
};
|
|
113
209
|
const getCompatibilityDate = (modernConfig)=>{
|
|
114
210
|
const configuredDate = modernConfig.deploy?.worker?.compatibilityDate?.trim();
|
|
115
211
|
const compatibilityDate = configuredDate || DEFAULT_COMPATIBILITY_DATE;
|
|
@@ -340,7 +436,7 @@ const readRouteSpec = async (outputDirectory)=>{
|
|
|
340
436
|
};
|
|
341
437
|
};
|
|
342
438
|
const createMissingEffectBffWorkerError = (outputDirectory, worker)=>new Error(`Cloudflare Effect API runtime is configured, but the BFF worker bundle is missing: ${node_path.join(outputDirectory, worker)}. ${EFFECT_BFF_CLOUDFLARE_IMPORT_GUIDANCE}`);
|
|
343
|
-
const createWorkerManifest = async (outputDirectory, modernConfig)=>{
|
|
439
|
+
const createWorkerManifest = async (outputDirectory, modernConfig, appContext)=>{
|
|
344
440
|
const routeSpec = await readRouteSpec(outputDirectory);
|
|
345
441
|
const routes = await Promise.all(routeSpec.routes.map(async (route)=>{
|
|
346
442
|
const worker = 'string' == typeof route.worker ? route.worker : void 0;
|
|
@@ -386,6 +482,7 @@ const createWorkerManifest = async (outputDirectory, modernConfig)=>{
|
|
|
386
482
|
routeManifest: ROUTE_MANIFEST_FILE
|
|
387
483
|
},
|
|
388
484
|
security: createCloudflareWorkerSecurityPolicy(modernConfig),
|
|
485
|
+
i18n: createI18nWorkerManifest(routeSpec, appContext),
|
|
389
486
|
bff: isEffectApi && primaryBffPrefix && effectApiWorkerExists ? {
|
|
390
487
|
runtimeFramework: 'effect',
|
|
391
488
|
prefix: primaryBffPrefix,
|
|
@@ -476,7 +573,7 @@ const createCloudflarePreset = ({ appContext, modernConfig })=>{
|
|
|
476
573
|
await fs.writeJSON(wranglerConfigPath, createWranglerConfig(appDirectory, modernConfig), {
|
|
477
574
|
spaces: 2
|
|
478
575
|
});
|
|
479
|
-
await fs.writeJSON(workerManifestPath, await createWorkerManifest(outputDirectory, modernConfig), {
|
|
576
|
+
await fs.writeJSON(workerManifestPath, await createWorkerManifest(outputDirectory, modernConfig, appContext), {
|
|
480
577
|
spaces: 2
|
|
481
578
|
});
|
|
482
579
|
await fs.writeJSON(node_path.join(outputDirectory, OUTPUT_PACKAGE_FILE), {
|
|
@@ -223,6 +223,255 @@ function routeMatches(route, pathname) {
|
|
|
223
223
|
const requestPath = normalizeRoutePath(pathname);
|
|
224
224
|
return routePath === requestPath || '/' === routePath && route.isSSR || requestPath.startsWith(`${routePath}/`);
|
|
225
225
|
}
|
|
226
|
+
function getRouteI18nEntry(route) {
|
|
227
|
+
if (!route || 'string' != typeof route.entryName) return;
|
|
228
|
+
return MODERN_WORKER_MANIFEST.i18n?.entries?.[route.entryName];
|
|
229
|
+
}
|
|
230
|
+
function getRouteBasePath(route) {
|
|
231
|
+
const routePath = 'string' == typeof route?.urlPath ? normalizeRoutePath(route.urlPath) : '/';
|
|
232
|
+
return routePath || '/';
|
|
233
|
+
}
|
|
234
|
+
function getRouteRemainingPathname(route, pathname) {
|
|
235
|
+
const basePath = getRouteBasePath(route);
|
|
236
|
+
if ('/' === basePath) return pathname || '/';
|
|
237
|
+
if (!matchesPrefix(pathname, basePath)) return pathname || '/';
|
|
238
|
+
const remaining = pathname.slice(basePath.length);
|
|
239
|
+
return remaining ? remaining.startsWith('/') ? remaining : `/${remaining}` : '/';
|
|
240
|
+
}
|
|
241
|
+
function normalizeLanguage(value, languages) {
|
|
242
|
+
if ('string' != typeof value || '' === value.trim()) return null;
|
|
243
|
+
const normalized = value.trim().toLowerCase();
|
|
244
|
+
const exact = languages.find((language)=>language.toLowerCase() === normalized);
|
|
245
|
+
if (exact) return exact;
|
|
246
|
+
const baseLanguage = normalized.split(/[-_]/u)[0];
|
|
247
|
+
return languages.find((language)=>language.toLowerCase() === baseLanguage) || null;
|
|
248
|
+
}
|
|
249
|
+
function getLanguageFromPath(route, pathname, languages) {
|
|
250
|
+
const remainingPathname = getRouteRemainingPathname(route, pathname);
|
|
251
|
+
const firstSegment = remainingPathname.split('/').filter(Boolean)[0];
|
|
252
|
+
return normalizeLanguage(firstSegment, languages);
|
|
253
|
+
}
|
|
254
|
+
function safeDecodeURIComponent(value) {
|
|
255
|
+
try {
|
|
256
|
+
return decodeURIComponent(value);
|
|
257
|
+
} catch {
|
|
258
|
+
return value;
|
|
259
|
+
}
|
|
260
|
+
}
|
|
261
|
+
function detectLanguageFromCookie(cookieHeader, lookupCookie, languages) {
|
|
262
|
+
if (!cookieHeader) return null;
|
|
263
|
+
for (const cookiePart of cookieHeader.split(';')){
|
|
264
|
+
const [rawName, ...rawValue] = cookiePart.trim().split('=');
|
|
265
|
+
if (rawName === lookupCookie && 0 !== rawValue.length) return normalizeLanguage(safeDecodeURIComponent(rawValue.join('=')), languages);
|
|
266
|
+
}
|
|
267
|
+
return null;
|
|
268
|
+
}
|
|
269
|
+
function detectLanguageFromAcceptLanguage(acceptLanguage, languages) {
|
|
270
|
+
if (!acceptLanguage) return null;
|
|
271
|
+
return acceptLanguage.split(',').map((entry, index)=>{
|
|
272
|
+
const [rawRange, ...rawParameters] = entry.trim().split(';');
|
|
273
|
+
const qualityParameter = rawParameters.find((parameter)=>parameter.trim().startsWith('q='));
|
|
274
|
+
const quality = qualityParameter ? Number.parseFloat(qualityParameter.split('=')[1] || '') : 1;
|
|
275
|
+
return {
|
|
276
|
+
index,
|
|
277
|
+
language: normalizeLanguage(rawRange, languages),
|
|
278
|
+
quality: Number.isFinite(quality) ? quality : 1
|
|
279
|
+
};
|
|
280
|
+
}).filter((candidate)=>candidate.language && candidate.quality > 0).sort((left, right)=>right.quality - left.quality || left.index - right.index)[0]?.language || null;
|
|
281
|
+
}
|
|
282
|
+
function detectRedirectLanguage(request, entry) {
|
|
283
|
+
if (!entry.i18nextDetector) return null;
|
|
284
|
+
const url = new URL(request.url);
|
|
285
|
+
const detection = entry.detection || {};
|
|
286
|
+
const order = Array.isArray(detection.order) ? detection.order : [
|
|
287
|
+
'querystring',
|
|
288
|
+
'cookie',
|
|
289
|
+
'header'
|
|
290
|
+
];
|
|
291
|
+
for (const method of order){
|
|
292
|
+
const language = 'querystring' === method ? normalizeLanguage(url.searchParams.get(detection.lookupQuerystring || 'lng'), entry.languages) : 'cookie' === method ? detectLanguageFromCookie(request.headers.get('cookie'), detection.lookupCookie || 'i18next', entry.languages) : 'header' === method ? detectLanguageFromAcceptLanguage(request.headers.get(detection.lookupHeader || 'accept-language'), entry.languages) : null;
|
|
293
|
+
if (language) return language;
|
|
294
|
+
}
|
|
295
|
+
return null;
|
|
296
|
+
}
|
|
297
|
+
function normaliseSlashes(pathname) {
|
|
298
|
+
const withoutDuplicateSlashes = pathname.replace(/\/+/g, '/');
|
|
299
|
+
const withLeadingSlash = withoutDuplicateSlashes.startsWith('/') ? withoutDuplicateSlashes : `/${withoutDuplicateSlashes}`;
|
|
300
|
+
return withLeadingSlash.length > 1 ? withLeadingSlash.replace(/\/+$/u, '') : withLeadingSlash;
|
|
301
|
+
}
|
|
302
|
+
function normalisePathPattern(pathname) {
|
|
303
|
+
return normaliseSlashes(pathname).replace(/\[(.+?)\]/g, ':$1');
|
|
304
|
+
}
|
|
305
|
+
function stripLanguagePrefix(pathname, languages) {
|
|
306
|
+
const segments = pathname.split('/').filter(Boolean);
|
|
307
|
+
const firstSegment = segments[0];
|
|
308
|
+
if (firstSegment && normalizeLanguage(firstSegment, languages)) return `/${segments.slice(1).join('/')}` || '/';
|
|
309
|
+
return pathname || '/';
|
|
310
|
+
}
|
|
311
|
+
function escapeRegExp(value) {
|
|
312
|
+
return value.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
|
|
313
|
+
}
|
|
314
|
+
function getPatternParamName(segment) {
|
|
315
|
+
return segment.slice(1).replace(/\?$/, '');
|
|
316
|
+
}
|
|
317
|
+
function compilePathPattern(pattern) {
|
|
318
|
+
const names = [];
|
|
319
|
+
const segments = normalisePathPattern(pattern).split('/').filter(Boolean);
|
|
320
|
+
const source = segments.map((segment)=>{
|
|
321
|
+
if (segment.startsWith(':')) {
|
|
322
|
+
names.push(getPatternParamName(segment));
|
|
323
|
+
return segment.endsWith('?') ? '(?:/([^/]+))?' : '/([^/]+)';
|
|
324
|
+
}
|
|
325
|
+
if ('*' === segment) {
|
|
326
|
+
names.push('*');
|
|
327
|
+
return '/(.*)';
|
|
328
|
+
}
|
|
329
|
+
return `/${escapeRegExp(segment)}`;
|
|
330
|
+
}).join('');
|
|
331
|
+
return {
|
|
332
|
+
names,
|
|
333
|
+
regexp: new RegExp(`^${source || '/'}$`)
|
|
334
|
+
};
|
|
335
|
+
}
|
|
336
|
+
function matchPathPattern(pathname, pattern) {
|
|
337
|
+
const { names, regexp } = compilePathPattern(pattern);
|
|
338
|
+
const match = regexp.exec(normaliseSlashes(pathname));
|
|
339
|
+
if (!match) return null;
|
|
340
|
+
const params = {};
|
|
341
|
+
for(let index = 0; index < names.length; index++)try {
|
|
342
|
+
params[names[index]] = decodeURIComponent(match[index + 1] || '');
|
|
343
|
+
} catch {
|
|
344
|
+
return null;
|
|
345
|
+
}
|
|
346
|
+
return params;
|
|
347
|
+
}
|
|
348
|
+
function buildPathFromPattern(pattern, params) {
|
|
349
|
+
const path = normalisePathPattern(pattern).split('/').filter(Boolean).map((segment)=>{
|
|
350
|
+
if (segment.startsWith(':')) {
|
|
351
|
+
const param = params[getPatternParamName(segment)];
|
|
352
|
+
return param ? encodeURIComponent(param) : '';
|
|
353
|
+
}
|
|
354
|
+
if ('*' === segment) return params['*'] || '';
|
|
355
|
+
return segment;
|
|
356
|
+
}).filter(Boolean).join('/');
|
|
357
|
+
return `/${path}`;
|
|
358
|
+
}
|
|
359
|
+
function patternSpecificity(pattern) {
|
|
360
|
+
const segments = normalisePathPattern(pattern).split('/').filter(Boolean);
|
|
361
|
+
let staticSegments = 0;
|
|
362
|
+
let dynamicSegments = 0;
|
|
363
|
+
let splatSegments = 0;
|
|
364
|
+
for (const segment of segments)if ('*' === segment) splatSegments++;
|
|
365
|
+
else if (segment.startsWith(':')) dynamicSegments++;
|
|
366
|
+
else staticSegments++;
|
|
367
|
+
return {
|
|
368
|
+
dynamicSegments,
|
|
369
|
+
splatSegments,
|
|
370
|
+
staticSegments,
|
|
371
|
+
totalSegments: segments.length
|
|
372
|
+
};
|
|
373
|
+
}
|
|
374
|
+
function comparePatternSpecificity(left, right) {
|
|
375
|
+
const leftSpecificity = patternSpecificity(left);
|
|
376
|
+
const rightSpecificity = patternSpecificity(right);
|
|
377
|
+
return rightSpecificity.staticSegments - leftSpecificity.staticSegments || rightSpecificity.totalSegments - leftSpecificity.totalSegments || leftSpecificity.splatSegments - rightSpecificity.splatSegments || leftSpecificity.dynamicSegments - rightSpecificity.dynamicSegments;
|
|
378
|
+
}
|
|
379
|
+
function sortLocalisedPatterns(patterns) {
|
|
380
|
+
return patterns.map((pattern, index)=>({
|
|
381
|
+
pattern,
|
|
382
|
+
index
|
|
383
|
+
})).sort((left, right)=>comparePatternSpecificity(left.pattern.pattern, right.pattern.pattern) || left.index - right.index).map(({ pattern })=>pattern);
|
|
384
|
+
}
|
|
385
|
+
function resolveLocalisedPath(pathname, targetLanguage, languages, localisedUrls) {
|
|
386
|
+
const normalizedPathname = normaliseSlashes(pathname);
|
|
387
|
+
const canonicalCandidates = sortLocalisedPatterns(Object.entries(localisedUrls || {}).map(([canonicalPattern, localisedUrlEntry])=>({
|
|
388
|
+
canonicalPattern,
|
|
389
|
+
localisedUrlEntry,
|
|
390
|
+
pattern: canonicalPattern
|
|
391
|
+
})));
|
|
392
|
+
for (const { canonicalPattern, localisedUrlEntry } of canonicalCandidates){
|
|
393
|
+
const targetPattern = localisedUrlEntry[targetLanguage];
|
|
394
|
+
const params = targetPattern ? matchPathPattern(normalizedPathname, canonicalPattern) : null;
|
|
395
|
+
if (params) return buildPathFromPattern(targetPattern, params);
|
|
396
|
+
}
|
|
397
|
+
const localisedCandidates = sortLocalisedPatterns(Object.values(localisedUrls || {}).flatMap((localisedUrlEntry)=>{
|
|
398
|
+
const targetPattern = localisedUrlEntry[targetLanguage];
|
|
399
|
+
if (!targetPattern) return [];
|
|
400
|
+
return languages.map((language)=>localisedUrlEntry[language]).filter(Boolean).map((sourcePattern)=>({
|
|
401
|
+
pattern: sourcePattern,
|
|
402
|
+
sourcePattern,
|
|
403
|
+
targetPattern
|
|
404
|
+
}));
|
|
405
|
+
}));
|
|
406
|
+
for (const { sourcePattern, targetPattern } of localisedCandidates){
|
|
407
|
+
const params = matchPathPattern(normalizedPathname, sourcePattern);
|
|
408
|
+
if (params) return buildPathFromPattern(targetPattern, params);
|
|
409
|
+
}
|
|
410
|
+
return normalizedPathname;
|
|
411
|
+
}
|
|
412
|
+
function localiseTargetPathname(pathname, language, languages, localisedUrls) {
|
|
413
|
+
const pathWithoutLanguage = stripLanguagePrefix(pathname, languages);
|
|
414
|
+
const resolvedPath = localisedUrls ? resolveLocalisedPath(pathWithoutLanguage, language, languages, localisedUrls) : pathWithoutLanguage;
|
|
415
|
+
const resolvedSegments = resolvedPath.split('/').filter(Boolean);
|
|
416
|
+
return `/${[
|
|
417
|
+
language,
|
|
418
|
+
...resolvedSegments
|
|
419
|
+
].join('/')}`;
|
|
420
|
+
}
|
|
421
|
+
function shouldIgnoreLocaleRedirect(pathname, route, entry) {
|
|
422
|
+
const remainingPathname = getRouteRemainingPathname(route, pathname);
|
|
423
|
+
const normalizedPathname = remainingPathname.startsWith('/') ? remainingPathname : `/${remainingPathname}`;
|
|
424
|
+
const ignoredPrefixes = [
|
|
425
|
+
...entry.ignoreRedirectRoutes || [],
|
|
426
|
+
...entry.staticRoutePrefixes || [],
|
|
427
|
+
'/static',
|
|
428
|
+
'/upload'
|
|
429
|
+
];
|
|
430
|
+
return ignoredPrefixes.some((prefix)=>{
|
|
431
|
+
const normalizedPrefix = normaliseSlashes(prefix);
|
|
432
|
+
return normalizedPathname === normalizedPrefix || normalizedPathname.startsWith(`${normalizedPrefix}/`);
|
|
433
|
+
});
|
|
434
|
+
}
|
|
435
|
+
function buildLocalizedUrl(route, request, language, entry) {
|
|
436
|
+
const url = new URL(request.url);
|
|
437
|
+
const basePath = getRouteBasePath(route);
|
|
438
|
+
const remainingPathname = getRouteRemainingPathname(route, url.pathname);
|
|
439
|
+
const localizedPathname = localiseTargetPathname(remainingPathname, language, entry.languages, entry.localisedUrls);
|
|
440
|
+
const nextPathname = '/' === basePath ? localizedPathname : normaliseSlashes(`${basePath}/${localizedPathname}`);
|
|
441
|
+
return `${nextPathname}${url.search}`;
|
|
442
|
+
}
|
|
443
|
+
function createLocaleRedirectResponse(location, entry) {
|
|
444
|
+
const headers = new Headers({
|
|
445
|
+
'cache-control': 'private, no-store',
|
|
446
|
+
location
|
|
447
|
+
});
|
|
448
|
+
const vary = [];
|
|
449
|
+
const detectionOrder = entry.i18nextDetector ? entry.detection?.order || [] : [];
|
|
450
|
+
if (detectionOrder.includes('header')) vary.push('Accept-Language');
|
|
451
|
+
if (detectionOrder.includes('cookie')) vary.push('Cookie');
|
|
452
|
+
if (vary.length > 0) headers.set('vary', vary.join(', '));
|
|
453
|
+
return new Response(null, {
|
|
454
|
+
headers,
|
|
455
|
+
status: 302
|
|
456
|
+
});
|
|
457
|
+
}
|
|
458
|
+
function createLocaleRedirectResponseForRequest(route, request) {
|
|
459
|
+
const entry = getRouteI18nEntry(route);
|
|
460
|
+
if (!entry || !Array.isArray(entry.languages) || 0 === entry.languages.length) return null;
|
|
461
|
+
const { pathname, search } = new URL(request.url);
|
|
462
|
+
if (isAssetLikePathname(pathname)) return null;
|
|
463
|
+
if (shouldIgnoreLocaleRedirect(pathname, route, entry)) return null;
|
|
464
|
+
const language = getLanguageFromPath(route, pathname, entry.languages);
|
|
465
|
+
if (!language) {
|
|
466
|
+
const targetLanguage = detectRedirectLanguage(request, entry) || entry.fallbackLanguage;
|
|
467
|
+
return createLocaleRedirectResponse(buildLocalizedUrl(route, request, targetLanguage, entry), entry);
|
|
468
|
+
}
|
|
469
|
+
if (entry.localisedUrls) {
|
|
470
|
+
const expectedUrl = buildLocalizedUrl(route, request, language, entry);
|
|
471
|
+
if (expectedUrl !== `${pathname}${search}`) return createLocaleRedirectResponse(expectedUrl, entry);
|
|
472
|
+
}
|
|
473
|
+
return null;
|
|
474
|
+
}
|
|
226
475
|
function findRoute(request) {
|
|
227
476
|
const { pathname } = new URL(request.url);
|
|
228
477
|
const routes = MODERN_WORKER_MANIFEST.routeSpec.routes;
|
|
@@ -565,6 +814,8 @@ export default {
|
|
|
565
814
|
status: 404
|
|
566
815
|
}), request), request);
|
|
567
816
|
}
|
|
817
|
+
const localeRedirectResponse = createLocaleRedirectResponseForRequest(route, request);
|
|
818
|
+
if (localeRedirectResponse) return finalizeResponseForRequest(withAppCorsHeaders(localeRedirectResponse, request), request);
|
|
568
819
|
if (route?.worker) {
|
|
569
820
|
const renderableRequest = createRenderableRequest(request);
|
|
570
821
|
return finalizeResponseForRequest(withAppCorsHeaders(await dispatchRouteWorker(route, renderableRequest, env, ctx), request), request);
|
package/package.json
CHANGED
|
@@ -17,7 +17,7 @@
|
|
|
17
17
|
"modern",
|
|
18
18
|
"modern.js"
|
|
19
19
|
],
|
|
20
|
-
"version": "3.5.0-ultramodern.
|
|
20
|
+
"version": "3.5.0-ultramodern.18",
|
|
21
21
|
"types": "./dist/types/index.d.ts",
|
|
22
22
|
"main": "./dist/cjs/index.js",
|
|
23
23
|
"exports": {
|
|
@@ -90,16 +90,16 @@
|
|
|
90
90
|
"ndepe": "^0.1.13",
|
|
91
91
|
"pkg-types": "^2.3.1",
|
|
92
92
|
"std-env": "4.1.0",
|
|
93
|
-
"@modern-js/builder": "npm:@bleedingdev/modern-js-builder@3.5.0-ultramodern.
|
|
94
|
-
"@modern-js/i18n-utils": "npm:@bleedingdev/modern-js-i18n-utils@3.5.0-ultramodern.
|
|
95
|
-
"@modern-js/plugin": "npm:@bleedingdev/modern-js-plugin@3.5.0-ultramodern.
|
|
96
|
-
"@modern-js/
|
|
97
|
-
"@modern-js/
|
|
98
|
-
"@modern-js/server-core": "npm:@bleedingdev/modern-js-server-core@3.5.0-ultramodern.
|
|
99
|
-
"@modern-js/server": "npm:@bleedingdev/modern-js-server@3.5.0-ultramodern.
|
|
100
|
-
"@modern-js/
|
|
101
|
-
"@modern-js/
|
|
102
|
-
"@modern-js/
|
|
93
|
+
"@modern-js/builder": "npm:@bleedingdev/modern-js-builder@3.5.0-ultramodern.18",
|
|
94
|
+
"@modern-js/i18n-utils": "npm:@bleedingdev/modern-js-i18n-utils@3.5.0-ultramodern.18",
|
|
95
|
+
"@modern-js/plugin-data-loader": "npm:@bleedingdev/modern-js-plugin-data-loader@3.5.0-ultramodern.18",
|
|
96
|
+
"@modern-js/prod-server": "npm:@bleedingdev/modern-js-prod-server@3.5.0-ultramodern.18",
|
|
97
|
+
"@modern-js/plugin": "npm:@bleedingdev/modern-js-plugin@3.5.0-ultramodern.18",
|
|
98
|
+
"@modern-js/server-core": "npm:@bleedingdev/modern-js-server-core@3.5.0-ultramodern.18",
|
|
99
|
+
"@modern-js/server-utils": "npm:@bleedingdev/modern-js-server-utils@3.5.0-ultramodern.18",
|
|
100
|
+
"@modern-js/server": "npm:@bleedingdev/modern-js-server@3.5.0-ultramodern.18",
|
|
101
|
+
"@modern-js/types": "npm:@bleedingdev/modern-js-types@3.5.0-ultramodern.18",
|
|
102
|
+
"@modern-js/utils": "npm:@bleedingdev/modern-js-utils@3.5.0-ultramodern.18"
|
|
103
103
|
},
|
|
104
104
|
"devDependencies": {
|
|
105
105
|
"@rslib/core": "0.23.1",
|