@bleedingdev/modern-js-i18n-runtime-extensions 3.8.3-ultramodern.2
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/LICENSE +21 -0
- package/dist/cjs/index.js +76 -0
- package/dist/cjs/language-sync/controller.js +241 -0
- package/dist/cjs/language-sync/react.js +169 -0
- package/dist/cjs/localisedUrls/config.js +49 -0
- package/dist/cjs/localisedUrls/index.js +91 -0
- package/dist/cjs/localisedUrls/normalise.js +87 -0
- package/dist/cjs/localisedUrls/pathname.js +68 -0
- package/dist/cjs/localisedUrls/patterns.js +214 -0
- package/dist/cjs/localisedUrls/redirect.js +103 -0
- package/dist/cjs/localisedUrls/resolve.js +78 -0
- package/dist/cjs/localisedUrls/routes.js +202 -0
- package/dist/cjs/localisedUrls/types.js +18 -0
- package/dist/esm/index.mjs +3 -0
- package/dist/esm/language-sync/controller.mjs +203 -0
- package/dist/esm/language-sync/react.mjs +118 -0
- package/dist/esm/localisedUrls/config.mjs +11 -0
- package/dist/esm/localisedUrls/index.mjs +7 -0
- package/dist/esm/localisedUrls/normalise.mjs +37 -0
- package/dist/esm/localisedUrls/pathname.mjs +24 -0
- package/dist/esm/localisedUrls/patterns.mjs +152 -0
- package/dist/esm/localisedUrls/redirect.mjs +56 -0
- package/dist/esm/localisedUrls/resolve.mjs +37 -0
- package/dist/esm/localisedUrls/routes.mjs +161 -0
- package/dist/esm/localisedUrls/types.mjs +0 -0
- package/dist/esm-node/index.mjs +4 -0
- package/dist/esm-node/language-sync/controller.mjs +204 -0
- package/dist/esm-node/language-sync/react.mjs +119 -0
- package/dist/esm-node/localisedUrls/config.mjs +12 -0
- package/dist/esm-node/localisedUrls/index.mjs +8 -0
- package/dist/esm-node/localisedUrls/normalise.mjs +38 -0
- package/dist/esm-node/localisedUrls/pathname.mjs +25 -0
- package/dist/esm-node/localisedUrls/patterns.mjs +153 -0
- package/dist/esm-node/localisedUrls/redirect.mjs +57 -0
- package/dist/esm-node/localisedUrls/resolve.mjs +38 -0
- package/dist/esm-node/localisedUrls/routes.mjs +162 -0
- package/dist/esm-node/localisedUrls/types.mjs +1 -0
- package/dist/types/index.d.ts +3 -0
- package/dist/types/language-sync/controller.d.ts +26 -0
- package/dist/types/language-sync/react.d.ts +38 -0
- package/dist/types/localisedUrls/config.d.ts +9 -0
- package/dist/types/localisedUrls/index.d.ts +9 -0
- package/dist/types/localisedUrls/normalise.d.ts +10 -0
- package/dist/types/localisedUrls/pathname.d.ts +4 -0
- package/dist/types/localisedUrls/patterns.d.ts +11 -0
- package/dist/types/localisedUrls/redirect.d.ts +11 -0
- package/dist/types/localisedUrls/resolve.d.ts +7 -0
- package/dist/types/localisedUrls/routes.d.ts +3 -0
- package/dist/types/localisedUrls/types.d.ts +14 -0
- package/package.json +71 -0
- package/rslib.config.mts +4 -0
- package/rstest.config.mts +36 -0
- package/src/index.ts +3 -0
- package/src/language-sync/controller.ts +386 -0
- package/src/language-sync/react.ts +227 -0
- package/src/localisedUrls/config.ts +18 -0
- package/src/localisedUrls/index.ts +24 -0
- package/src/localisedUrls/normalise.ts +62 -0
- package/src/localisedUrls/pathname.ts +60 -0
- package/src/localisedUrls/patterns.ts +283 -0
- package/src/localisedUrls/redirect.ts +93 -0
- package/src/localisedUrls/resolve.ts +106 -0
- package/src/localisedUrls/routes.ts +422 -0
- package/src/localisedUrls/types.ts +18 -0
- package/tests/clientRedirectIntegration.test.tsx +74 -0
- package/tests/languageSyncController.test.ts +213 -0
- package/tests/localisedUrls.test.ts +673 -0
- package/tests/reactLanguageSync.test.tsx +117 -0
- package/tests/tsconfig.json +11 -0
- package/tsconfig.json +11 -0
|
@@ -0,0 +1,202 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
var __webpack_require__ = {};
|
|
3
|
+
(()=>{
|
|
4
|
+
__webpack_require__.d = (exports1, getters, values)=>{
|
|
5
|
+
var define = (defs, kind)=>{
|
|
6
|
+
for(var key in defs)if (__webpack_require__.o(defs, key) && !__webpack_require__.o(exports1, key)) Object.defineProperty(exports1, key, {
|
|
7
|
+
enumerable: true,
|
|
8
|
+
[kind]: defs[key]
|
|
9
|
+
});
|
|
10
|
+
};
|
|
11
|
+
define(getters, "get");
|
|
12
|
+
define(values, "value");
|
|
13
|
+
};
|
|
14
|
+
})();
|
|
15
|
+
(()=>{
|
|
16
|
+
__webpack_require__.o = (obj, prop)=>Object.prototype.hasOwnProperty.call(obj, prop);
|
|
17
|
+
})();
|
|
18
|
+
(()=>{
|
|
19
|
+
__webpack_require__.r = (exports1)=>{
|
|
20
|
+
if ("u" > typeof Symbol && Symbol.toStringTag) Object.defineProperty(exports1, Symbol.toStringTag, {
|
|
21
|
+
value: 'Module'
|
|
22
|
+
});
|
|
23
|
+
Object.defineProperty(exports1, '__esModule', {
|
|
24
|
+
value: true
|
|
25
|
+
});
|
|
26
|
+
};
|
|
27
|
+
})();
|
|
28
|
+
var __webpack_exports__ = {};
|
|
29
|
+
__webpack_require__.r(__webpack_exports__);
|
|
30
|
+
__webpack_require__.d(__webpack_exports__, {
|
|
31
|
+
applyLocalisedUrlsToRoutes: ()=>applyLocalisedUrlsToRoutes,
|
|
32
|
+
validateLocalisedUrls: ()=>validateLocalisedUrls
|
|
33
|
+
});
|
|
34
|
+
const external_normalise_js_namespaceObject = require("./normalise.js");
|
|
35
|
+
const external_patterns_js_namespaceObject = require("./patterns.js");
|
|
36
|
+
const isLocalisableRoutePath = (path)=>{
|
|
37
|
+
const pathWithoutLocale = (0, external_normalise_js_namespaceObject.stripLeadingLocaleParam)(path);
|
|
38
|
+
if (!pathWithoutLocale || '/' === pathWithoutLocale || '*' === pathWithoutLocale) return false;
|
|
39
|
+
return true;
|
|
40
|
+
};
|
|
41
|
+
const joinPath = (parentPath, routePath)=>{
|
|
42
|
+
if (!isLocalisableRoutePath(routePath)) return parentPath;
|
|
43
|
+
const segment = (0, external_normalise_js_namespaceObject.normaliseRoutePath)((0, external_normalise_js_namespaceObject.stripLeadingLocaleParam)(routePath) || '');
|
|
44
|
+
return (0, external_normalise_js_namespaceObject.normalisePathPattern)(`${parentPath}/${segment}`);
|
|
45
|
+
};
|
|
46
|
+
const isFrameworkInternalRoutePath = (canonicalPath)=>'/_mf' === canonicalPath || canonicalPath.startsWith('/_mf/');
|
|
47
|
+
const legalRouteIdPart = (value)=>value.replace(/[^a-zA-Z0-9_$-]+/g, '_').replace(/^_+|_+$/g, '') || 'index';
|
|
48
|
+
const formatParamSignature = (signature)=>signature.length > 0 ? signature.join(', ') : '(none)';
|
|
49
|
+
const validateLocalisedUrlPatterns = (languages, localisedUrls)=>{
|
|
50
|
+
const physicalPatterns = new Map();
|
|
51
|
+
const claimedPatterns = [];
|
|
52
|
+
const claimPhysicalPattern = (sourcePattern, canonicalPath, source)=>{
|
|
53
|
+
const physicalPattern = (0, external_patterns_js_namespaceObject.getPhysicalPathPattern)(sourcePattern);
|
|
54
|
+
const existing = physicalPatterns.get(physicalPattern);
|
|
55
|
+
if (existing && existing.canonicalPath !== canonicalPath) throw new Error(`localisedUrls["${existing.canonicalPath}"] ${existing.source} and localisedUrls["${canonicalPath}"] ${source} generate the same physical route pattern "${physicalPattern}". Localised route patterns must identify exactly one canonical route.`);
|
|
56
|
+
const overlapping = claimedPatterns.find((claimed)=>claimed.canonicalPath !== canonicalPath && (0, external_patterns_js_namespaceObject.haveEqualPatternSpecificity)(claimed.pattern, sourcePattern) && (0, external_patterns_js_namespaceObject.doPathPatternsOverlap)(claimed.pattern, sourcePattern));
|
|
57
|
+
if (overlapping) throw new Error(`localisedUrls["${overlapping.canonicalPath}"] ${overlapping.source} pattern "${overlapping.pattern}" and localisedUrls["${canonicalPath}"] ${source} pattern "${sourcePattern}" are equal-specificity overlapping route patterns. Localised route patterns must identify exactly one canonical route.`);
|
|
58
|
+
physicalPatterns.set(physicalPattern, {
|
|
59
|
+
canonicalPath,
|
|
60
|
+
source
|
|
61
|
+
});
|
|
62
|
+
claimedPatterns.push({
|
|
63
|
+
canonicalPath,
|
|
64
|
+
pattern: sourcePattern,
|
|
65
|
+
source
|
|
66
|
+
});
|
|
67
|
+
};
|
|
68
|
+
for (const [canonicalPath, entry] of Object.entries(localisedUrls).sort(([left], [right])=>left < right ? -1 : left > right ? 1 : 0)){
|
|
69
|
+
const canonicalIssue = (0, external_patterns_js_namespaceObject.getPathPatternValidationIssue)(canonicalPath);
|
|
70
|
+
if (canonicalIssue) throw new Error(`localisedUrls["${canonicalPath}"] canonical pattern has ${canonicalIssue}.`);
|
|
71
|
+
const expectedSignature = (0, external_patterns_js_namespaceObject.getPathParamSignature)(canonicalPath);
|
|
72
|
+
const expectedOrderedSignature = (0, external_patterns_js_namespaceObject.getOrderedPathParamSignature)(canonicalPath);
|
|
73
|
+
claimPhysicalPattern(canonicalPath, canonicalPath, 'canonical pattern');
|
|
74
|
+
for (const language of languages){
|
|
75
|
+
const localisedPattern = entry[language];
|
|
76
|
+
if (!localisedPattern) continue;
|
|
77
|
+
const localisedIssue = (0, external_patterns_js_namespaceObject.getPathPatternValidationIssue)(localisedPattern);
|
|
78
|
+
if (localisedIssue) throw new Error(`localisedUrls["${canonicalPath}"].${language} has ${localisedIssue}.`);
|
|
79
|
+
const actualSignature = (0, external_patterns_js_namespaceObject.getPathParamSignature)(localisedPattern);
|
|
80
|
+
if (actualSignature.length !== expectedSignature.length || actualSignature.some((parameter, index)=>parameter !== expectedSignature[index])) throw new Error(`localisedUrls["${canonicalPath}"].${language} must use the same parameter signature as "${canonicalPath}": expected ${formatParamSignature(expectedSignature)}; received ${formatParamSignature(actualSignature)}.`);
|
|
81
|
+
const actualOrderedSignature = (0, external_patterns_js_namespaceObject.getOrderedPathParamSignature)(localisedPattern);
|
|
82
|
+
if (expectedOrderedSignature.some((parameter)=>'*' === parameter || parameter.endsWith('?')) && actualOrderedSignature.some((parameter, index)=>parameter !== expectedOrderedSignature[index])) throw new Error(`localisedUrls["${canonicalPath}"].${language} must keep optional and splat parameters in canonical order: expected ${formatParamSignature(expectedOrderedSignature)}; received ${formatParamSignature(actualOrderedSignature)}.`);
|
|
83
|
+
claimPhysicalPattern(localisedPattern, canonicalPath, `.${language}`);
|
|
84
|
+
}
|
|
85
|
+
}
|
|
86
|
+
};
|
|
87
|
+
const ensureLocalisedUrlsForPath = (canonicalPath, languages, localisedUrls)=>{
|
|
88
|
+
const entry = localisedUrls[canonicalPath];
|
|
89
|
+
if (!entry) throw new Error(`localisedUrls is enabled, but route "${canonicalPath}" does not define localised URLs for languages: ${languages.join(', ')}. Add localisedUrls["${canonicalPath}"] or set localeDetection.localisedUrls to false.`);
|
|
90
|
+
const missingLanguages = languages.filter((language)=>!entry[language]);
|
|
91
|
+
if (missingLanguages.length > 0) throw new Error(`localisedUrls["${canonicalPath}"] is missing languages: ${missingLanguages.join(', ')}. Every configured language must have a localised URL.`);
|
|
92
|
+
return entry;
|
|
93
|
+
};
|
|
94
|
+
const validateLocalisedUrls = (routes, languages, localisedUrls)=>{
|
|
95
|
+
validateLocalisedUrlPatterns(languages, localisedUrls);
|
|
96
|
+
const visit = (route, parentPath)=>{
|
|
97
|
+
const canonicalPath = joinPath(parentPath, route.path);
|
|
98
|
+
if (isLocalisableRoutePath(route.path) && !isFrameworkInternalRoutePath(canonicalPath)) ensureLocalisedUrlsForPath(canonicalPath, languages, localisedUrls);
|
|
99
|
+
if ('children' in route && route.children) route.children.forEach((child)=>visit(child, canonicalPath));
|
|
100
|
+
};
|
|
101
|
+
routes.forEach((route)=>visit(route, ''));
|
|
102
|
+
};
|
|
103
|
+
const getLocalisedRouteIdSuffixes = (paths)=>{
|
|
104
|
+
const usedSuffixes = new Set();
|
|
105
|
+
return paths.map((path, index)=>{
|
|
106
|
+
if (0 === index) return;
|
|
107
|
+
const baseSuffix = legalRouteIdPart(path);
|
|
108
|
+
let suffix = baseSuffix;
|
|
109
|
+
let disambiguator = 2;
|
|
110
|
+
while(usedSuffixes.has(suffix))suffix = `${baseSuffix}_${disambiguator++}`;
|
|
111
|
+
usedSuffixes.add(suffix);
|
|
112
|
+
return suffix;
|
|
113
|
+
});
|
|
114
|
+
};
|
|
115
|
+
const getLocalisedRoutePaths = (canonicalPath, parentLocalisedPaths, languages, entry)=>{
|
|
116
|
+
const paths = languages.map((language)=>{
|
|
117
|
+
const fullPath = (0, external_normalise_js_namespaceObject.normalisePathPattern)(entry[language]);
|
|
118
|
+
const parentPath = (0, external_normalise_js_namespaceObject.normalisePathPattern)(parentLocalisedPaths[language] || '/');
|
|
119
|
+
if ('/' === parentPath) return (0, external_normalise_js_namespaceObject.normaliseRoutePath)(fullPath) || void 0;
|
|
120
|
+
const parentPrefix = `${parentPath}/`;
|
|
121
|
+
if (!fullPath.startsWith(parentPrefix)) throw new Error(`localisedUrls["${canonicalPath}"].${language} must be nested under "${parentPath}" because its parent route is localised there.`);
|
|
122
|
+
return (0, external_normalise_js_namespaceObject.normaliseRoutePath)(fullPath.slice(parentPath.length));
|
|
123
|
+
});
|
|
124
|
+
const uniquePaths = Array.from(new Set(paths.filter(Boolean)));
|
|
125
|
+
return uniquePaths;
|
|
126
|
+
};
|
|
127
|
+
const transformLocalisedRoute = (route, parentCanonicalPath, parentLocalisedPaths, languages, localisedUrls)=>{
|
|
128
|
+
const canonicalPath = joinPath(parentCanonicalPath, route.path);
|
|
129
|
+
const localisedUrlEntry = isLocalisableRoutePath(route.path) && !isFrameworkInternalRoutePath(canonicalPath) ? ensureLocalisedUrlsForPath(canonicalPath, languages, localisedUrls) : void 0;
|
|
130
|
+
const routeLocalisedPaths = localisedUrlEntry ? languages.reduce((acc, language)=>{
|
|
131
|
+
acc[language] = (0, external_normalise_js_namespaceObject.normalisePathPattern)(localisedUrlEntry[language]);
|
|
132
|
+
return acc;
|
|
133
|
+
}, {}) : parentLocalisedPaths;
|
|
134
|
+
const children = 'children' in route && route.children ? route.children.flatMap((child)=>transformLocalisedRoute(child, canonicalPath, routeLocalisedPaths, languages, localisedUrls)) : void 0;
|
|
135
|
+
const baseRoute = {
|
|
136
|
+
...route,
|
|
137
|
+
...children ? {
|
|
138
|
+
children
|
|
139
|
+
} : {}
|
|
140
|
+
};
|
|
141
|
+
if (!localisedUrlEntry) return [
|
|
142
|
+
baseRoute
|
|
143
|
+
];
|
|
144
|
+
const localisedPaths = getLocalisedRoutePaths(canonicalPath, parentLocalisedPaths, languages, localisedUrlEntry);
|
|
145
|
+
const leadingLocaleParam = (0, external_normalise_js_namespaceObject.getLeadingLocaleParam)(route.path);
|
|
146
|
+
const routeIdPaths = leadingLocaleParam ? localisedPaths.map((path)=>(0, external_normalise_js_namespaceObject.normaliseRoutePath)(`${leadingLocaleParam}/${path}`)) : localisedPaths;
|
|
147
|
+
const routeIdSuffixes = getLocalisedRouteIdSuffixes(routeIdPaths);
|
|
148
|
+
return localisedPaths.map((localisedPath, index)=>cloneRouteWithLocalisedPath(baseRoute, localisedPath, routeIdSuffixes[index], canonicalPath));
|
|
149
|
+
};
|
|
150
|
+
const suffixRouteIds = (route, suffix)=>{
|
|
151
|
+
const children = 'children' in route && route.children ? route.children.map((child)=>suffixRouteIds(child, suffix)) : void 0;
|
|
152
|
+
return {
|
|
153
|
+
...route,
|
|
154
|
+
...route.id ? {
|
|
155
|
+
id: `${route.id}__localised_${suffix}`
|
|
156
|
+
} : {},
|
|
157
|
+
...children ? {
|
|
158
|
+
children
|
|
159
|
+
} : {}
|
|
160
|
+
};
|
|
161
|
+
};
|
|
162
|
+
const collectRouteIdCounts = (routes, counts = new Map())=>{
|
|
163
|
+
for (const route of routes){
|
|
164
|
+
if (route.id) counts.set(route.id, (counts.get(route.id) ?? 0) + 1);
|
|
165
|
+
if ('children' in route && route.children) collectRouteIdCounts(route.children, counts);
|
|
166
|
+
}
|
|
167
|
+
return counts;
|
|
168
|
+
};
|
|
169
|
+
const validateNoNewRouteIdCollisions = (sourceRoutes, localisedRoutes)=>{
|
|
170
|
+
const sourceCounts = collectRouteIdCounts(sourceRoutes);
|
|
171
|
+
const localisedCounts = collectRouteIdCounts(localisedRoutes);
|
|
172
|
+
for (const [routeId, count] of localisedCounts)if (count > 1 && count > (sourceCounts.get(routeId) ?? 0)) throw new Error(`Localised route expansion generated duplicate route ID "${routeId}". Change the source route ID or localised path so every generated route remains identifiable.`);
|
|
173
|
+
};
|
|
174
|
+
const cloneRouteWithLocalisedPath = (route, path, routeIdSuffix, canonicalPath)=>{
|
|
175
|
+
const leadingLocaleParam = (0, external_normalise_js_namespaceObject.getLeadingLocaleParam)(route.path);
|
|
176
|
+
const localisedPath = leadingLocaleParam ? (0, external_normalise_js_namespaceObject.normaliseRoutePath)(`${leadingLocaleParam}/${path}`) : path;
|
|
177
|
+
const routeWithPath = {
|
|
178
|
+
...route,
|
|
179
|
+
path: localisedPath
|
|
180
|
+
};
|
|
181
|
+
routeWithPath.modernCanonicalPath = canonicalPath;
|
|
182
|
+
return routeIdSuffix ? suffixRouteIds(routeWithPath, routeIdSuffix) : routeWithPath;
|
|
183
|
+
};
|
|
184
|
+
const applyLocalisedUrlsToRoutes = (routes, languages, localisedUrls)=>{
|
|
185
|
+
const rootLocalisedPaths = languages.reduce((acc, language)=>{
|
|
186
|
+
acc[language] = '/';
|
|
187
|
+
return acc;
|
|
188
|
+
}, {});
|
|
189
|
+
validateLocalisedUrls(routes, languages, localisedUrls);
|
|
190
|
+
const localisedRoutes = routes.flatMap((route)=>transformLocalisedRoute(route, '', rootLocalisedPaths, languages, localisedUrls));
|
|
191
|
+
validateNoNewRouteIdCollisions(routes, localisedRoutes);
|
|
192
|
+
return localisedRoutes;
|
|
193
|
+
};
|
|
194
|
+
exports.applyLocalisedUrlsToRoutes = __webpack_exports__.applyLocalisedUrlsToRoutes;
|
|
195
|
+
exports.validateLocalisedUrls = __webpack_exports__.validateLocalisedUrls;
|
|
196
|
+
for(var __rspack_i in __webpack_exports__)if (-1 === [
|
|
197
|
+
"applyLocalisedUrlsToRoutes",
|
|
198
|
+
"validateLocalisedUrls"
|
|
199
|
+
].indexOf(__rspack_i)) exports[__rspack_i] = __webpack_exports__[__rspack_i];
|
|
200
|
+
Object.defineProperty(exports, '__esModule', {
|
|
201
|
+
value: true
|
|
202
|
+
});
|
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
var __webpack_require__ = {};
|
|
3
|
+
(()=>{
|
|
4
|
+
__webpack_require__.r = (exports1)=>{
|
|
5
|
+
if ("u" > typeof Symbol && Symbol.toStringTag) Object.defineProperty(exports1, Symbol.toStringTag, {
|
|
6
|
+
value: 'Module'
|
|
7
|
+
});
|
|
8
|
+
Object.defineProperty(exports1, '__esModule', {
|
|
9
|
+
value: true
|
|
10
|
+
});
|
|
11
|
+
};
|
|
12
|
+
})();
|
|
13
|
+
var __webpack_exports__ = {};
|
|
14
|
+
__webpack_require__.r(__webpack_exports__);
|
|
15
|
+
for(var __rspack_i in __webpack_exports__)exports[__rspack_i] = __webpack_exports__[__rspack_i];
|
|
16
|
+
Object.defineProperty(exports, '__esModule', {
|
|
17
|
+
value: true
|
|
18
|
+
});
|
|
@@ -0,0 +1,203 @@
|
|
|
1
|
+
const DEFAULT_ATTEMPT_TIMEOUT_MS = 1000;
|
|
2
|
+
const DEFAULT_MAX_ATTEMPTS = 4;
|
|
3
|
+
const defaultRetryDelay = (failedAttempts)=>Math.min(50 * 2 ** Math.max(failedAttempts - 1, 0), 1000);
|
|
4
|
+
const normalizePolicy = (policy)=>({
|
|
5
|
+
attemptTimeoutMs: Math.max(1, policy.attemptTimeoutMs ?? DEFAULT_ATTEMPT_TIMEOUT_MS),
|
|
6
|
+
maxAttempts: Math.max(1, policy.maxAttempts ?? DEFAULT_MAX_ATTEMPTS),
|
|
7
|
+
retryDelayMs: policy.retryDelayMs ?? defaultRetryDelay
|
|
8
|
+
});
|
|
9
|
+
class LanguageSyncTimeoutError extends Error {
|
|
10
|
+
constructor(language, timeoutMs){
|
|
11
|
+
super(`Language synchronization for "${language}" did not settle within ${timeoutMs}ms.`);
|
|
12
|
+
this.name = 'LanguageSyncTimeoutError';
|
|
13
|
+
}
|
|
14
|
+
}
|
|
15
|
+
let requestOrder = 0;
|
|
16
|
+
class Binding {
|
|
17
|
+
activate(target) {
|
|
18
|
+
if (this.target === target && this.coordinator) return;
|
|
19
|
+
const desiredLanguage = this.desiredLanguage;
|
|
20
|
+
const order = this.order;
|
|
21
|
+
this.deactivate();
|
|
22
|
+
this.desiredLanguage = desiredLanguage;
|
|
23
|
+
this.order = order;
|
|
24
|
+
this.target = target;
|
|
25
|
+
this.coordinator = getCoordinator(target, this.policy);
|
|
26
|
+
this.coordinator.add(this);
|
|
27
|
+
this.coordinator.refresh();
|
|
28
|
+
}
|
|
29
|
+
clearRequest() {
|
|
30
|
+
if (!this.desiredLanguage) return;
|
|
31
|
+
this.desiredLanguage = void 0;
|
|
32
|
+
this.order = ++requestOrder;
|
|
33
|
+
this.coordinator?.refresh();
|
|
34
|
+
}
|
|
35
|
+
deactivate() {
|
|
36
|
+
const coordinator = this.coordinator;
|
|
37
|
+
this.coordinator = void 0;
|
|
38
|
+
this.target = void 0;
|
|
39
|
+
this.desiredLanguage = void 0;
|
|
40
|
+
coordinator?.remove(this);
|
|
41
|
+
}
|
|
42
|
+
request(language) {
|
|
43
|
+
const normalizedLanguage = language.trim();
|
|
44
|
+
if (!normalizedLanguage) return;
|
|
45
|
+
const repeatedFailedRequest = this.desiredLanguage === normalizedLanguage && this.coordinator?.isFailed(normalizedLanguage);
|
|
46
|
+
this.desiredLanguage = normalizedLanguage;
|
|
47
|
+
this.order = ++requestOrder;
|
|
48
|
+
this.coordinator?.refresh(repeatedFailedRequest);
|
|
49
|
+
}
|
|
50
|
+
updateCallbacks(callbacks) {
|
|
51
|
+
this.callbacks = callbacks;
|
|
52
|
+
}
|
|
53
|
+
constructor(policy){
|
|
54
|
+
this.policy = policy;
|
|
55
|
+
this.order = 0;
|
|
56
|
+
}
|
|
57
|
+
}
|
|
58
|
+
class Coordinator {
|
|
59
|
+
add(binding) {
|
|
60
|
+
this.bindings.add(binding);
|
|
61
|
+
}
|
|
62
|
+
isFailed(language) {
|
|
63
|
+
return this.intent?.language === language && 'failed' === this.intent.status;
|
|
64
|
+
}
|
|
65
|
+
refresh(forceRetry = false) {
|
|
66
|
+
const selected = this.selectBinding();
|
|
67
|
+
if (!selected?.desiredLanguage) return void this.invalidateIntent();
|
|
68
|
+
const language = selected.desiredLanguage;
|
|
69
|
+
if (this.intent?.language === language && !forceRetry) {
|
|
70
|
+
if ('satisfied' === this.intent.status) this.commit(language);
|
|
71
|
+
return;
|
|
72
|
+
}
|
|
73
|
+
this.beginIntent(language, false);
|
|
74
|
+
}
|
|
75
|
+
remove(binding) {
|
|
76
|
+
this.bindings.delete(binding);
|
|
77
|
+
this.refresh();
|
|
78
|
+
}
|
|
79
|
+
beginIntent(language, forceChange) {
|
|
80
|
+
this.clearRetry();
|
|
81
|
+
const intent = {
|
|
82
|
+
attempts: 0,
|
|
83
|
+
generation: ++this.generation,
|
|
84
|
+
language,
|
|
85
|
+
status: 'attempting'
|
|
86
|
+
};
|
|
87
|
+
this.intent = intent;
|
|
88
|
+
const activeBinding = this.selectBinding();
|
|
89
|
+
if (!forceChange && activeBinding?.callbacks?.readLanguage?.(this.target) === language) {
|
|
90
|
+
intent.status = 'satisfied';
|
|
91
|
+
this.commit(language);
|
|
92
|
+
return;
|
|
93
|
+
}
|
|
94
|
+
this.startAttempt(intent);
|
|
95
|
+
}
|
|
96
|
+
clearRetry() {
|
|
97
|
+
if (void 0 !== this.retryTimer) {
|
|
98
|
+
clearTimeout(this.retryTimer);
|
|
99
|
+
this.retryTimer = void 0;
|
|
100
|
+
}
|
|
101
|
+
}
|
|
102
|
+
commit(language) {
|
|
103
|
+
for (const binding of this.bindings)if (binding.callbacks) binding.callbacks.commitLanguage(this.target, language);
|
|
104
|
+
}
|
|
105
|
+
failAttempt(intent, reason, error) {
|
|
106
|
+
if (this.intent !== intent || 'satisfied' === intent.status) return;
|
|
107
|
+
if (intent.attempts >= this.policy.maxAttempts) {
|
|
108
|
+
intent.status = 'failed';
|
|
109
|
+
for (const binding of this.bindings)if (binding.desiredLanguage === intent.language) binding.callbacks?.reportFailure?.({
|
|
110
|
+
attempts: intent.attempts,
|
|
111
|
+
error,
|
|
112
|
+
language: intent.language,
|
|
113
|
+
reason
|
|
114
|
+
});
|
|
115
|
+
return;
|
|
116
|
+
}
|
|
117
|
+
intent.status = 'retry-wait';
|
|
118
|
+
const retryDelay = Math.max(0, this.policy.retryDelayMs(intent.attempts));
|
|
119
|
+
this.retryTimer = setTimeout(()=>{
|
|
120
|
+
this.retryTimer = void 0;
|
|
121
|
+
if (this.intent === intent && this.selectBinding()?.desiredLanguage) {
|
|
122
|
+
intent.status = 'attempting';
|
|
123
|
+
this.startAttempt(intent);
|
|
124
|
+
}
|
|
125
|
+
}, retryDelay);
|
|
126
|
+
}
|
|
127
|
+
invalidateIntent() {
|
|
128
|
+
this.clearRetry();
|
|
129
|
+
this.generation += 1;
|
|
130
|
+
this.intent = void 0;
|
|
131
|
+
}
|
|
132
|
+
onFulfilled(attempt) {
|
|
133
|
+
if (attempt.settled) return;
|
|
134
|
+
attempt.settled = true;
|
|
135
|
+
clearTimeout(attempt.timer);
|
|
136
|
+
const intent = this.intent;
|
|
137
|
+
if (!intent) return;
|
|
138
|
+
if (attempt.language === intent.language) {
|
|
139
|
+
this.clearRetry();
|
|
140
|
+
if ('satisfied' !== intent.status) {
|
|
141
|
+
intent.status = 'satisfied';
|
|
142
|
+
this.commit(intent.language);
|
|
143
|
+
}
|
|
144
|
+
return;
|
|
145
|
+
}
|
|
146
|
+
this.beginIntent(intent.language, true);
|
|
147
|
+
}
|
|
148
|
+
onRejected(attempt, error) {
|
|
149
|
+
if (attempt.settled) return;
|
|
150
|
+
attempt.settled = true;
|
|
151
|
+
clearTimeout(attempt.timer);
|
|
152
|
+
if (attempt.timedOut) return;
|
|
153
|
+
const intent = this.intent;
|
|
154
|
+
if (intent?.generation === attempt.generation && intent.language === attempt.language) this.failAttempt(intent, 'rejected', error);
|
|
155
|
+
}
|
|
156
|
+
selectBinding() {
|
|
157
|
+
let selected;
|
|
158
|
+
for (const binding of this.bindings)if (binding.desiredLanguage && binding.callbacks && (!selected || binding.order > selected.order)) selected = binding;
|
|
159
|
+
return selected;
|
|
160
|
+
}
|
|
161
|
+
startAttempt(intent) {
|
|
162
|
+
const binding = this.selectBinding();
|
|
163
|
+
const callbacks = binding?.callbacks;
|
|
164
|
+
if (!callbacks || binding?.desiredLanguage !== intent.language) return void this.refresh();
|
|
165
|
+
intent.attempts += 1;
|
|
166
|
+
const attempt = {
|
|
167
|
+
generation: intent.generation,
|
|
168
|
+
language: intent.language,
|
|
169
|
+
settled: false,
|
|
170
|
+
timedOut: false,
|
|
171
|
+
timer: void 0
|
|
172
|
+
};
|
|
173
|
+
attempt.timer = setTimeout(()=>{
|
|
174
|
+
if (attempt.settled || attempt.timedOut) return;
|
|
175
|
+
attempt.timedOut = true;
|
|
176
|
+
if (this.intent === intent && intent.generation === attempt.generation && intent.language === attempt.language) this.failAttempt(intent, 'timeout', new LanguageSyncTimeoutError(attempt.language, this.policy.attemptTimeoutMs));
|
|
177
|
+
}, this.policy.attemptTimeoutMs);
|
|
178
|
+
let result;
|
|
179
|
+
try {
|
|
180
|
+
result = callbacks.changeLanguage(this.target, intent.language);
|
|
181
|
+
} catch (error) {
|
|
182
|
+
this.onRejected(attempt, error);
|
|
183
|
+
return;
|
|
184
|
+
}
|
|
185
|
+
Promise.resolve(result).then(()=>this.onFulfilled(attempt), (error)=>this.onRejected(attempt, error));
|
|
186
|
+
}
|
|
187
|
+
constructor(target, policy){
|
|
188
|
+
this.target = target;
|
|
189
|
+
this.policy = policy;
|
|
190
|
+
this.bindings = new Set();
|
|
191
|
+
this.generation = 0;
|
|
192
|
+
}
|
|
193
|
+
}
|
|
194
|
+
const coordinators = new WeakMap();
|
|
195
|
+
const getCoordinator = (target, policy)=>{
|
|
196
|
+
const existing = coordinators.get(target);
|
|
197
|
+
if (existing) return existing;
|
|
198
|
+
const coordinator = new Coordinator(target, policy);
|
|
199
|
+
coordinators.set(target, coordinator);
|
|
200
|
+
return coordinator;
|
|
201
|
+
};
|
|
202
|
+
const createLatestLanguageSyncBinding = (policy = {})=>new Binding(normalizePolicy(policy));
|
|
203
|
+
export { createLatestLanguageSyncBinding };
|
|
@@ -0,0 +1,118 @@
|
|
|
1
|
+
import { getGlobalBasename } from "@modern-js/runtime/context";
|
|
2
|
+
import i18next_browser_languagedetector from "i18next-browser-languagedetector";
|
|
3
|
+
import { useCallback, useEffect, useRef } from "react";
|
|
4
|
+
import { createLatestLanguageSyncBinding } from "./controller.mjs";
|
|
5
|
+
const useLatestLanguageSync = ({ changeLanguage, commitLanguage, desiredLanguage, enabled = true, policy, readLanguage, reportFailure, target })=>{
|
|
6
|
+
const bindingRef = useRef(null);
|
|
7
|
+
if (null === bindingRef.current) bindingRef.current = createLatestLanguageSyncBinding(policy);
|
|
8
|
+
const binding = bindingRef.current;
|
|
9
|
+
useEffect(()=>{
|
|
10
|
+
binding.updateCallbacks({
|
|
11
|
+
changeLanguage,
|
|
12
|
+
commitLanguage,
|
|
13
|
+
readLanguage,
|
|
14
|
+
reportFailure
|
|
15
|
+
});
|
|
16
|
+
});
|
|
17
|
+
useEffect(()=>{
|
|
18
|
+
if (!enabled || !target) return;
|
|
19
|
+
binding.activate(target);
|
|
20
|
+
return ()=>binding.deactivate();
|
|
21
|
+
}, [
|
|
22
|
+
binding,
|
|
23
|
+
enabled,
|
|
24
|
+
target
|
|
25
|
+
]);
|
|
26
|
+
useEffect(()=>{
|
|
27
|
+
if (!enabled || !target || !desiredLanguage) return void binding.clearRequest();
|
|
28
|
+
binding.request(desiredLanguage);
|
|
29
|
+
}, [
|
|
30
|
+
binding,
|
|
31
|
+
desiredLanguage,
|
|
32
|
+
enabled,
|
|
33
|
+
target
|
|
34
|
+
]);
|
|
35
|
+
useEffect(()=>{
|
|
36
|
+
if (!enabled || !target || !desiredLanguage || "u" < typeof window) return;
|
|
37
|
+
const retryCurrentIntent = ()=>binding.request(desiredLanguage);
|
|
38
|
+
const retryVisibleIntent = ()=>{
|
|
39
|
+
if ('visible' === document.visibilityState) retryCurrentIntent();
|
|
40
|
+
};
|
|
41
|
+
window.addEventListener('online', retryCurrentIntent);
|
|
42
|
+
document.addEventListener('visibilitychange', retryVisibleIntent);
|
|
43
|
+
return ()=>{
|
|
44
|
+
window.removeEventListener('online', retryCurrentIntent);
|
|
45
|
+
document.removeEventListener('visibilitychange', retryVisibleIntent);
|
|
46
|
+
};
|
|
47
|
+
}, [
|
|
48
|
+
binding,
|
|
49
|
+
desiredLanguage,
|
|
50
|
+
enabled,
|
|
51
|
+
target
|
|
52
|
+
]);
|
|
53
|
+
return useCallback((language)=>binding.request(language), [
|
|
54
|
+
binding
|
|
55
|
+
]);
|
|
56
|
+
};
|
|
57
|
+
const actualInstance = (instance)=>instance.i18nInstance?.instance ?? instance;
|
|
58
|
+
const cacheLanguage = (instance, language)=>{
|
|
59
|
+
if ("u" < typeof window) return;
|
|
60
|
+
const actual = actualInstance(instance);
|
|
61
|
+
const detector = actual.services?.languageDetector ?? instance.services?.languageDetector;
|
|
62
|
+
try {
|
|
63
|
+
if (detector?.cacheUserLanguage) return void detector.cacheUserLanguage(language);
|
|
64
|
+
if (instance.isInitialized || actual.isInitialized) {
|
|
65
|
+
const services = actual.services ?? instance.services;
|
|
66
|
+
const options = actual.options ?? instance.options;
|
|
67
|
+
if (services && options) {
|
|
68
|
+
const manualDetector = new i18next_browser_languagedetector();
|
|
69
|
+
manualDetector.init(services, instance.options?.detection ?? options.detection);
|
|
70
|
+
manualDetector.cacheUserLanguage?.(language);
|
|
71
|
+
}
|
|
72
|
+
}
|
|
73
|
+
} catch {}
|
|
74
|
+
};
|
|
75
|
+
const detectPathLanguage = (pathname, languages)=>{
|
|
76
|
+
if (!pathname) return;
|
|
77
|
+
const basename = getGlobalBasename();
|
|
78
|
+
const entryPath = basename && '/' !== basename ? basename : '';
|
|
79
|
+
const relativePath = pathname.startsWith(entryPath) ? pathname.slice(entryPath.length) : pathname;
|
|
80
|
+
const segments = relativePath.split('/').filter(Boolean);
|
|
81
|
+
const segmentsToCheck = !entryPath && segments.length > 1 && segments[0] && !languages.includes(segments[0]) ? segments.slice(1) : segments;
|
|
82
|
+
const language = segmentsToCheck[0];
|
|
83
|
+
return language && languages.includes(language) ? language : void 0;
|
|
84
|
+
};
|
|
85
|
+
function useLanguageSync(i18nInstance, localePathRedirect, languages, pathname, prevLangRef, setLang) {
|
|
86
|
+
const desiredLanguage = localePathRedirect ? detectPathLanguage(pathname, languages) : void 0;
|
|
87
|
+
const synchronizeLanguage = useLatestLanguageSync({
|
|
88
|
+
target: i18nInstance,
|
|
89
|
+
desiredLanguage,
|
|
90
|
+
enabled: Boolean(i18nInstance && localePathRedirect),
|
|
91
|
+
readLanguage: (instance)=>instance.language,
|
|
92
|
+
changeLanguage: (instance, language)=>instance.setLang ? instance.setLang(language) : instance.changeLanguage?.(language),
|
|
93
|
+
commitLanguage: (instance, language)=>{
|
|
94
|
+
prevLangRef.current = language;
|
|
95
|
+
setLang(language);
|
|
96
|
+
cacheLanguage(instance, language);
|
|
97
|
+
},
|
|
98
|
+
reportFailure: ({ error, language })=>{
|
|
99
|
+
console.error(`Failed to synchronize i18n language "${language}".`, error);
|
|
100
|
+
}
|
|
101
|
+
});
|
|
102
|
+
useEffect(()=>{
|
|
103
|
+
if (!localePathRedirect && i18nInstance?.language) {
|
|
104
|
+
const instanceLanguage = i18nInstance.language;
|
|
105
|
+
if (instanceLanguage !== prevLangRef.current) {
|
|
106
|
+
prevLangRef.current = instanceLanguage;
|
|
107
|
+
setLang(instanceLanguage);
|
|
108
|
+
}
|
|
109
|
+
}
|
|
110
|
+
}, [
|
|
111
|
+
i18nInstance,
|
|
112
|
+
localePathRedirect,
|
|
113
|
+
prevLangRef,
|
|
114
|
+
setLang
|
|
115
|
+
]);
|
|
116
|
+
return synchronizeLanguage;
|
|
117
|
+
}
|
|
118
|
+
export { useLanguageSync, useLatestLanguageSync };
|
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
const resolveLocalisedUrlsConfig = (option)=>{
|
|
2
|
+
if (option && 'object' == typeof option && Object.keys(option).length > 0) return {
|
|
3
|
+
enabled: true,
|
|
4
|
+
map: option
|
|
5
|
+
};
|
|
6
|
+
return {
|
|
7
|
+
enabled: false,
|
|
8
|
+
map: {}
|
|
9
|
+
};
|
|
10
|
+
};
|
|
11
|
+
export { resolveLocalisedUrlsConfig };
|
|
@@ -0,0 +1,7 @@
|
|
|
1
|
+
export { resolveLocalisedUrlsConfig } from "./config.mjs";
|
|
2
|
+
export { normalisePathPattern, normalisePathname } from "./normalise.mjs";
|
|
3
|
+
export { canonicalTargetPathname, localiseTargetPathname, stripLanguagePrefix } from "./pathname.mjs";
|
|
4
|
+
export { buildPathFromPattern, matchPathPattern } from "./patterns.mjs";
|
|
5
|
+
export { DEFAULT_LOCALE_REDIRECT_SKIP_RULES, isDefaultLocaleRedirectSkipPath, matchesPathPrefix, shouldSkipLocaleRedirect } from "./redirect.mjs";
|
|
6
|
+
export { resolveCanonicalLocalisedPath, resolveLocalisedPath } from "./resolve.mjs";
|
|
7
|
+
export { applyLocalisedUrlsToRoutes, validateLocalisedUrls } from "./routes.mjs";
|
|
@@ -0,0 +1,37 @@
|
|
|
1
|
+
const LOCALE_PARAM_NAMES = new Set([
|
|
2
|
+
'lang',
|
|
3
|
+
'locale',
|
|
4
|
+
'language'
|
|
5
|
+
]);
|
|
6
|
+
const normaliseSlashes = (path)=>{
|
|
7
|
+
const withoutDuplicateSlashes = path.replace(/\/+/g, '/');
|
|
8
|
+
const withLeadingSlash = withoutDuplicateSlashes.startsWith('/') ? withoutDuplicateSlashes : `/${withoutDuplicateSlashes}`;
|
|
9
|
+
return withLeadingSlash.length > 1 ? withLeadingSlash.replace(/\/+$/, '') : withLeadingSlash;
|
|
10
|
+
};
|
|
11
|
+
const normalisePathPattern = (path)=>normaliseSlashes(path).replace(/\[(.+?)\]/g, ':$1');
|
|
12
|
+
const normalisePathname = (pathname)=>normaliseSlashes(pathname);
|
|
13
|
+
const normaliseRoutePath = (path)=>{
|
|
14
|
+
const normalized = normalisePathPattern(path);
|
|
15
|
+
return '/' === normalized ? '' : normalized.slice(1);
|
|
16
|
+
};
|
|
17
|
+
const getLocaleParamSegment = (segment)=>{
|
|
18
|
+
if (!segment.startsWith(':')) return null;
|
|
19
|
+
const paramName = segment.slice(1).replace(/\?$/, '');
|
|
20
|
+
return LOCALE_PARAM_NAMES.has(paramName) ? segment : null;
|
|
21
|
+
};
|
|
22
|
+
const splitPathSegments = (path)=>{
|
|
23
|
+
if (!path) return [];
|
|
24
|
+
return normalisePathPattern(path).split('/').filter(Boolean);
|
|
25
|
+
};
|
|
26
|
+
const stripLeadingLocaleParam = (path)=>{
|
|
27
|
+
const segments = splitPathSegments(path);
|
|
28
|
+
const leadingLocaleParam = getLocaleParamSegment(segments[0] || '');
|
|
29
|
+
if (!leadingLocaleParam) return path;
|
|
30
|
+
const remainingPath = segments.slice(1).join('/');
|
|
31
|
+
return remainingPath ? `/${remainingPath}` : void 0;
|
|
32
|
+
};
|
|
33
|
+
const getLeadingLocaleParam = (path)=>{
|
|
34
|
+
const segments = splitPathSegments(path);
|
|
35
|
+
return getLocaleParamSegment(segments[0] || '');
|
|
36
|
+
};
|
|
37
|
+
export { getLeadingLocaleParam, normalisePathPattern, normalisePathname, normaliseRoutePath, stripLeadingLocaleParam };
|
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
import { resolveLocalisedUrlsConfig } from "./config.mjs";
|
|
2
|
+
import { resolveCanonicalLocalisedPath, resolveLocalisedPath } from "./resolve.mjs";
|
|
3
|
+
const stripLanguagePrefix = (pathname, languages)=>{
|
|
4
|
+
const segments = pathname.split('/').filter(Boolean);
|
|
5
|
+
const firstSegment = segments[0]?.toLowerCase();
|
|
6
|
+
if (firstSegment && languages.some((language)=>language.toLowerCase() === firstSegment)) return `/${segments.slice(1).join('/')}`;
|
|
7
|
+
return pathname || '/';
|
|
8
|
+
};
|
|
9
|
+
const localiseTargetPathname = (pathname, language, languages, localisedUrls)=>{
|
|
10
|
+
const pathWithoutLanguage = stripLanguagePrefix(pathname, languages);
|
|
11
|
+
const localisedUrlsConfig = resolveLocalisedUrlsConfig(localisedUrls);
|
|
12
|
+
const resolvedPath = localisedUrlsConfig.enabled ? resolveLocalisedPath(pathWithoutLanguage, language, languages, localisedUrlsConfig.map) : pathWithoutLanguage;
|
|
13
|
+
const resolvedSegments = resolvedPath.split('/').filter(Boolean);
|
|
14
|
+
return `/${[
|
|
15
|
+
language,
|
|
16
|
+
...resolvedSegments
|
|
17
|
+
].join('/')}`;
|
|
18
|
+
};
|
|
19
|
+
const canonicalTargetPathname = (pathname, languages, localisedUrls)=>{
|
|
20
|
+
const pathWithoutLanguage = stripLanguagePrefix(pathname, languages);
|
|
21
|
+
const localisedUrlsConfig = resolveLocalisedUrlsConfig(localisedUrls);
|
|
22
|
+
return localisedUrlsConfig.enabled ? resolveCanonicalLocalisedPath(pathWithoutLanguage, languages, localisedUrlsConfig.map) : pathWithoutLanguage;
|
|
23
|
+
};
|
|
24
|
+
export { canonicalTargetPathname, localiseTargetPathname, stripLanguagePrefix };
|