@bleedingdev/modern-js-i18n-runtime-extensions 3.9.0-ultramodern.9 → 3.9.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.
Files changed (104) hide show
  1. package/dist/cjs/index.js +10 -10
  2. package/dist/cjs/localeRedirectPolicy.js +6 -6
  3. package/dist/cjs/native.js +160 -0
  4. package/dist/cjs/paths.js +11 -3
  5. package/dist/cjs/requestLanguage.js +195 -0
  6. package/dist/cjs/router-navigation/adapter.js +93 -0
  7. package/dist/cjs/{localisedUrls/config.js → router-navigation/index.js} +5 -14
  8. package/dist/cjs/ssrLanguageHandoff.js +94 -0
  9. package/dist/cjs/urlRewrite.js +64 -0
  10. package/dist/esm/index.mjs +1 -1
  11. package/dist/esm/localeRedirectPolicy.mjs +1 -1
  12. package/dist/esm/native.mjs +112 -0
  13. package/dist/esm/paths.mjs +7 -2
  14. package/dist/esm/requestLanguage.mjs +148 -0
  15. package/dist/esm/router-navigation/adapter.mjs +55 -0
  16. package/dist/esm/router-navigation/index.mjs +1 -0
  17. package/dist/esm/ssrLanguageHandoff.mjs +44 -0
  18. package/dist/esm/urlRewrite.mjs +26 -0
  19. package/dist/esm-node/index.mjs +1 -1
  20. package/dist/esm-node/localeRedirectPolicy.mjs +1 -1
  21. package/dist/esm-node/native.mjs +113 -0
  22. package/dist/esm-node/paths.mjs +7 -2
  23. package/dist/esm-node/requestLanguage.mjs +149 -0
  24. package/dist/esm-node/router-navigation/adapter.mjs +56 -0
  25. package/dist/esm-node/router-navigation/index.mjs +2 -0
  26. package/dist/esm-node/ssrLanguageHandoff.mjs +45 -0
  27. package/dist/esm-node/urlRewrite.mjs +27 -0
  28. package/dist/types/index.d.ts +1 -1
  29. package/dist/types/localeRedirectPolicy.d.ts +1 -1
  30. package/dist/types/native.d.ts +21 -0
  31. package/dist/types/paths.d.ts +8 -1
  32. package/dist/types/requestLanguage.d.ts +60 -0
  33. package/dist/types/router-navigation/adapter.d.ts +23 -0
  34. package/dist/types/router-navigation/index.d.ts +1 -0
  35. package/dist/types/ssrLanguageHandoff.d.ts +60 -0
  36. package/dist/types/urlRewrite.d.ts +60 -0
  37. package/package.json +101 -14
  38. package/src/index.ts +1 -1
  39. package/src/localeRedirectPolicy.ts +2 -2
  40. package/src/native.ts +212 -0
  41. package/src/paths.ts +23 -2
  42. package/src/requestLanguage.ts +311 -0
  43. package/src/router-navigation/adapter.tsx +120 -0
  44. package/src/router-navigation/index.ts +6 -0
  45. package/src/ssrLanguageHandoff.ts +138 -0
  46. package/src/urlRewrite.ts +107 -0
  47. package/dist/cjs/localisedUrls/index.js +0 -98
  48. package/dist/cjs/localisedUrls/normalise.js +0 -87
  49. package/dist/cjs/localisedUrls/pathname.js +0 -68
  50. package/dist/cjs/localisedUrls/patterns.js +0 -214
  51. package/dist/cjs/localisedUrls/redirect.js +0 -100
  52. package/dist/cjs/localisedUrls/resolve.js +0 -78
  53. package/dist/cjs/localisedUrls/routeIdentity.js +0 -118
  54. package/dist/cjs/localisedUrls/routes.js +0 -210
  55. package/dist/cjs/localisedUrls/types.js +0 -18
  56. package/dist/esm/localisedUrls/config.mjs +0 -11
  57. package/dist/esm/localisedUrls/index.mjs +0 -8
  58. package/dist/esm/localisedUrls/normalise.mjs +0 -37
  59. package/dist/esm/localisedUrls/pathname.mjs +0 -24
  60. package/dist/esm/localisedUrls/patterns.mjs +0 -152
  61. package/dist/esm/localisedUrls/redirect.mjs +0 -53
  62. package/dist/esm/localisedUrls/resolve.mjs +0 -37
  63. package/dist/esm/localisedUrls/routeIdentity.mjs +0 -77
  64. package/dist/esm/localisedUrls/routes.mjs +0 -169
  65. package/dist/esm/localisedUrls/types.mjs +0 -0
  66. package/dist/esm-node/localisedUrls/config.mjs +0 -12
  67. package/dist/esm-node/localisedUrls/index.mjs +0 -9
  68. package/dist/esm-node/localisedUrls/normalise.mjs +0 -38
  69. package/dist/esm-node/localisedUrls/pathname.mjs +0 -25
  70. package/dist/esm-node/localisedUrls/patterns.mjs +0 -153
  71. package/dist/esm-node/localisedUrls/redirect.mjs +0 -54
  72. package/dist/esm-node/localisedUrls/resolve.mjs +0 -38
  73. package/dist/esm-node/localisedUrls/routeIdentity.mjs +0 -78
  74. package/dist/esm-node/localisedUrls/routes.mjs +0 -170
  75. package/dist/esm-node/localisedUrls/types.mjs +0 -1
  76. package/dist/types/localisedUrls/config.d.ts +0 -9
  77. package/dist/types/localisedUrls/index.d.ts +0 -10
  78. package/dist/types/localisedUrls/normalise.d.ts +0 -10
  79. package/dist/types/localisedUrls/pathname.d.ts +0 -4
  80. package/dist/types/localisedUrls/patterns.d.ts +0 -11
  81. package/dist/types/localisedUrls/redirect.d.ts +0 -19
  82. package/dist/types/localisedUrls/resolve.d.ts +0 -7
  83. package/dist/types/localisedUrls/routeIdentity.d.ts +0 -24
  84. package/dist/types/localisedUrls/routes.d.ts +0 -3
  85. package/dist/types/localisedUrls/types.d.ts +0 -14
  86. package/rslib.config.mts +0 -4
  87. package/rstest.config.mts +0 -36
  88. package/src/localisedUrls/config.ts +0 -18
  89. package/src/localisedUrls/index.ts +0 -28
  90. package/src/localisedUrls/normalise.ts +0 -62
  91. package/src/localisedUrls/pathname.ts +0 -60
  92. package/src/localisedUrls/patterns.ts +0 -283
  93. package/src/localisedUrls/redirect.ts +0 -107
  94. package/src/localisedUrls/resolve.ts +0 -106
  95. package/src/localisedUrls/routeIdentity.ts +0 -107
  96. package/src/localisedUrls/routes.ts +0 -432
  97. package/src/localisedUrls/types.ts +0 -18
  98. package/tests/languageSyncController.test.ts +0 -136
  99. package/tests/localeRedirectPolicy.test.ts +0 -173
  100. package/tests/localisedUrls.test.ts +0 -532
  101. package/tests/paths.test.ts +0 -24
  102. package/tests/reactLanguageSync.test.tsx +0 -136
  103. package/tests/tsconfig.json +0 -11
  104. package/tsconfig.json +0 -11
@@ -0,0 +1,311 @@
1
+ import {
2
+ type LocalisedUrlsOption,
3
+ localiseTargetPathname,
4
+ shouldSkipLocaleRedirect,
5
+ } from '@modern-js/runtime-extensions/localised-urls';
6
+
7
+ /**
8
+ * Renderer-neutral request language resolution for path-prefixed locales.
9
+ * It is the Fetch-native counterpart of `@modern-js/plugin-i18n`'s Hono
10
+ * language-detector + redirect middleware pair: a native renderer's request
11
+ * handler calls it with the standard `Request`, so it needs neither Hono nor
12
+ * React and runs unchanged on Node and in workers.
13
+ */
14
+ export type RequestLanguageDetector = 'querystring' | 'cookie' | 'header';
15
+
16
+ export interface RequestLanguageDetection {
17
+ /** @default ['querystring', 'cookie', 'header'] */
18
+ order?: readonly RequestLanguageDetector[];
19
+ /** @default 'lng' */
20
+ lookupQuerystring?: string;
21
+ /** @default 'i18next' */
22
+ lookupCookie?: string;
23
+ /** @default 'accept-language' */
24
+ lookupHeader?: string;
25
+ }
26
+
27
+ export interface ResolveRequestLanguageOptions {
28
+ languages: readonly string[];
29
+ fallbackLanguage: string;
30
+ /** Public path the entry is mounted under. @default '/' */
31
+ basePath?: string;
32
+ localisedUrls?: LocalisedUrlsOption;
33
+ /**
34
+ * Detect the language of an unprefixed URL from the query string, cookie
35
+ * and `Accept-Language`. When false the fallback language is used.
36
+ * @default true
37
+ */
38
+ detect?: boolean;
39
+ detection?: RequestLanguageDetection;
40
+ /** Canonical paths that are served without a language prefix. */
41
+ ignoreRedirectRoutes?: readonly string[] | ((pathname: string) => boolean);
42
+ }
43
+
44
+ export type ResolvedRequestLanguage =
45
+ | { kind: 'language'; language: string; vary?: readonly string[] }
46
+ | { kind: 'redirect'; language: string; location: string };
47
+
48
+ const DEFAULT_ORDER: readonly RequestLanguageDetector[] = [
49
+ 'querystring',
50
+ 'cookie',
51
+ 'header',
52
+ ];
53
+
54
+ const normaliseBasePath = (basePath: string | undefined): string => {
55
+ if (!basePath || basePath === '/') return '';
56
+ return `/${basePath.split('/').filter(Boolean).join('/')}`;
57
+ };
58
+
59
+ /** The pathname below the entry base path, or `undefined` outside it. */
60
+ const entryPathname = (
61
+ pathname: string,
62
+ basePath: string | undefined,
63
+ ): string | undefined => {
64
+ const base = normaliseBasePath(basePath);
65
+ if (!base) return pathname || '/';
66
+ if (pathname === base) return '/';
67
+ return pathname.startsWith(`${base}/`)
68
+ ? pathname.slice(base.length)
69
+ : undefined;
70
+ };
71
+
72
+ const baseLanguage = (tag: string) => tag.toLowerCase().split(/[-_]/u)[0];
73
+
74
+ /**
75
+ * Supported languages a range covers, best first: the exact tag, the range's
76
+ * base language (`cs-CZ` -> `cs`), then regional languages under a base range
77
+ * (`en` -> `en-US`, `en-GB`).
78
+ */
79
+ const compatibleLanguages = (
80
+ candidate: string | null | undefined,
81
+ languages: readonly string[],
82
+ ): string[] => {
83
+ const value = candidate?.trim().toLowerCase();
84
+ if (!value) return [];
85
+ const base = baseLanguage(value);
86
+ return [
87
+ ...languages.filter(language => language.toLowerCase() === value),
88
+ ...languages.filter(language => language.toLowerCase() === base),
89
+ ...(base === value
90
+ ? languages.filter(language => baseLanguage(language) === value)
91
+ : []),
92
+ ].filter((language, index, all) => all.indexOf(language) === index);
93
+ };
94
+
95
+ const matchLanguage = (
96
+ candidate: string | null | undefined,
97
+ languages: readonly string[],
98
+ ): string | undefined => compatibleLanguages(candidate, languages)[0];
99
+
100
+ /** The supported language carried by a public pathname's first segment. */
101
+ export function languageFromPathname(
102
+ pathname: string,
103
+ languages: readonly string[],
104
+ basePath?: string,
105
+ ): string | undefined {
106
+ const remaining = entryPathname(pathname, basePath);
107
+ if (remaining === undefined) return undefined;
108
+ const first = remaining.split('/').filter(Boolean)[0];
109
+ if (!first) return undefined;
110
+ return languages.find(
111
+ language => language.toLowerCase() === first.toLowerCase(),
112
+ );
113
+ }
114
+
115
+ const readCookie = (header: string | null, name: string): string | null => {
116
+ if (!header) return null;
117
+ for (const part of header.split(';')) {
118
+ const separator = part.indexOf('=');
119
+ if (separator < 0) continue;
120
+ if (part.slice(0, separator).trim() !== name) continue;
121
+ const raw = part.slice(separator + 1).trim();
122
+ try {
123
+ return decodeURIComponent(raw);
124
+ } catch {
125
+ return raw;
126
+ }
127
+ }
128
+ return null;
129
+ };
130
+
131
+ const QVALUE = /^(?:0(?:\.\d{0,3})?|1(?:\.0{0,3})?)$/;
132
+
133
+ /**
134
+ * How specifically a range covers a supported language: the exact tag, then
135
+ * longer prefix ranges (`en` for `en-US`), then a regional range naming the
136
+ * base language (`cs-CZ` for `cs`), then `*`. `undefined` when it does not.
137
+ */
138
+ const rangeSpecificity = (
139
+ range: string,
140
+ language: string,
141
+ ): number | undefined => {
142
+ const value = range.toLowerCase().replaceAll('_', '-');
143
+ const supported = language.toLowerCase().replaceAll('_', '-');
144
+ if (value === '*') return 0;
145
+ if (value === supported) return Number.MAX_SAFE_INTEGER;
146
+ if (supported.startsWith(`${value}-`)) return 1 + value.split('-').length;
147
+ if (baseLanguage(value) === supported) return 1;
148
+ return undefined;
149
+ };
150
+
151
+ /**
152
+ * Supported languages in `Accept-Language` order, highest quality first. A
153
+ * language's quality comes from the most specific range covering it, and `*`
154
+ * covers every language no other range names.
155
+ */
156
+ const acceptedLanguages = (
157
+ header: string | null,
158
+ languages: readonly string[],
159
+ ): string[] => {
160
+ const items = (header ?? '')
161
+ .split(',')
162
+ .map((item, index) => {
163
+ const [tag, ...parameters] = item.trim().split(';');
164
+ const quality = parameters
165
+ .map(parameter => parameter.trim())
166
+ .find(parameter => parameter.toLowerCase().startsWith('q='));
167
+ // RFC 9110 qvalue: 0 to 1 with at most three decimals. A malformed
168
+ // weight drops the item, so it neither ranks nor shadows other ranges.
169
+ const value = quality?.slice(2).trim();
170
+ const q =
171
+ value === undefined ? 1 : QVALUE.test(value) ? Number(value) : NaN;
172
+ return { tag: tag.trim(), q, index };
173
+ })
174
+ .filter(item => item.tag && !Number.isNaN(item.q));
175
+ // Each supported language takes the quality of its most specific range, so
176
+ // `en;q=0, en-US` keeps en-US while excluding the other English locales.
177
+ const ranked = languages.flatMap((language, position) => {
178
+ let best: { q: number; index: number; specificity: number } | undefined;
179
+ for (const item of items) {
180
+ const specificity = rangeSpecificity(item.tag, language);
181
+ if (
182
+ specificity !== undefined &&
183
+ (!best || specificity > best.specificity)
184
+ )
185
+ best = { q: item.q, index: item.index, specificity };
186
+ }
187
+ return best && best.q > 0 ? [{ language, position, ...best }] : [];
188
+ });
189
+ return ranked
190
+ .sort(
191
+ (left, right) =>
192
+ right.q - left.q ||
193
+ left.index - right.index ||
194
+ right.specificity - left.specificity ||
195
+ left.position - right.position,
196
+ )
197
+ .map(entry => entry.language);
198
+ };
199
+
200
+ /**
201
+ * Detect a supported language from the request alone (no path), in the
202
+ * configured detector order. Returns `undefined` when nothing matches.
203
+ */
204
+ export function detectRequestLanguage(
205
+ request: Request,
206
+ languages: readonly string[],
207
+ detection: RequestLanguageDetection = {},
208
+ ): string | undefined {
209
+ const url = new URL(request.url);
210
+ for (const detector of detection.order ?? DEFAULT_ORDER) {
211
+ let language: string | undefined;
212
+ if (detector === 'querystring') {
213
+ language = matchLanguage(
214
+ url.searchParams.get(detection.lookupQuerystring ?? 'lng'),
215
+ languages,
216
+ );
217
+ } else if (detector === 'cookie') {
218
+ language = matchLanguage(
219
+ readCookie(
220
+ request.headers.get('cookie'),
221
+ detection.lookupCookie ?? 'i18next',
222
+ ),
223
+ languages,
224
+ );
225
+ } else if (detector === 'header') {
226
+ language = acceptedLanguages(
227
+ request.headers.get(detection.lookupHeader ?? 'accept-language'),
228
+ languages,
229
+ ).find(Boolean);
230
+ }
231
+ if (language) return language;
232
+ }
233
+ return undefined;
234
+ }
235
+
236
+ /**
237
+ * Resolve the language a request renders in. A URL that already carries a
238
+ * supported language prefix renders in it; an unprefixed page URL is
239
+ * redirected to the detected (or fallback) language's localized URL, keeping
240
+ * search and the base path, unless it is an ignored or framework-owned path.
241
+ */
242
+ export function resolveRequestLanguage(
243
+ request: Request,
244
+ options: ResolveRequestLanguageOptions,
245
+ ): ResolvedRequestLanguage {
246
+ const { languages, fallbackLanguage } = options;
247
+ const url = new URL(request.url);
248
+ const fromPath = languageFromPathname(
249
+ url.pathname,
250
+ languages,
251
+ options.basePath,
252
+ );
253
+ if (fromPath) return { kind: 'language', language: fromPath };
254
+ const detected =
255
+ (options.detect === false
256
+ ? undefined
257
+ : detectRequestLanguage(request, languages, options.detection)) ??
258
+ fallbackLanguage;
259
+ const remaining = entryPathname(url.pathname, options.basePath);
260
+ if (
261
+ remaining === undefined ||
262
+ shouldSkipLocaleRedirect(
263
+ remaining,
264
+ [...languages],
265
+ options.ignoreRedirectRoutes as
266
+ | string[]
267
+ | ((pathname: string) => boolean)
268
+ | undefined,
269
+ )
270
+ ) {
271
+ // An unprefixed page that is not redirected still renders in a language
272
+ // read from request headers, so its response depends on them.
273
+ const vary = (
274
+ options.detect === false
275
+ ? []
276
+ : (options.detection?.order ?? DEFAULT_ORDER)
277
+ ).flatMap(detector =>
278
+ detector === 'header'
279
+ ? [options.detection?.lookupHeader ?? 'Accept-Language']
280
+ : detector === 'cookie'
281
+ ? ['Cookie']
282
+ : [],
283
+ );
284
+ return vary.length
285
+ ? { kind: 'language', language: detected, vary }
286
+ : { kind: 'language', language: detected };
287
+ }
288
+ const localized = localiseTargetPathname(
289
+ remaining,
290
+ detected,
291
+ [...languages],
292
+ options.localisedUrls,
293
+ );
294
+ return {
295
+ kind: 'redirect',
296
+ language: detected,
297
+ location: `${normaliseBasePath(options.basePath)}${localized}${url.search}`,
298
+ };
299
+ }
300
+
301
+ /** The same uncacheable 302 the React server middleware answers with. */
302
+ export function createRequestLanguageRedirect(location: string): Response {
303
+ return new Response(null, {
304
+ status: 302,
305
+ headers: {
306
+ 'cache-control': 'private, no-store',
307
+ location,
308
+ vary: 'Accept-Language, Cookie',
309
+ },
310
+ });
311
+ }
@@ -0,0 +1,120 @@
1
+ import { RuntimeContext } from '@modern-js/runtime';
2
+ import { InternalRuntimeContext } from '@modern-js/runtime/context';
3
+ import {
4
+ getRouterRuntimeState,
5
+ type RouterLinkTarget,
6
+ type RouterNavigationCapability,
7
+ type RouterNavigationSnapshot,
8
+ subscribeRouterRuntimeState,
9
+ } from '@modern-js/runtime-extensions/router-state';
10
+ import type React from 'react';
11
+ import { useCallback, useContext, useSyncExternalStore } from 'react';
12
+
13
+ export interface I18nRouterNavigationDependencies<
14
+ Adapter extends I18nRouterAdapterShape,
15
+ > {
16
+ I18nNavigationProvider: React.ComponentType<
17
+ React.PropsWithChildren<{ value: Adapter }>
18
+ >;
19
+ useNativeI18nRouterAdapter: () => Adapter;
20
+ }
21
+
22
+ export type I18nRouterLinkTarget = RouterLinkTarget;
23
+
24
+ export interface I18nRouterAdapterShape {
25
+ framework?: string;
26
+ hasRouter: boolean;
27
+ location: RouterNavigationSnapshot['location'] | null;
28
+ navigate: RouterNavigationCapability['navigate'] | null;
29
+ Link: RouterNavigationCapability['Link'] | null;
30
+ params: Record<string, string>;
31
+ createLinkProps?: RouterNavigationCapability['createLinkProps'];
32
+ }
33
+
34
+ const subscribeNone = () => () => {};
35
+ const getEmptySnapshot = () => null;
36
+
37
+ /** The router provider owns navigation; this seam only observes its capability. */
38
+ export const createI18nRouterNavigation = <
39
+ Adapter extends I18nRouterAdapterShape,
40
+ >({
41
+ I18nNavigationProvider,
42
+ useNativeI18nRouterAdapter,
43
+ }: I18nRouterNavigationDependencies<Adapter>) => {
44
+ // The injected context accepts null to preserve native-router fallback.
45
+ const NavigationProvider = I18nNavigationProvider as React.ComponentType<
46
+ React.PropsWithChildren<{ value: Adapter | null }>
47
+ >;
48
+ const useIntegratedRouterAdapter = (): Adapter => {
49
+ const runtimeContext = useContext(RuntimeContext);
50
+ const internalContext = useContext(InternalRuntimeContext);
51
+ const native = useNativeI18nRouterAdapter();
52
+ const getCapability = useCallback(
53
+ () =>
54
+ getRouterRuntimeState(internalContext)?.navigation ??
55
+ getRouterRuntimeState(runtimeContext)?.navigation,
56
+ [internalContext, runtimeContext],
57
+ );
58
+ const subscribeCapability = useCallback(
59
+ (update: () => void) => {
60
+ const stopInternal = subscribeRouterRuntimeState(
61
+ internalContext,
62
+ update,
63
+ );
64
+ const stopPublic =
65
+ internalContext === runtimeContext
66
+ ? undefined
67
+ : subscribeRouterRuntimeState(runtimeContext, update);
68
+ return () => {
69
+ stopInternal();
70
+ stopPublic?.();
71
+ };
72
+ },
73
+ [internalContext, runtimeContext],
74
+ );
75
+ // Separate subscriptions handle both delayed installation/replacement and
76
+ // navigation inside the currently selected provider, without hook guessing.
77
+ const capability = useSyncExternalStore(
78
+ subscribeCapability,
79
+ getCapability,
80
+ getCapability,
81
+ );
82
+ const snapshot = useSyncExternalStore(
83
+ capability?.subscribe ?? subscribeNone,
84
+ capability?.getSnapshot ?? getEmptySnapshot,
85
+ capability?.getSnapshot ?? getEmptySnapshot,
86
+ );
87
+ if (native.hasRouter || !capability || !snapshot) {
88
+ return native;
89
+ }
90
+ const internalState = getRouterRuntimeState(internalContext);
91
+ return {
92
+ framework:
93
+ internalState?.navigation === capability
94
+ ? internalState.framework
95
+ : getRouterRuntimeState(runtimeContext)?.framework,
96
+ hasRouter: true,
97
+ ...snapshot,
98
+ navigate: capability.navigate,
99
+ Link: capability.Link,
100
+ createLinkProps: capability.createLinkProps,
101
+ } as Adapter;
102
+ };
103
+
104
+ const I18nRouterNavigationProvider = ({
105
+ children,
106
+ }: React.PropsWithChildren) => {
107
+ const value = useIntegratedRouterAdapter();
108
+ const native = useNativeI18nRouterAdapter();
109
+ // Keep the router subtree mounted when a capability arrives or disappears.
110
+ return (
111
+ <NavigationProvider
112
+ value={value.hasRouter && !native.hasRouter ? value : null}
113
+ >
114
+ {children}
115
+ </NavigationProvider>
116
+ );
117
+ };
118
+
119
+ return { useIntegratedRouterAdapter, I18nRouterNavigationProvider };
120
+ };
@@ -0,0 +1,6 @@
1
+ export {
2
+ createI18nRouterNavigation,
3
+ type I18nRouterAdapterShape,
4
+ type I18nRouterLinkTarget,
5
+ type I18nRouterNavigationDependencies,
6
+ } from './adapter';
@@ -0,0 +1,138 @@
1
+ import {
2
+ parsePublicData,
3
+ serializePublicData,
4
+ } from '@modern-js/renderer-core/data';
5
+ import { serializeInlineData } from '@modern-js/renderer-core/document';
6
+
7
+ /**
8
+ * Renderer-neutral replacement for React's `window._SSR_DATA.data.i18nData`
9
+ * handoff (see `plugin-i18n/src/runtime/i18n/detection/ssr.ts` and
10
+ * `pluginSetup.ts`'s `exportServerLngToWindow`). That path only exists
11
+ * because the React renderer serializes its own `_SSR_DATA` global; native
12
+ * renderers have no equivalent global, so the language (and an optional
13
+ * resource subset, for first-paint translation without a network round
14
+ * trip) travels in its own inline `<script type="application/json">`
15
+ * element instead, using renderer-core's public-data codec — the same
16
+ * XSS-safe escaping and size/depth limits used for loader/action data.
17
+ */
18
+ export const I18N_SSR_HANDOFF_ELEMENT_ID = '__modernjs_i18n_ssr__';
19
+
20
+ /** `{ [namespace]: { [key]: value } }`, scoped to a single language. */
21
+ export type I18nSsrHandoffResources = Record<string, Record<string, unknown>>;
22
+
23
+ export interface I18nSsrHandoffPayload {
24
+ /** The language the server resolved for this request. */
25
+ language: string;
26
+ /** Optional subset of resources for `language`, keyed by namespace. */
27
+ resources?: I18nSsrHandoffResources;
28
+ }
29
+
30
+ export interface SerializeI18nSsrHandoffOptions {
31
+ /** @default I18N_SSR_HANDOFF_ELEMENT_ID */
32
+ id?: string;
33
+ nonce?: string;
34
+ }
35
+
36
+ function assertPayload(
37
+ payload: I18nSsrHandoffPayload,
38
+ ): asserts payload is I18nSsrHandoffPayload {
39
+ if (typeof payload.language !== 'string' || payload.language.length === 0) {
40
+ throw new TypeError(
41
+ 'An i18n SSR handoff payload requires a nonempty language.',
42
+ );
43
+ }
44
+ if (
45
+ payload.resources !== undefined &&
46
+ (typeof payload.resources !== 'object' || payload.resources === null)
47
+ ) {
48
+ throw new TypeError(
49
+ 'An i18n SSR handoff payload resources field must be a plain object.',
50
+ );
51
+ }
52
+ }
53
+
54
+ /** An inline data element a native document serializes with its own nonce. */
55
+ export interface I18nSsrHandoffInlineData {
56
+ readonly id: string;
57
+ readonly payload: ReturnType<typeof serializePublicData>;
58
+ }
59
+
60
+ /**
61
+ * Encode the handoff as `{ id, payload }` for a native document's
62
+ * `inlineData` option, which places it before the client modules and stamps
63
+ * it with the document's CSP nonce.
64
+ */
65
+ export function createI18nSsrHandoffInlineData(
66
+ payload: I18nSsrHandoffPayload,
67
+ options: Pick<SerializeI18nSsrHandoffOptions, 'id'> = {},
68
+ ): I18nSsrHandoffInlineData {
69
+ assertPayload(payload);
70
+ return {
71
+ id: options.id ?? I18N_SSR_HANDOFF_ELEMENT_ID,
72
+ payload: serializePublicData({
73
+ language: payload.language,
74
+ ...(payload.resources ? { resources: payload.resources } : {}),
75
+ }),
76
+ };
77
+ }
78
+
79
+ /**
80
+ * Serialize the language/resources handoff into a complete, inert
81
+ * `<script type="application/json">` element string. The native renderer's
82
+ * document composition decides where to place it (before any module that
83
+ * reads it on the client).
84
+ */
85
+ export function serializeI18nSsrHandoff(
86
+ payload: I18nSsrHandoffPayload,
87
+ options: SerializeI18nSsrHandoffOptions = {},
88
+ ): string {
89
+ return serializeInlineData({
90
+ ...createI18nSsrHandoffInlineData(payload, options),
91
+ nonce: options.nonce,
92
+ });
93
+ }
94
+
95
+ function isHandoffPayload(value: unknown): value is I18nSsrHandoffPayload {
96
+ return (
97
+ Boolean(value) &&
98
+ typeof value === 'object' &&
99
+ typeof (value as { language?: unknown }).language === 'string'
100
+ );
101
+ }
102
+
103
+ /**
104
+ * Decode a handoff payload from the raw text content of the element
105
+ * `serializeI18nSsrHandoff` produced. Pure — no DOM access — so it can be
106
+ * unit tested or used from a non-browser client runtime directly.
107
+ * Returns `undefined` for missing, malformed or oversized input rather than
108
+ * throwing, since a corrupted handoff should fall back to client detection.
109
+ */
110
+ export function readI18nSsrHandoffFromText(
111
+ text: string | null | undefined,
112
+ ): I18nSsrHandoffPayload | undefined {
113
+ if (!text) {
114
+ return undefined;
115
+ }
116
+ try {
117
+ const result = parsePublicData(text);
118
+ return isHandoffPayload(result) ? result : undefined;
119
+ } catch {
120
+ return undefined;
121
+ }
122
+ }
123
+
124
+ /**
125
+ * Browser convenience wrapper: reads the handoff element from `document` (or
126
+ * a supplied document-like object) by id and decodes it.
127
+ */
128
+ export function readI18nSsrHandoff(
129
+ options: { id?: string; doc?: Pick<Document, 'getElementById'> } = {},
130
+ ): I18nSsrHandoffPayload | undefined {
131
+ const doc =
132
+ options.doc ?? (typeof document === 'undefined' ? undefined : document);
133
+ if (!doc) {
134
+ return undefined;
135
+ }
136
+ const element = doc.getElementById(options.id ?? I18N_SSR_HANDOFF_ELEMENT_ID);
137
+ return readI18nSsrHandoffFromText(element?.textContent ?? null);
138
+ }
@@ -0,0 +1,107 @@
1
+ import {
2
+ canonicalTargetPathname,
3
+ type LocalisedUrlsOption,
4
+ localiseTargetPathname,
5
+ } from '@modern-js/runtime-extensions/localised-urls';
6
+ import { languageFromPathname } from './requestLanguage';
7
+
8
+ /**
9
+ * Structurally identical to `@tanstack/router-core`'s `LocationRewriteFunction`.
10
+ * Defined locally so this module has no runtime dependency on router-core —
11
+ * any router built on that contract (TanStack Router, `@octanejs/tanstack-router`,
12
+ * which re-exports it verbatim) can consume the rewrite this module builds.
13
+ */
14
+ export type I18nLocationRewriteFunction = (args: {
15
+ url: URL;
16
+ }) => undefined | string | URL;
17
+
18
+ /** Structurally identical to `@tanstack/router-core`'s `LocationRewrite`. */
19
+ export interface I18nLocationRewrite {
20
+ input?: I18nLocationRewriteFunction;
21
+ output?: I18nLocationRewriteFunction;
22
+ }
23
+
24
+ export interface CreateI18nUrlRewriteOptions {
25
+ /** Supported language codes, e.g. `['en', 'cs']`. */
26
+ languages: readonly string[];
27
+ /**
28
+ * The language an outgoing (internal -> public) location is localized to
29
+ * when its pathname does not already start with a supported language.
30
+ */
31
+ getLanguage: () => string;
32
+ /**
33
+ * Canonical-path -> per-language path map; mirrors
34
+ * `localeDetection.localisedUrls`. Omit for the plain language-prefix policy.
35
+ */
36
+ localisedUrls?: LocalisedUrlsOption;
37
+ /**
38
+ * Called with the language segment found in an incoming public pathname,
39
+ * before it is stripped to its canonical form. Not called when the
40
+ * pathname carries no recognizable language segment.
41
+ */
42
+ onLanguageDetected?: (language: string) => void;
43
+ }
44
+
45
+ /**
46
+ * Build a TanStack router-core `LocationRewrite` from the fork's localized-URL
47
+ * pathname helpers: the router matches against canonical (language-agnostic)
48
+ * paths internally, while every public href it produces carries the current
49
+ * language prefix (and any mapped locale slug). An outgoing pathname that
50
+ * already starts with a supported language keeps that language, which is how
51
+ * a route mask such as `LocalizedLink`'s publishes another language's URL.
52
+ *
53
+ * Pass the result directly as a router's `rewrite` option:
54
+ *
55
+ * ```ts
56
+ * createRouter({ routeTree, rewrite: createI18nUrlRewrite({ languages, getLanguage }) })
57
+ * ```
58
+ *
59
+ * If the router also configures a `basepath`, router-core composes the two
60
+ * for you — its constructor internally does
61
+ * `this.rewrite = rewriteBasepath(basepath, caseSensitive, options.rewrite)`
62
+ * (that helper is an internal, not part of router-core's public exports; the
63
+ * composition happens automatically, nothing extra to call here). It strips
64
+ * the basepath before this rewrite's `input` runs, and applies this
65
+ * rewrite's `output` before the basepath is re-added, so the public URL
66
+ * shape is `${basepath}${languagePrefix}${canonicalPath}` — e.g.
67
+ * `/app/en/dashboard` while the router itself only ever sees `/dashboard`.
68
+ */
69
+ export const createI18nUrlRewrite = (
70
+ options: CreateI18nUrlRewriteOptions,
71
+ ): I18nLocationRewrite => {
72
+ const { languages, getLanguage, localisedUrls, onLanguageDetected } = options;
73
+ const languageList = [...languages];
74
+
75
+ return {
76
+ input: ({ url }) => {
77
+ const pathname = url.pathname;
78
+ if (onLanguageDetected) {
79
+ const segments = pathname.split('/').filter(Boolean);
80
+ const firstSegment = segments[0];
81
+ const detected = firstSegment
82
+ ? languageList.find(
83
+ language => language.toLowerCase() === firstSegment.toLowerCase(),
84
+ )
85
+ : undefined;
86
+ if (detected) {
87
+ onLanguageDetected(detected);
88
+ }
89
+ }
90
+ url.pathname = canonicalTargetPathname(
91
+ pathname,
92
+ languageList,
93
+ localisedUrls,
94
+ );
95
+ return url;
96
+ },
97
+ output: ({ url }) => {
98
+ url.pathname = localiseTargetPathname(
99
+ url.pathname,
100
+ languageFromPathname(url.pathname, languageList) ?? getLanguage(),
101
+ languageList,
102
+ localisedUrls,
103
+ );
104
+ return url;
105
+ },
106
+ };
107
+ };