@bleedingdev/modern-js-i18n-runtime-extensions 3.9.0-ultramodern.9 → 3.9.3-ultramodern.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/cjs/index.js +10 -10
- package/dist/cjs/localeRedirectPolicy.js +6 -6
- package/dist/cjs/native.js +160 -0
- package/dist/cjs/paths.js +11 -3
- package/dist/cjs/requestLanguage.js +195 -0
- package/dist/cjs/router-navigation/adapter.js +93 -0
- package/dist/cjs/{localisedUrls/config.js → router-navigation/index.js} +5 -14
- package/dist/cjs/ssrLanguageHandoff.js +94 -0
- package/dist/cjs/urlRewrite.js +64 -0
- package/dist/esm/index.mjs +1 -1
- package/dist/esm/localeRedirectPolicy.mjs +1 -1
- package/dist/esm/native.mjs +112 -0
- package/dist/esm/paths.mjs +7 -2
- package/dist/esm/requestLanguage.mjs +148 -0
- package/dist/esm/router-navigation/adapter.mjs +55 -0
- package/dist/esm/router-navigation/index.mjs +1 -0
- package/dist/esm/ssrLanguageHandoff.mjs +44 -0
- package/dist/esm/urlRewrite.mjs +26 -0
- package/dist/esm-node/index.mjs +1 -1
- package/dist/esm-node/localeRedirectPolicy.mjs +1 -1
- package/dist/esm-node/native.mjs +113 -0
- package/dist/esm-node/paths.mjs +7 -2
- package/dist/esm-node/requestLanguage.mjs +149 -0
- package/dist/esm-node/router-navigation/adapter.mjs +56 -0
- package/dist/esm-node/router-navigation/index.mjs +2 -0
- package/dist/esm-node/ssrLanguageHandoff.mjs +45 -0
- package/dist/esm-node/urlRewrite.mjs +27 -0
- package/dist/types/index.d.ts +1 -1
- package/dist/types/localeRedirectPolicy.d.ts +1 -1
- package/dist/types/native.d.ts +21 -0
- package/dist/types/paths.d.ts +8 -1
- package/dist/types/requestLanguage.d.ts +60 -0
- package/dist/types/router-navigation/adapter.d.ts +23 -0
- package/dist/types/router-navigation/index.d.ts +1 -0
- package/dist/types/ssrLanguageHandoff.d.ts +60 -0
- package/dist/types/urlRewrite.d.ts +60 -0
- package/package.json +101 -14
- package/src/index.ts +1 -1
- package/src/localeRedirectPolicy.ts +2 -2
- package/src/native.ts +212 -0
- package/src/paths.ts +23 -2
- package/src/requestLanguage.ts +311 -0
- package/src/router-navigation/adapter.tsx +120 -0
- package/src/router-navigation/index.ts +6 -0
- package/src/ssrLanguageHandoff.ts +138 -0
- package/src/urlRewrite.ts +107 -0
- package/dist/cjs/localisedUrls/index.js +0 -98
- package/dist/cjs/localisedUrls/normalise.js +0 -87
- package/dist/cjs/localisedUrls/pathname.js +0 -68
- package/dist/cjs/localisedUrls/patterns.js +0 -214
- package/dist/cjs/localisedUrls/redirect.js +0 -100
- package/dist/cjs/localisedUrls/resolve.js +0 -78
- package/dist/cjs/localisedUrls/routeIdentity.js +0 -118
- package/dist/cjs/localisedUrls/routes.js +0 -210
- package/dist/cjs/localisedUrls/types.js +0 -18
- package/dist/esm/localisedUrls/config.mjs +0 -11
- package/dist/esm/localisedUrls/index.mjs +0 -8
- package/dist/esm/localisedUrls/normalise.mjs +0 -37
- package/dist/esm/localisedUrls/pathname.mjs +0 -24
- package/dist/esm/localisedUrls/patterns.mjs +0 -152
- package/dist/esm/localisedUrls/redirect.mjs +0 -53
- package/dist/esm/localisedUrls/resolve.mjs +0 -37
- package/dist/esm/localisedUrls/routeIdentity.mjs +0 -77
- package/dist/esm/localisedUrls/routes.mjs +0 -169
- package/dist/esm/localisedUrls/types.mjs +0 -0
- package/dist/esm-node/localisedUrls/config.mjs +0 -12
- package/dist/esm-node/localisedUrls/index.mjs +0 -9
- package/dist/esm-node/localisedUrls/normalise.mjs +0 -38
- package/dist/esm-node/localisedUrls/pathname.mjs +0 -25
- package/dist/esm-node/localisedUrls/patterns.mjs +0 -153
- package/dist/esm-node/localisedUrls/redirect.mjs +0 -54
- package/dist/esm-node/localisedUrls/resolve.mjs +0 -38
- package/dist/esm-node/localisedUrls/routeIdentity.mjs +0 -78
- package/dist/esm-node/localisedUrls/routes.mjs +0 -170
- package/dist/esm-node/localisedUrls/types.mjs +0 -1
- package/dist/types/localisedUrls/config.d.ts +0 -9
- package/dist/types/localisedUrls/index.d.ts +0 -10
- package/dist/types/localisedUrls/normalise.d.ts +0 -10
- package/dist/types/localisedUrls/pathname.d.ts +0 -4
- package/dist/types/localisedUrls/patterns.d.ts +0 -11
- package/dist/types/localisedUrls/redirect.d.ts +0 -19
- package/dist/types/localisedUrls/resolve.d.ts +0 -7
- package/dist/types/localisedUrls/routeIdentity.d.ts +0 -24
- package/dist/types/localisedUrls/routes.d.ts +0 -3
- package/dist/types/localisedUrls/types.d.ts +0 -14
- package/rslib.config.mts +0 -4
- package/rstest.config.mts +0 -36
- package/src/localisedUrls/config.ts +0 -18
- package/src/localisedUrls/index.ts +0 -28
- package/src/localisedUrls/normalise.ts +0 -62
- package/src/localisedUrls/pathname.ts +0 -60
- package/src/localisedUrls/patterns.ts +0 -283
- package/src/localisedUrls/redirect.ts +0 -107
- package/src/localisedUrls/resolve.ts +0 -106
- package/src/localisedUrls/routeIdentity.ts +0 -107
- package/src/localisedUrls/routes.ts +0 -432
- package/src/localisedUrls/types.ts +0 -18
- package/tests/languageSyncController.test.ts +0 -136
- package/tests/localeRedirectPolicy.test.ts +0 -173
- package/tests/localisedUrls.test.ts +0 -532
- package/tests/paths.test.ts +0 -24
- package/tests/reactLanguageSync.test.tsx +0 -136
- package/tests/tsconfig.json +0 -11
- package/tsconfig.json +0 -11
|
@@ -0,0 +1,113 @@
|
|
|
1
|
+
import "node:module";
|
|
2
|
+
import i18next from "i18next";
|
|
3
|
+
import { createLatestLanguageSyncBinding } from "./language-sync/controller.mjs";
|
|
4
|
+
import { createRequestLanguageRedirect, languageFromPathname, resolveRequestLanguage } from "./requestLanguage.mjs";
|
|
5
|
+
import { createI18nSsrHandoffInlineData, readI18nSsrHandoff } from "./ssrLanguageHandoff.mjs";
|
|
6
|
+
import { createI18nUrlRewrite } from "./urlRewrite.mjs";
|
|
7
|
+
function unwrapResource(module) {
|
|
8
|
+
const resource = 'object' == typeof module && null !== module && 'default' in module ? module.default : module;
|
|
9
|
+
return resource;
|
|
10
|
+
}
|
|
11
|
+
function createNativeI18n(options, loaders) {
|
|
12
|
+
const { initOptions = {}, ...routing } = options;
|
|
13
|
+
const namespaces = [
|
|
14
|
+
...new Set(Object.values(loaders).flatMap(Object.keys))
|
|
15
|
+
].sort();
|
|
16
|
+
const defaultNamespace = 'string' == typeof initOptions.defaultNS ? initOptions.defaultNS : namespaces.includes('translation') ? 'translation' : namespaces[0] ?? 'translation';
|
|
17
|
+
const cookie = routing.detection?.lookupCookie ?? 'i18next';
|
|
18
|
+
const backend = {
|
|
19
|
+
type: 'backend',
|
|
20
|
+
init () {},
|
|
21
|
+
read (language, namespace, callback) {
|
|
22
|
+
const bundles = Object.hasOwn(loaders, language) ? loaders[language] : void 0;
|
|
23
|
+
const load = bundles && Object.hasOwn(bundles, namespace) ? bundles[namespace] : void 0;
|
|
24
|
+
if (!load) return void callback(null, {});
|
|
25
|
+
load().then((module)=>callback(null, unwrapResource(module)), (error)=>callback(error, false));
|
|
26
|
+
}
|
|
27
|
+
};
|
|
28
|
+
const currentLanguage = ()=>languageFromPathname(window.location.pathname, routing.languages, routing.basePath);
|
|
29
|
+
return {
|
|
30
|
+
languages: routing.languages,
|
|
31
|
+
...void 0 === routing.localisedUrls ? {} : {
|
|
32
|
+
localisedUrls: routing.localisedUrls
|
|
33
|
+
},
|
|
34
|
+
resolveRequest: (request)=>resolveRequestLanguage(request, routing),
|
|
35
|
+
redirect: createRequestLanguageRedirect,
|
|
36
|
+
async create (language, resources) {
|
|
37
|
+
const instance = i18next.createInstance();
|
|
38
|
+
instance.use(backend);
|
|
39
|
+
await instance.init({
|
|
40
|
+
...initOptions,
|
|
41
|
+
lng: language,
|
|
42
|
+
fallbackLng: routing.fallbackLanguage,
|
|
43
|
+
supportedLngs: [
|
|
44
|
+
...routing.languages
|
|
45
|
+
],
|
|
46
|
+
ns: namespaces,
|
|
47
|
+
defaultNS: defaultNamespace,
|
|
48
|
+
partialBundledLanguages: true,
|
|
49
|
+
resources: resources ? {
|
|
50
|
+
[language]: resources
|
|
51
|
+
} : {},
|
|
52
|
+
interpolation: {
|
|
53
|
+
escapeValue: false,
|
|
54
|
+
...initOptions.interpolation
|
|
55
|
+
}
|
|
56
|
+
});
|
|
57
|
+
return instance;
|
|
58
|
+
},
|
|
59
|
+
rewrite: (getLanguage)=>createI18nUrlRewrite({
|
|
60
|
+
languages: routing.languages,
|
|
61
|
+
getLanguage,
|
|
62
|
+
localisedUrls: routing.localisedUrls
|
|
63
|
+
}),
|
|
64
|
+
handoff (language, instance) {
|
|
65
|
+
return createI18nSsrHandoffInlineData({
|
|
66
|
+
language,
|
|
67
|
+
...instance ? {
|
|
68
|
+
resources: Object.fromEntries(namespaces.map((namespace)=>[
|
|
69
|
+
namespace,
|
|
70
|
+
instance.getResourceBundle(language, namespace) ?? {}
|
|
71
|
+
]))
|
|
72
|
+
} : {}
|
|
73
|
+
});
|
|
74
|
+
},
|
|
75
|
+
clientHandoff () {
|
|
76
|
+
const handoff = readI18nSsrHandoff();
|
|
77
|
+
if (handoff && routing.languages.includes(handoff.language)) return handoff;
|
|
78
|
+
return {
|
|
79
|
+
language: currentLanguage() ?? routing.fallbackLanguage
|
|
80
|
+
};
|
|
81
|
+
},
|
|
82
|
+
syncWithRouter (router, instance) {
|
|
83
|
+
const sync = createLatestLanguageSyncBinding();
|
|
84
|
+
sync.updateCallbacks({
|
|
85
|
+
changeLanguage: (target, language)=>target.changeLanguage(language),
|
|
86
|
+
commitLanguage () {},
|
|
87
|
+
readLanguage: (target)=>target.language,
|
|
88
|
+
reportFailure: ()=>window.location.reload()
|
|
89
|
+
});
|
|
90
|
+
sync.activate(instance);
|
|
91
|
+
const unsubscribe = router.subscribe('onBeforeLoad', ({ toLocation })=>{
|
|
92
|
+
const publicHref = toLocation.maskedLocation?.publicHref ?? toLocation.publicHref;
|
|
93
|
+
const pathname = new URL(publicHref, window.location.origin).pathname;
|
|
94
|
+
const language = languageFromPathname(pathname, routing.languages, routing.basePath);
|
|
95
|
+
if (language) sync.request(language);
|
|
96
|
+
});
|
|
97
|
+
return ()=>{
|
|
98
|
+
unsubscribe();
|
|
99
|
+
sync.deactivate();
|
|
100
|
+
};
|
|
101
|
+
},
|
|
102
|
+
persist (instance) {
|
|
103
|
+
const persist = (language)=>{
|
|
104
|
+
document.documentElement.lang = language;
|
|
105
|
+
document.cookie = `${cookie}=${encodeURIComponent(language)}; path=/; max-age=31536000; samesite=lax`;
|
|
106
|
+
};
|
|
107
|
+
if (instance.language) persist(instance.language);
|
|
108
|
+
instance.on('languageChanged', persist);
|
|
109
|
+
return ()=>instance.off('languageChanged', persist);
|
|
110
|
+
}
|
|
111
|
+
};
|
|
112
|
+
}
|
|
113
|
+
export { createNativeI18n };
|
package/dist/esm-node/paths.mjs
CHANGED
|
@@ -1,6 +1,11 @@
|
|
|
1
1
|
import "node:module";
|
|
2
|
+
import { canonicalTargetPathname, localiseTargetPathname } from "@modern-js/runtime-extensions/localised-urls";
|
|
2
3
|
import { splitUrlTarget } from "@modern-js/runtime-utils/url";
|
|
3
|
-
|
|
4
|
+
const configuredLanguage = (requested, languages)=>{
|
|
5
|
+
const language = languages.find((candidate)=>candidate.toLowerCase() === String(requested).toLowerCase());
|
|
6
|
+
if (void 0 === language) throw new RangeError(`Unsupported language "${requested}"; expected one of: ${languages.join(', ')}`);
|
|
7
|
+
return language;
|
|
8
|
+
};
|
|
4
9
|
const buildLocalizedUrl = (target, language, languages, localisedUrls)=>{
|
|
5
10
|
const { pathname, search, hash } = splitUrlTarget(target);
|
|
6
11
|
return `${localiseTargetPathname(pathname, language, languages, localisedUrls)}${search}${hash}`;
|
|
@@ -10,4 +15,4 @@ const canonicalPath = (target, config)=>{
|
|
|
10
15
|
const { pathname, search, hash } = splitUrlTarget(target);
|
|
11
16
|
return `${canonicalTargetPathname(pathname, config.languages, config.localisedUrls)}${search}${hash}`;
|
|
12
17
|
};
|
|
13
|
-
export { buildLocalizedUrl, canonicalPath, localizePath };
|
|
18
|
+
export { buildLocalizedUrl, canonicalPath, configuredLanguage, localizePath };
|
|
@@ -0,0 +1,149 @@
|
|
|
1
|
+
import "node:module";
|
|
2
|
+
import { localiseTargetPathname, shouldSkipLocaleRedirect } from "@modern-js/runtime-extensions/localised-urls";
|
|
3
|
+
const DEFAULT_ORDER = [
|
|
4
|
+
'querystring',
|
|
5
|
+
'cookie',
|
|
6
|
+
'header'
|
|
7
|
+
];
|
|
8
|
+
const normaliseBasePath = (basePath)=>{
|
|
9
|
+
if (!basePath || '/' === basePath) return '';
|
|
10
|
+
return `/${basePath.split('/').filter(Boolean).join('/')}`;
|
|
11
|
+
};
|
|
12
|
+
const entryPathname = (pathname, basePath)=>{
|
|
13
|
+
const base = normaliseBasePath(basePath);
|
|
14
|
+
if (!base) return pathname || '/';
|
|
15
|
+
if (pathname === base) return '/';
|
|
16
|
+
return pathname.startsWith(`${base}/`) ? pathname.slice(base.length) : void 0;
|
|
17
|
+
};
|
|
18
|
+
const baseLanguage = (tag)=>tag.toLowerCase().split(/[-_]/u)[0];
|
|
19
|
+
const compatibleLanguages = (candidate, languages)=>{
|
|
20
|
+
const value = candidate?.trim().toLowerCase();
|
|
21
|
+
if (!value) return [];
|
|
22
|
+
const base = baseLanguage(value);
|
|
23
|
+
return [
|
|
24
|
+
...languages.filter((language)=>language.toLowerCase() === value),
|
|
25
|
+
...languages.filter((language)=>language.toLowerCase() === base),
|
|
26
|
+
...base === value ? languages.filter((language)=>baseLanguage(language) === value) : []
|
|
27
|
+
].filter((language, index, all)=>all.indexOf(language) === index);
|
|
28
|
+
};
|
|
29
|
+
const matchLanguage = (candidate, languages)=>compatibleLanguages(candidate, languages)[0];
|
|
30
|
+
function languageFromPathname(pathname, languages, basePath) {
|
|
31
|
+
const remaining = entryPathname(pathname, basePath);
|
|
32
|
+
if (void 0 === remaining) return;
|
|
33
|
+
const first = remaining.split('/').filter(Boolean)[0];
|
|
34
|
+
if (!first) return;
|
|
35
|
+
return languages.find((language)=>language.toLowerCase() === first.toLowerCase());
|
|
36
|
+
}
|
|
37
|
+
const readCookie = (header, name)=>{
|
|
38
|
+
if (!header) return null;
|
|
39
|
+
for (const part of header.split(';')){
|
|
40
|
+
const separator = part.indexOf('=');
|
|
41
|
+
if (separator < 0) continue;
|
|
42
|
+
if (part.slice(0, separator).trim() !== name) continue;
|
|
43
|
+
const raw = part.slice(separator + 1).trim();
|
|
44
|
+
try {
|
|
45
|
+
return decodeURIComponent(raw);
|
|
46
|
+
} catch {
|
|
47
|
+
return raw;
|
|
48
|
+
}
|
|
49
|
+
}
|
|
50
|
+
return null;
|
|
51
|
+
};
|
|
52
|
+
const QVALUE = /^(?:0(?:\.\d{0,3})?|1(?:\.0{0,3})?)$/;
|
|
53
|
+
const rangeSpecificity = (range, language)=>{
|
|
54
|
+
const value = range.toLowerCase().replaceAll('_', '-');
|
|
55
|
+
const supported = language.toLowerCase().replaceAll('_', '-');
|
|
56
|
+
if ('*' === value) return 0;
|
|
57
|
+
if (value === supported) return Number.MAX_SAFE_INTEGER;
|
|
58
|
+
if (supported.startsWith(`${value}-`)) return 1 + value.split('-').length;
|
|
59
|
+
if (baseLanguage(value) === supported) return 1;
|
|
60
|
+
};
|
|
61
|
+
const acceptedLanguages = (header, languages)=>{
|
|
62
|
+
const items = (header ?? '').split(',').map((item, index)=>{
|
|
63
|
+
const [tag, ...parameters] = item.trim().split(';');
|
|
64
|
+
const quality = parameters.map((parameter)=>parameter.trim()).find((parameter)=>parameter.toLowerCase().startsWith('q='));
|
|
65
|
+
const value = quality?.slice(2).trim();
|
|
66
|
+
const q = void 0 === value ? 1 : QVALUE.test(value) ? Number(value) : 0 / 0;
|
|
67
|
+
return {
|
|
68
|
+
tag: tag.trim(),
|
|
69
|
+
q,
|
|
70
|
+
index
|
|
71
|
+
};
|
|
72
|
+
}).filter((item)=>item.tag && !Number.isNaN(item.q));
|
|
73
|
+
const ranked = languages.flatMap((language, position)=>{
|
|
74
|
+
let best;
|
|
75
|
+
for (const item of items){
|
|
76
|
+
const specificity = rangeSpecificity(item.tag, language);
|
|
77
|
+
if (void 0 !== specificity && (!best || specificity > best.specificity)) best = {
|
|
78
|
+
q: item.q,
|
|
79
|
+
index: item.index,
|
|
80
|
+
specificity
|
|
81
|
+
};
|
|
82
|
+
}
|
|
83
|
+
return best && best.q > 0 ? [
|
|
84
|
+
{
|
|
85
|
+
language,
|
|
86
|
+
position,
|
|
87
|
+
...best
|
|
88
|
+
}
|
|
89
|
+
] : [];
|
|
90
|
+
});
|
|
91
|
+
return ranked.sort((left, right)=>right.q - left.q || left.index - right.index || right.specificity - left.specificity || left.position - right.position).map((entry)=>entry.language);
|
|
92
|
+
};
|
|
93
|
+
function detectRequestLanguage(request, languages, detection = {}) {
|
|
94
|
+
const url = new URL(request.url);
|
|
95
|
+
for (const detector of detection.order ?? DEFAULT_ORDER){
|
|
96
|
+
let language;
|
|
97
|
+
if ('querystring' === detector) language = matchLanguage(url.searchParams.get(detection.lookupQuerystring ?? 'lng'), languages);
|
|
98
|
+
else if ('cookie' === detector) language = matchLanguage(readCookie(request.headers.get('cookie'), detection.lookupCookie ?? 'i18next'), languages);
|
|
99
|
+
else if ('header' === detector) language = acceptedLanguages(request.headers.get(detection.lookupHeader ?? 'accept-language'), languages).find(Boolean);
|
|
100
|
+
if (language) return language;
|
|
101
|
+
}
|
|
102
|
+
}
|
|
103
|
+
function resolveRequestLanguage(request, options) {
|
|
104
|
+
const { languages, fallbackLanguage } = options;
|
|
105
|
+
const url = new URL(request.url);
|
|
106
|
+
const fromPath = languageFromPathname(url.pathname, languages, options.basePath);
|
|
107
|
+
if (fromPath) return {
|
|
108
|
+
kind: 'language',
|
|
109
|
+
language: fromPath
|
|
110
|
+
};
|
|
111
|
+
const detected = (false === options.detect ? void 0 : detectRequestLanguage(request, languages, options.detection)) ?? fallbackLanguage;
|
|
112
|
+
const remaining = entryPathname(url.pathname, options.basePath);
|
|
113
|
+
if (void 0 === remaining || shouldSkipLocaleRedirect(remaining, [
|
|
114
|
+
...languages
|
|
115
|
+
], options.ignoreRedirectRoutes)) {
|
|
116
|
+
const vary = (false === options.detect ? [] : options.detection?.order ?? DEFAULT_ORDER).flatMap((detector)=>'header' === detector ? [
|
|
117
|
+
options.detection?.lookupHeader ?? 'Accept-Language'
|
|
118
|
+
] : 'cookie' === detector ? [
|
|
119
|
+
'Cookie'
|
|
120
|
+
] : []);
|
|
121
|
+
return vary.length ? {
|
|
122
|
+
kind: 'language',
|
|
123
|
+
language: detected,
|
|
124
|
+
vary
|
|
125
|
+
} : {
|
|
126
|
+
kind: 'language',
|
|
127
|
+
language: detected
|
|
128
|
+
};
|
|
129
|
+
}
|
|
130
|
+
const localized = localiseTargetPathname(remaining, detected, [
|
|
131
|
+
...languages
|
|
132
|
+
], options.localisedUrls);
|
|
133
|
+
return {
|
|
134
|
+
kind: 'redirect',
|
|
135
|
+
language: detected,
|
|
136
|
+
location: `${normaliseBasePath(options.basePath)}${localized}${url.search}`
|
|
137
|
+
};
|
|
138
|
+
}
|
|
139
|
+
function createRequestLanguageRedirect(location) {
|
|
140
|
+
return new Response(null, {
|
|
141
|
+
status: 302,
|
|
142
|
+
headers: {
|
|
143
|
+
'cache-control': 'private, no-store',
|
|
144
|
+
location,
|
|
145
|
+
vary: 'Accept-Language, Cookie'
|
|
146
|
+
}
|
|
147
|
+
});
|
|
148
|
+
}
|
|
149
|
+
export { createRequestLanguageRedirect, detectRequestLanguage, languageFromPathname, resolveRequestLanguage };
|
|
@@ -0,0 +1,56 @@
|
|
|
1
|
+
import "node:module";
|
|
2
|
+
import { jsx } from "react/jsx-runtime";
|
|
3
|
+
import { RuntimeContext } from "@modern-js/runtime";
|
|
4
|
+
import { InternalRuntimeContext } from "@modern-js/runtime/context";
|
|
5
|
+
import { getRouterRuntimeState, subscribeRouterRuntimeState } from "@modern-js/runtime-extensions/router-state";
|
|
6
|
+
import { useCallback, useContext, useSyncExternalStore } from "react";
|
|
7
|
+
const subscribeNone = ()=>()=>{};
|
|
8
|
+
const getEmptySnapshot = ()=>null;
|
|
9
|
+
const createI18nRouterNavigation = ({ I18nNavigationProvider, useNativeI18nRouterAdapter })=>{
|
|
10
|
+
const NavigationProvider = I18nNavigationProvider;
|
|
11
|
+
const useIntegratedRouterAdapter = ()=>{
|
|
12
|
+
const runtimeContext = useContext(RuntimeContext);
|
|
13
|
+
const internalContext = useContext(InternalRuntimeContext);
|
|
14
|
+
const native = useNativeI18nRouterAdapter();
|
|
15
|
+
const getCapability = useCallback(()=>getRouterRuntimeState(internalContext)?.navigation ?? getRouterRuntimeState(runtimeContext)?.navigation, [
|
|
16
|
+
internalContext,
|
|
17
|
+
runtimeContext
|
|
18
|
+
]);
|
|
19
|
+
const subscribeCapability = useCallback((update)=>{
|
|
20
|
+
const stopInternal = subscribeRouterRuntimeState(internalContext, update);
|
|
21
|
+
const stopPublic = internalContext === runtimeContext ? void 0 : subscribeRouterRuntimeState(runtimeContext, update);
|
|
22
|
+
return ()=>{
|
|
23
|
+
stopInternal();
|
|
24
|
+
stopPublic?.();
|
|
25
|
+
};
|
|
26
|
+
}, [
|
|
27
|
+
internalContext,
|
|
28
|
+
runtimeContext
|
|
29
|
+
]);
|
|
30
|
+
const capability = useSyncExternalStore(subscribeCapability, getCapability, getCapability);
|
|
31
|
+
const snapshot = useSyncExternalStore(capability?.subscribe ?? subscribeNone, capability?.getSnapshot ?? getEmptySnapshot, capability?.getSnapshot ?? getEmptySnapshot);
|
|
32
|
+
if (native.hasRouter || !capability || !snapshot) return native;
|
|
33
|
+
const internalState = getRouterRuntimeState(internalContext);
|
|
34
|
+
return {
|
|
35
|
+
framework: internalState?.navigation === capability ? internalState.framework : getRouterRuntimeState(runtimeContext)?.framework,
|
|
36
|
+
hasRouter: true,
|
|
37
|
+
...snapshot,
|
|
38
|
+
navigate: capability.navigate,
|
|
39
|
+
Link: capability.Link,
|
|
40
|
+
createLinkProps: capability.createLinkProps
|
|
41
|
+
};
|
|
42
|
+
};
|
|
43
|
+
const I18nRouterNavigationProvider = ({ children })=>{
|
|
44
|
+
const value = useIntegratedRouterAdapter();
|
|
45
|
+
const native = useNativeI18nRouterAdapter();
|
|
46
|
+
return /*#__PURE__*/ jsx(NavigationProvider, {
|
|
47
|
+
value: value.hasRouter && !native.hasRouter ? value : null,
|
|
48
|
+
children: children
|
|
49
|
+
});
|
|
50
|
+
};
|
|
51
|
+
return {
|
|
52
|
+
useIntegratedRouterAdapter,
|
|
53
|
+
I18nRouterNavigationProvider
|
|
54
|
+
};
|
|
55
|
+
};
|
|
56
|
+
export { createI18nRouterNavigation };
|
|
@@ -0,0 +1,45 @@
|
|
|
1
|
+
import "node:module";
|
|
2
|
+
import { parsePublicData, serializePublicData } from "@modern-js/renderer-core/data";
|
|
3
|
+
import { serializeInlineData } from "@modern-js/renderer-core/document";
|
|
4
|
+
const I18N_SSR_HANDOFF_ELEMENT_ID = '__modernjs_i18n_ssr__';
|
|
5
|
+
function assertPayload(payload) {
|
|
6
|
+
if ('string' != typeof payload.language || 0 === payload.language.length) throw new TypeError('An i18n SSR handoff payload requires a nonempty language.');
|
|
7
|
+
if (void 0 !== payload.resources && ('object' != typeof payload.resources || null === payload.resources)) throw new TypeError('An i18n SSR handoff payload resources field must be a plain object.');
|
|
8
|
+
}
|
|
9
|
+
function createI18nSsrHandoffInlineData(payload, options = {}) {
|
|
10
|
+
assertPayload(payload);
|
|
11
|
+
return {
|
|
12
|
+
id: options.id ?? I18N_SSR_HANDOFF_ELEMENT_ID,
|
|
13
|
+
payload: serializePublicData({
|
|
14
|
+
language: payload.language,
|
|
15
|
+
...payload.resources ? {
|
|
16
|
+
resources: payload.resources
|
|
17
|
+
} : {}
|
|
18
|
+
})
|
|
19
|
+
};
|
|
20
|
+
}
|
|
21
|
+
function serializeI18nSsrHandoff(payload, options = {}) {
|
|
22
|
+
return serializeInlineData({
|
|
23
|
+
...createI18nSsrHandoffInlineData(payload, options),
|
|
24
|
+
nonce: options.nonce
|
|
25
|
+
});
|
|
26
|
+
}
|
|
27
|
+
function isHandoffPayload(value) {
|
|
28
|
+
return Boolean(value) && 'object' == typeof value && 'string' == typeof value.language;
|
|
29
|
+
}
|
|
30
|
+
function readI18nSsrHandoffFromText(text) {
|
|
31
|
+
if (!text) return;
|
|
32
|
+
try {
|
|
33
|
+
const result = parsePublicData(text);
|
|
34
|
+
return isHandoffPayload(result) ? result : void 0;
|
|
35
|
+
} catch {
|
|
36
|
+
return;
|
|
37
|
+
}
|
|
38
|
+
}
|
|
39
|
+
function readI18nSsrHandoff(options = {}) {
|
|
40
|
+
const doc = options.doc ?? ("u" < typeof document ? void 0 : document);
|
|
41
|
+
if (!doc) return;
|
|
42
|
+
const element = doc.getElementById(options.id ?? I18N_SSR_HANDOFF_ELEMENT_ID);
|
|
43
|
+
return readI18nSsrHandoffFromText(element?.textContent ?? null);
|
|
44
|
+
}
|
|
45
|
+
export { I18N_SSR_HANDOFF_ELEMENT_ID, createI18nSsrHandoffInlineData, readI18nSsrHandoff, readI18nSsrHandoffFromText, serializeI18nSsrHandoff };
|
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
import "node:module";
|
|
2
|
+
import { canonicalTargetPathname, localiseTargetPathname } from "@modern-js/runtime-extensions/localised-urls";
|
|
3
|
+
import { languageFromPathname } from "./requestLanguage.mjs";
|
|
4
|
+
const createI18nUrlRewrite = (options)=>{
|
|
5
|
+
const { languages, getLanguage, localisedUrls, onLanguageDetected } = options;
|
|
6
|
+
const languageList = [
|
|
7
|
+
...languages
|
|
8
|
+
];
|
|
9
|
+
return {
|
|
10
|
+
input: ({ url })=>{
|
|
11
|
+
const pathname = url.pathname;
|
|
12
|
+
if (onLanguageDetected) {
|
|
13
|
+
const segments = pathname.split('/').filter(Boolean);
|
|
14
|
+
const firstSegment = segments[0];
|
|
15
|
+
const detected = firstSegment ? languageList.find((language)=>language.toLowerCase() === firstSegment.toLowerCase()) : void 0;
|
|
16
|
+
if (detected) onLanguageDetected(detected);
|
|
17
|
+
}
|
|
18
|
+
url.pathname = canonicalTargetPathname(pathname, languageList, localisedUrls);
|
|
19
|
+
return url;
|
|
20
|
+
},
|
|
21
|
+
output: ({ url })=>{
|
|
22
|
+
url.pathname = localiseTargetPathname(url.pathname, languageFromPathname(url.pathname, languageList) ?? getLanguage(), languageList, localisedUrls);
|
|
23
|
+
return url;
|
|
24
|
+
}
|
|
25
|
+
};
|
|
26
|
+
};
|
|
27
|
+
export { createI18nUrlRewrite };
|
package/dist/types/index.d.ts
CHANGED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
import type { NativeEntryI18n } from '@modern-js/renderer-core/entry-server';
|
|
2
|
+
import type { LocalisedUrlsOption } from '@modern-js/runtime-extensions/localised-urls';
|
|
3
|
+
import { type i18n as I18nInstance, type InitOptions } from 'i18next';
|
|
4
|
+
import { type ResolveRequestLanguageOptions } from './requestLanguage.js';
|
|
5
|
+
export type { I18nInstance };
|
|
6
|
+
export interface NativeI18nOptions extends ResolveRequestLanguageOptions {
|
|
7
|
+
/**
|
|
8
|
+
* JSON-serializable i18next `init` options. `lng`, `fallbackLng`,
|
|
9
|
+
* `supportedLngs`, `resources` and `ns` are owned by the native entry.
|
|
10
|
+
*/
|
|
11
|
+
initOptions?: InitOptions;
|
|
12
|
+
}
|
|
13
|
+
/** language -> namespace -> lazily imported translation bundle. */
|
|
14
|
+
export type NativeI18nLoaders = Readonly<Record<string, Readonly<Record<string, () => Promise<unknown>>>>>;
|
|
15
|
+
export type NativeI18n = NativeEntryI18n<I18nInstance, LocalisedUrlsOption>;
|
|
16
|
+
/**
|
|
17
|
+
* The localized routing and per-request i18next instances of a native
|
|
18
|
+
* Solid or Octane entry. Each language's bundles load on demand; an instance
|
|
19
|
+
* is never a module singleton.
|
|
20
|
+
*/
|
|
21
|
+
export declare function createNativeI18n(options: NativeI18nOptions, loaders: NativeI18nLoaders): NativeI18n;
|
package/dist/types/paths.d.ts
CHANGED
|
@@ -1,8 +1,15 @@
|
|
|
1
|
-
import { type LocalisedUrlsOption } from '
|
|
1
|
+
import { type LocalisedUrlsOption } from '@modern-js/runtime-extensions/localised-urls';
|
|
2
|
+
export type { LocalisedUrlsOption };
|
|
2
3
|
export interface LocalizedPathsConfig {
|
|
3
4
|
languages: string[];
|
|
4
5
|
localisedUrls?: LocalisedUrlsOption;
|
|
5
6
|
}
|
|
7
|
+
/**
|
|
8
|
+
* The configured spelling of a requested language, matched case-insensitively.
|
|
9
|
+
* Only a configured language has a locale prefix, so anything else throws
|
|
10
|
+
* rather than becoming an unknown path segment.
|
|
11
|
+
*/
|
|
12
|
+
export declare const configuredLanguage: (requested: string, languages: readonly string[]) => string;
|
|
6
13
|
/** Synchronous mapped URL construction, independent of React/plugin setup. */
|
|
7
14
|
export declare const buildLocalizedUrl: (target: string, language: string, languages: string[], localisedUrls?: LocalisedUrlsOption) => string;
|
|
8
15
|
export declare const localizePath: (target: string, language: string, config: LocalizedPathsConfig) => string;
|
|
@@ -0,0 +1,60 @@
|
|
|
1
|
+
import { type LocalisedUrlsOption } from '@modern-js/runtime-extensions/localised-urls';
|
|
2
|
+
/**
|
|
3
|
+
* Renderer-neutral request language resolution for path-prefixed locales.
|
|
4
|
+
* It is the Fetch-native counterpart of `@modern-js/plugin-i18n`'s Hono
|
|
5
|
+
* language-detector + redirect middleware pair: a native renderer's request
|
|
6
|
+
* handler calls it with the standard `Request`, so it needs neither Hono nor
|
|
7
|
+
* React and runs unchanged on Node and in workers.
|
|
8
|
+
*/
|
|
9
|
+
export type RequestLanguageDetector = 'querystring' | 'cookie' | 'header';
|
|
10
|
+
export interface RequestLanguageDetection {
|
|
11
|
+
/** @default ['querystring', 'cookie', 'header'] */
|
|
12
|
+
order?: readonly RequestLanguageDetector[];
|
|
13
|
+
/** @default 'lng' */
|
|
14
|
+
lookupQuerystring?: string;
|
|
15
|
+
/** @default 'i18next' */
|
|
16
|
+
lookupCookie?: string;
|
|
17
|
+
/** @default 'accept-language' */
|
|
18
|
+
lookupHeader?: string;
|
|
19
|
+
}
|
|
20
|
+
export interface ResolveRequestLanguageOptions {
|
|
21
|
+
languages: readonly string[];
|
|
22
|
+
fallbackLanguage: string;
|
|
23
|
+
/** Public path the entry is mounted under. @default '/' */
|
|
24
|
+
basePath?: string;
|
|
25
|
+
localisedUrls?: LocalisedUrlsOption;
|
|
26
|
+
/**
|
|
27
|
+
* Detect the language of an unprefixed URL from the query string, cookie
|
|
28
|
+
* and `Accept-Language`. When false the fallback language is used.
|
|
29
|
+
* @default true
|
|
30
|
+
*/
|
|
31
|
+
detect?: boolean;
|
|
32
|
+
detection?: RequestLanguageDetection;
|
|
33
|
+
/** Canonical paths that are served without a language prefix. */
|
|
34
|
+
ignoreRedirectRoutes?: readonly string[] | ((pathname: string) => boolean);
|
|
35
|
+
}
|
|
36
|
+
export type ResolvedRequestLanguage = {
|
|
37
|
+
kind: 'language';
|
|
38
|
+
language: string;
|
|
39
|
+
vary?: readonly string[];
|
|
40
|
+
} | {
|
|
41
|
+
kind: 'redirect';
|
|
42
|
+
language: string;
|
|
43
|
+
location: string;
|
|
44
|
+
};
|
|
45
|
+
/** The supported language carried by a public pathname's first segment. */
|
|
46
|
+
export declare function languageFromPathname(pathname: string, languages: readonly string[], basePath?: string): string | undefined;
|
|
47
|
+
/**
|
|
48
|
+
* Detect a supported language from the request alone (no path), in the
|
|
49
|
+
* configured detector order. Returns `undefined` when nothing matches.
|
|
50
|
+
*/
|
|
51
|
+
export declare function detectRequestLanguage(request: Request, languages: readonly string[], detection?: RequestLanguageDetection): string | undefined;
|
|
52
|
+
/**
|
|
53
|
+
* Resolve the language a request renders in. A URL that already carries a
|
|
54
|
+
* supported language prefix renders in it; an unprefixed page URL is
|
|
55
|
+
* redirected to the detected (or fallback) language's localized URL, keeping
|
|
56
|
+
* search and the base path, unless it is an ignored or framework-owned path.
|
|
57
|
+
*/
|
|
58
|
+
export declare function resolveRequestLanguage(request: Request, options: ResolveRequestLanguageOptions): ResolvedRequestLanguage;
|
|
59
|
+
/** The same uncacheable 302 the React server middleware answers with. */
|
|
60
|
+
export declare function createRequestLanguageRedirect(location: string): Response;
|
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
import { type RouterLinkTarget, type RouterNavigationCapability, type RouterNavigationSnapshot } from '@modern-js/runtime-extensions/router-state';
|
|
2
|
+
import type React from 'react';
|
|
3
|
+
export interface I18nRouterNavigationDependencies<Adapter extends I18nRouterAdapterShape> {
|
|
4
|
+
I18nNavigationProvider: React.ComponentType<React.PropsWithChildren<{
|
|
5
|
+
value: Adapter;
|
|
6
|
+
}>>;
|
|
7
|
+
useNativeI18nRouterAdapter: () => Adapter;
|
|
8
|
+
}
|
|
9
|
+
export type I18nRouterLinkTarget = RouterLinkTarget;
|
|
10
|
+
export interface I18nRouterAdapterShape {
|
|
11
|
+
framework?: string;
|
|
12
|
+
hasRouter: boolean;
|
|
13
|
+
location: RouterNavigationSnapshot['location'] | null;
|
|
14
|
+
navigate: RouterNavigationCapability['navigate'] | null;
|
|
15
|
+
Link: RouterNavigationCapability['Link'] | null;
|
|
16
|
+
params: Record<string, string>;
|
|
17
|
+
createLinkProps?: RouterNavigationCapability['createLinkProps'];
|
|
18
|
+
}
|
|
19
|
+
/** The router provider owns navigation; this seam only observes its capability. */
|
|
20
|
+
export declare const createI18nRouterNavigation: <Adapter extends I18nRouterAdapterShape>({ I18nNavigationProvider, useNativeI18nRouterAdapter, }: I18nRouterNavigationDependencies<Adapter>) => {
|
|
21
|
+
useIntegratedRouterAdapter: () => Adapter;
|
|
22
|
+
I18nRouterNavigationProvider: ({ children, }: React.PropsWithChildren) => React.JSX.Element;
|
|
23
|
+
};
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export { createI18nRouterNavigation, type I18nRouterAdapterShape, type I18nRouterLinkTarget, type I18nRouterNavigationDependencies, } from './adapter.js';
|
|
@@ -0,0 +1,60 @@
|
|
|
1
|
+
import { serializePublicData } from '@modern-js/renderer-core/data';
|
|
2
|
+
/**
|
|
3
|
+
* Renderer-neutral replacement for React's `window._SSR_DATA.data.i18nData`
|
|
4
|
+
* handoff (see `plugin-i18n/src/runtime/i18n/detection/ssr.ts` and
|
|
5
|
+
* `pluginSetup.ts`'s `exportServerLngToWindow`). That path only exists
|
|
6
|
+
* because the React renderer serializes its own `_SSR_DATA` global; native
|
|
7
|
+
* renderers have no equivalent global, so the language (and an optional
|
|
8
|
+
* resource subset, for first-paint translation without a network round
|
|
9
|
+
* trip) travels in its own inline `<script type="application/json">`
|
|
10
|
+
* element instead, using renderer-core's public-data codec — the same
|
|
11
|
+
* XSS-safe escaping and size/depth limits used for loader/action data.
|
|
12
|
+
*/
|
|
13
|
+
export declare const I18N_SSR_HANDOFF_ELEMENT_ID = "__modernjs_i18n_ssr__";
|
|
14
|
+
/** `{ [namespace]: { [key]: value } }`, scoped to a single language. */
|
|
15
|
+
export type I18nSsrHandoffResources = Record<string, Record<string, unknown>>;
|
|
16
|
+
export interface I18nSsrHandoffPayload {
|
|
17
|
+
/** The language the server resolved for this request. */
|
|
18
|
+
language: string;
|
|
19
|
+
/** Optional subset of resources for `language`, keyed by namespace. */
|
|
20
|
+
resources?: I18nSsrHandoffResources;
|
|
21
|
+
}
|
|
22
|
+
export interface SerializeI18nSsrHandoffOptions {
|
|
23
|
+
/** @default I18N_SSR_HANDOFF_ELEMENT_ID */
|
|
24
|
+
id?: string;
|
|
25
|
+
nonce?: string;
|
|
26
|
+
}
|
|
27
|
+
/** An inline data element a native document serializes with its own nonce. */
|
|
28
|
+
export interface I18nSsrHandoffInlineData {
|
|
29
|
+
readonly id: string;
|
|
30
|
+
readonly payload: ReturnType<typeof serializePublicData>;
|
|
31
|
+
}
|
|
32
|
+
/**
|
|
33
|
+
* Encode the handoff as `{ id, payload }` for a native document's
|
|
34
|
+
* `inlineData` option, which places it before the client modules and stamps
|
|
35
|
+
* it with the document's CSP nonce.
|
|
36
|
+
*/
|
|
37
|
+
export declare function createI18nSsrHandoffInlineData(payload: I18nSsrHandoffPayload, options?: Pick<SerializeI18nSsrHandoffOptions, 'id'>): I18nSsrHandoffInlineData;
|
|
38
|
+
/**
|
|
39
|
+
* Serialize the language/resources handoff into a complete, inert
|
|
40
|
+
* `<script type="application/json">` element string. The native renderer's
|
|
41
|
+
* document composition decides where to place it (before any module that
|
|
42
|
+
* reads it on the client).
|
|
43
|
+
*/
|
|
44
|
+
export declare function serializeI18nSsrHandoff(payload: I18nSsrHandoffPayload, options?: SerializeI18nSsrHandoffOptions): string;
|
|
45
|
+
/**
|
|
46
|
+
* Decode a handoff payload from the raw text content of the element
|
|
47
|
+
* `serializeI18nSsrHandoff` produced. Pure — no DOM access — so it can be
|
|
48
|
+
* unit tested or used from a non-browser client runtime directly.
|
|
49
|
+
* Returns `undefined` for missing, malformed or oversized input rather than
|
|
50
|
+
* throwing, since a corrupted handoff should fall back to client detection.
|
|
51
|
+
*/
|
|
52
|
+
export declare function readI18nSsrHandoffFromText(text: string | null | undefined): I18nSsrHandoffPayload | undefined;
|
|
53
|
+
/**
|
|
54
|
+
* Browser convenience wrapper: reads the handoff element from `document` (or
|
|
55
|
+
* a supplied document-like object) by id and decodes it.
|
|
56
|
+
*/
|
|
57
|
+
export declare function readI18nSsrHandoff(options?: {
|
|
58
|
+
id?: string;
|
|
59
|
+
doc?: Pick<Document, 'getElementById'>;
|
|
60
|
+
}): I18nSsrHandoffPayload | undefined;
|