@bleedingdev/modern-js-plugin-i18n 3.2.0-ultramodern.12 → 3.2.0-ultramodern.121
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/README.md +221 -11
- package/dist/cjs/cli/index.js +32 -5
- package/dist/cjs/runtime/I18nLink.js +17 -28
- package/dist/cjs/runtime/Link.js +264 -0
- package/dist/cjs/runtime/canonicalRoutes.js +18 -0
- package/dist/cjs/runtime/context.js +41 -10
- package/dist/cjs/runtime/hooks.js +17 -10
- package/dist/cjs/runtime/i18n/backend/config.js +9 -5
- package/dist/cjs/runtime/i18n/backend/defaults.js +15 -10
- package/dist/cjs/runtime/i18n/backend/defaults.node.js +47 -8
- package/dist/cjs/runtime/i18n/backend/index.js +9 -5
- package/dist/cjs/runtime/i18n/backend/middleware.common.js +9 -5
- package/dist/cjs/runtime/i18n/backend/middleware.js +9 -5
- package/dist/cjs/runtime/i18n/backend/middleware.node.js +13 -9
- package/dist/cjs/runtime/i18n/backend/sdk-backend.js +9 -5
- package/dist/cjs/runtime/i18n/backend/sdk-event.js +16 -11
- package/dist/cjs/runtime/i18n/detection/config.js +9 -5
- package/dist/cjs/runtime/i18n/detection/index.js +9 -5
- package/dist/cjs/runtime/i18n/detection/middleware.js +9 -5
- package/dist/cjs/runtime/i18n/detection/middleware.node.js +9 -5
- package/dist/cjs/runtime/i18n/index.js +9 -5
- package/dist/cjs/runtime/i18n/instance.js +17 -37
- package/dist/cjs/runtime/i18n/react-i18next.js +53 -0
- package/dist/cjs/runtime/i18n/utils.js +9 -17
- package/dist/cjs/runtime/index.js +50 -15
- package/dist/cjs/runtime/localizedPaths.js +102 -0
- package/dist/cjs/runtime/routerAdapter.js +167 -0
- package/dist/cjs/runtime/utils.js +80 -97
- package/dist/cjs/server/index.js +62 -14
- package/dist/cjs/shared/deepMerge.js +12 -8
- package/dist/cjs/shared/detection.js +9 -5
- package/dist/cjs/shared/localisedUrls.js +351 -0
- package/dist/cjs/shared/utils.js +15 -11
- package/dist/esm/cli/index.mjs +23 -0
- package/dist/esm/runtime/I18nLink.mjs +7 -22
- package/dist/esm/runtime/Link.mjs +221 -0
- package/dist/esm/runtime/canonicalRoutes.mjs +0 -0
- package/dist/esm/runtime/context.mjs +34 -7
- package/dist/esm/runtime/hooks.mjs +9 -6
- package/dist/esm/runtime/i18n/backend/defaults.mjs +1 -1
- package/dist/esm/runtime/i18n/backend/defaults.node.mjs +24 -3
- package/dist/esm/runtime/i18n/backend/middleware.node.mjs +3 -3
- package/dist/esm/runtime/i18n/instance.mjs +1 -19
- package/dist/esm/runtime/i18n/react-i18next.mjs +15 -0
- package/dist/esm/runtime/i18n/utils.mjs +0 -12
- package/dist/esm/runtime/index.mjs +23 -13
- package/dist/esm/runtime/localizedPaths.mjs +55 -0
- package/dist/esm/runtime/routerAdapter.mjs +129 -0
- package/dist/esm/runtime/utils.mjs +19 -31
- package/dist/esm/server/index.mjs +46 -8
- package/dist/esm/shared/localisedUrls.mjs +283 -0
- package/dist/esm-node/cli/index.mjs +23 -0
- package/dist/esm-node/runtime/I18nLink.mjs +7 -22
- package/dist/esm-node/runtime/Link.mjs +222 -0
- package/dist/esm-node/runtime/canonicalRoutes.mjs +1 -0
- package/dist/esm-node/runtime/context.mjs +34 -7
- package/dist/esm-node/runtime/hooks.mjs +9 -6
- package/dist/esm-node/runtime/i18n/backend/defaults.mjs +1 -1
- package/dist/esm-node/runtime/i18n/backend/defaults.node.mjs +24 -3
- package/dist/esm-node/runtime/i18n/backend/middleware.node.mjs +3 -3
- package/dist/esm-node/runtime/i18n/instance.mjs +1 -19
- package/dist/esm-node/runtime/i18n/react-i18next.mjs +16 -0
- package/dist/esm-node/runtime/i18n/utils.mjs +0 -12
- package/dist/esm-node/runtime/index.mjs +23 -13
- package/dist/esm-node/runtime/localizedPaths.mjs +56 -0
- package/dist/esm-node/runtime/routerAdapter.mjs +130 -0
- package/dist/esm-node/runtime/utils.mjs +19 -31
- package/dist/esm-node/server/index.mjs +46 -8
- package/dist/esm-node/shared/localisedUrls.mjs +284 -0
- package/dist/types/cli/index.d.ts +1 -0
- package/dist/types/runtime/I18nLink.d.ts +6 -0
- package/dist/types/runtime/Link.d.ts +66 -0
- package/dist/types/runtime/canonicalRoutes.d.ts +60 -0
- package/dist/types/runtime/context.d.ts +3 -0
- package/dist/types/runtime/hooks.d.ts +4 -2
- package/dist/types/runtime/i18n/backend/defaults.node.d.ts +3 -2
- package/dist/types/runtime/i18n/backend/middleware.node.d.ts +1 -1
- package/dist/types/runtime/i18n/instance.d.ts +4 -6
- package/dist/types/runtime/i18n/react-i18next.d.ts +7 -0
- package/dist/types/runtime/index.d.ts +6 -1
- package/dist/types/runtime/localizedPaths.d.ts +39 -0
- package/dist/types/runtime/routerAdapter.d.ts +26 -0
- package/dist/types/runtime/types.d.ts +1 -1
- package/dist/types/runtime/utils.d.ts +13 -9
- package/dist/types/server/index.d.ts +6 -0
- package/dist/types/shared/localisedUrls.d.ts +36 -0
- package/dist/types/shared/type.d.ts +14 -0
- package/package.json +24 -24
- package/rstest.config.mts +44 -0
- package/src/cli/index.ts +44 -1
- package/src/runtime/I18nLink.tsx +14 -51
- package/src/runtime/Link.tsx +430 -0
- package/src/runtime/canonicalRoutes.ts +93 -0
- package/src/runtime/context.tsx +45 -7
- package/src/runtime/hooks.ts +13 -4
- package/src/runtime/i18n/backend/defaults.node.ts +40 -2
- package/src/runtime/i18n/backend/defaults.ts +3 -1
- package/src/runtime/i18n/backend/middleware.node.ts +1 -1
- package/src/runtime/i18n/instance.ts +3 -30
- package/src/runtime/i18n/react-i18next.ts +25 -0
- package/src/runtime/i18n/utils.ts +4 -26
- package/src/runtime/index.tsx +47 -12
- package/src/runtime/localizedPaths.ts +107 -0
- package/src/runtime/routerAdapter.tsx +332 -0
- package/src/runtime/types.ts +1 -1
- package/src/runtime/utils.ts +33 -38
- package/src/server/index.ts +108 -11
- package/src/shared/localisedUrls.ts +623 -0
- package/src/shared/type.ts +14 -0
- package/tests/backendDefaults.test.ts +51 -0
- package/tests/i18nUtils.test.ts +59 -0
- package/tests/link.test.tsx +525 -0
- package/tests/linkTypes.test.ts +28 -0
- package/tests/localisedUrls.test.ts +536 -0
- package/tests/routerAdapter.test.tsx +456 -0
- package/tests/type-fixture/linkTypes.fixture.tsx +51 -0
- package/tests/type-fixture/tsconfig.json +15 -0
- package/dist/esm/rslib-runtime.mjs +0 -18
- package/dist/esm-node/rslib-runtime.mjs +0 -19
|
@@ -0,0 +1,536 @@
|
|
|
1
|
+
import type { NestedRouteForCli } from '@modern-js/types';
|
|
2
|
+
import { describe, expect, test } from '@rstest/core';
|
|
3
|
+
import { i18nPlugin as i18nCliPlugin } from '../src/cli';
|
|
4
|
+
import {
|
|
5
|
+
collectApiPrefixes,
|
|
6
|
+
i18nServerPlugin,
|
|
7
|
+
matchesApiPrefix,
|
|
8
|
+
} from '../src/server';
|
|
9
|
+
import {
|
|
10
|
+
applyLocalisedUrlsToRoutes,
|
|
11
|
+
canonicalTargetPathname,
|
|
12
|
+
localiseTargetPathname,
|
|
13
|
+
matchPathPattern,
|
|
14
|
+
resolveCanonicalLocalisedPath,
|
|
15
|
+
resolveLocalisedPath,
|
|
16
|
+
resolveLocalisedUrlsConfig,
|
|
17
|
+
validateLocalisedUrls,
|
|
18
|
+
} from '../src/shared/localisedUrls';
|
|
19
|
+
|
|
20
|
+
const createRoute = (
|
|
21
|
+
path: string,
|
|
22
|
+
children?: NestedRouteForCli[],
|
|
23
|
+
): NestedRouteForCli => ({
|
|
24
|
+
id: path,
|
|
25
|
+
path,
|
|
26
|
+
type: 'nested',
|
|
27
|
+
origin: 'file-system',
|
|
28
|
+
routeType: children ? 'layout' : 'page',
|
|
29
|
+
_component: `${path}.tsx`,
|
|
30
|
+
children,
|
|
31
|
+
});
|
|
32
|
+
|
|
33
|
+
const createRequestContext = (pathname: string) =>
|
|
34
|
+
({
|
|
35
|
+
req: {
|
|
36
|
+
url: `http://localhost${pathname}`,
|
|
37
|
+
},
|
|
38
|
+
}) as any;
|
|
39
|
+
|
|
40
|
+
describe('resolveLocalisedUrlsConfig', () => {
|
|
41
|
+
test('is opt-in: only a non-empty map enables the feature', () => {
|
|
42
|
+
const map = { '/about': { en: '/about', cs: '/o-nas' } };
|
|
43
|
+
expect(resolveLocalisedUrlsConfig(map)).toEqual({ enabled: true, map });
|
|
44
|
+
});
|
|
45
|
+
|
|
46
|
+
test('absent, boolean and empty-map options resolve to disabled', () => {
|
|
47
|
+
const disabled = { enabled: false, map: {} };
|
|
48
|
+
expect(resolveLocalisedUrlsConfig(undefined)).toEqual(disabled);
|
|
49
|
+
expect(resolveLocalisedUrlsConfig(false)).toEqual(disabled);
|
|
50
|
+
expect(resolveLocalisedUrlsConfig(true)).toEqual(disabled);
|
|
51
|
+
expect(resolveLocalisedUrlsConfig({})).toEqual(disabled);
|
|
52
|
+
});
|
|
53
|
+
});
|
|
54
|
+
|
|
55
|
+
describe('cli modifyFileSystemRoutes', () => {
|
|
56
|
+
const setupModifyRoutes = (localeDetection: Record<string, unknown>) => {
|
|
57
|
+
let modifyRoutes:
|
|
58
|
+
| ((args: { entrypoint: any; routes: any[] }) => {
|
|
59
|
+
entrypoint: any;
|
|
60
|
+
routes: any[];
|
|
61
|
+
})
|
|
62
|
+
| undefined;
|
|
63
|
+
|
|
64
|
+
i18nCliPlugin({ localeDetection }).setup({
|
|
65
|
+
_internalRuntimePlugins: () => {},
|
|
66
|
+
modifyFileSystemRoutes: (fn: any) => {
|
|
67
|
+
modifyRoutes = fn;
|
|
68
|
+
},
|
|
69
|
+
_internalServerPlugins: () => {},
|
|
70
|
+
} as any);
|
|
71
|
+
|
|
72
|
+
expect(modifyRoutes).toBeDefined();
|
|
73
|
+
return modifyRoutes!;
|
|
74
|
+
};
|
|
75
|
+
|
|
76
|
+
test('upstream-style configs without a map keep routes untouched', () => {
|
|
77
|
+
const modifyRoutes = setupModifyRoutes({
|
|
78
|
+
localePathRedirect: true,
|
|
79
|
+
languages: ['en', 'cs'],
|
|
80
|
+
});
|
|
81
|
+
const routes = [createRoute(':lang', [createRoute('about')])];
|
|
82
|
+
|
|
83
|
+
const result = modifyRoutes({ entrypoint: { entryName: 'main' }, routes });
|
|
84
|
+
|
|
85
|
+
expect(result.routes).toBe(routes);
|
|
86
|
+
});
|
|
87
|
+
|
|
88
|
+
test('a configured map still expands localised route aliases', () => {
|
|
89
|
+
const modifyRoutes = setupModifyRoutes({
|
|
90
|
+
localePathRedirect: true,
|
|
91
|
+
languages: ['en', 'cs'],
|
|
92
|
+
localisedUrls: {
|
|
93
|
+
'/about': { en: '/about', cs: '/o-nas' },
|
|
94
|
+
},
|
|
95
|
+
});
|
|
96
|
+
const routes = [createRoute(':lang', [createRoute('about')])];
|
|
97
|
+
|
|
98
|
+
const result = modifyRoutes({ entrypoint: { entryName: 'main' }, routes });
|
|
99
|
+
|
|
100
|
+
const localeRoute = result.routes[0] as NestedRouteForCli;
|
|
101
|
+
expect(localeRoute.children?.map(route => route.path)).toEqual([
|
|
102
|
+
'about',
|
|
103
|
+
'o-nas',
|
|
104
|
+
]);
|
|
105
|
+
});
|
|
106
|
+
});
|
|
107
|
+
|
|
108
|
+
describe('localisedUrls', () => {
|
|
109
|
+
test('requires every localisable route path to define every language', () => {
|
|
110
|
+
const routes = [createRoute(':lang', [createRoute('terms-of-service')])];
|
|
111
|
+
|
|
112
|
+
expect(() =>
|
|
113
|
+
validateLocalisedUrls(routes, ['en', 'cs'], {
|
|
114
|
+
'/terms-of-service': {
|
|
115
|
+
en: '/terms-of-service',
|
|
116
|
+
},
|
|
117
|
+
}),
|
|
118
|
+
).toThrow('missing languages: cs');
|
|
119
|
+
});
|
|
120
|
+
|
|
121
|
+
test('expands route paths to localised aliases', () => {
|
|
122
|
+
const routes = [
|
|
123
|
+
createRoute(':lang', [
|
|
124
|
+
createRoute('terms-of-service'),
|
|
125
|
+
createRoute('products', [createRoute(':slug')]),
|
|
126
|
+
]),
|
|
127
|
+
];
|
|
128
|
+
|
|
129
|
+
const localisedRoutes = applyLocalisedUrlsToRoutes(routes, ['en', 'cs'], {
|
|
130
|
+
'/terms-of-service': {
|
|
131
|
+
en: '/terms-of-service',
|
|
132
|
+
cs: '/podminky-pouzivani',
|
|
133
|
+
},
|
|
134
|
+
'/products': {
|
|
135
|
+
en: '/products',
|
|
136
|
+
cs: '/produkty',
|
|
137
|
+
},
|
|
138
|
+
'/products/:slug': {
|
|
139
|
+
en: '/products/:slug',
|
|
140
|
+
cs: '/produkty/:slug',
|
|
141
|
+
},
|
|
142
|
+
});
|
|
143
|
+
|
|
144
|
+
const localeRoute = localisedRoutes[0] as NestedRouteForCli;
|
|
145
|
+
expect(localeRoute.children?.map(route => route.path)).toEqual([
|
|
146
|
+
'terms-of-service',
|
|
147
|
+
'podminky-pouzivani',
|
|
148
|
+
'products',
|
|
149
|
+
'produkty',
|
|
150
|
+
]);
|
|
151
|
+
|
|
152
|
+
const productRoutes = localeRoute.children?.filter(
|
|
153
|
+
route => route.path === 'products' || route.path === 'produkty',
|
|
154
|
+
);
|
|
155
|
+
expect(productRoutes?.[0].children?.map(route => route.path)).toEqual([
|
|
156
|
+
':slug',
|
|
157
|
+
]);
|
|
158
|
+
expect(productRoutes?.[1].children?.map(route => route.path)).toEqual([
|
|
159
|
+
':slug',
|
|
160
|
+
]);
|
|
161
|
+
});
|
|
162
|
+
|
|
163
|
+
test('expands flat locale-prefixed route paths with canonical keys', () => {
|
|
164
|
+
const routes = [
|
|
165
|
+
createRoute('/:lang/about'),
|
|
166
|
+
createRoute('/:lang/products/:slug'),
|
|
167
|
+
];
|
|
168
|
+
|
|
169
|
+
const localisedRoutes = applyLocalisedUrlsToRoutes(routes, ['en', 'cs'], {
|
|
170
|
+
'/about': {
|
|
171
|
+
en: '/about',
|
|
172
|
+
cs: '/o-nas',
|
|
173
|
+
},
|
|
174
|
+
'/products/:slug': {
|
|
175
|
+
en: '/products/:slug',
|
|
176
|
+
cs: '/produkty/:slug',
|
|
177
|
+
},
|
|
178
|
+
});
|
|
179
|
+
|
|
180
|
+
expect(localisedRoutes.map(route => route.path)).toEqual([
|
|
181
|
+
':lang/about',
|
|
182
|
+
':lang/o-nas',
|
|
183
|
+
':lang/products/:slug',
|
|
184
|
+
':lang/produkty/:slug',
|
|
185
|
+
]);
|
|
186
|
+
});
|
|
187
|
+
|
|
188
|
+
test('resolves current localized path to the target language', () => {
|
|
189
|
+
const localisedUrls = {
|
|
190
|
+
'/terms-of-service': {
|
|
191
|
+
en: '/terms-of-service',
|
|
192
|
+
cs: '/podminky-pouzivani',
|
|
193
|
+
},
|
|
194
|
+
'/products/:slug': {
|
|
195
|
+
en: '/products/:slug',
|
|
196
|
+
cs: '/produkty/:slug',
|
|
197
|
+
},
|
|
198
|
+
};
|
|
199
|
+
|
|
200
|
+
expect(
|
|
201
|
+
resolveLocalisedPath(
|
|
202
|
+
'/terms-of-service',
|
|
203
|
+
'cs',
|
|
204
|
+
['en', 'cs'],
|
|
205
|
+
localisedUrls,
|
|
206
|
+
),
|
|
207
|
+
).toBe('/podminky-pouzivani');
|
|
208
|
+
expect(
|
|
209
|
+
resolveLocalisedPath('/produkty/cervena-bota', 'en', ['en', 'cs'], {
|
|
210
|
+
...localisedUrls,
|
|
211
|
+
'/products/:slug': {
|
|
212
|
+
en: '/products/:slug',
|
|
213
|
+
cs: '/produkty/:slug',
|
|
214
|
+
},
|
|
215
|
+
}),
|
|
216
|
+
).toBe('/products/cervena-bota');
|
|
217
|
+
});
|
|
218
|
+
|
|
219
|
+
test('resolves optional route params', () => {
|
|
220
|
+
const localisedUrls = {
|
|
221
|
+
'/products/:slug?': {
|
|
222
|
+
en: '/products/:slug?',
|
|
223
|
+
cs: '/produkty/:slug?',
|
|
224
|
+
},
|
|
225
|
+
};
|
|
226
|
+
|
|
227
|
+
expect(
|
|
228
|
+
resolveLocalisedPath('/products', 'cs', ['en', 'cs'], localisedUrls),
|
|
229
|
+
).toBe('/produkty');
|
|
230
|
+
expect(
|
|
231
|
+
resolveLocalisedPath(
|
|
232
|
+
'/produkty/cervena-bota',
|
|
233
|
+
'en',
|
|
234
|
+
['en', 'cs'],
|
|
235
|
+
localisedUrls,
|
|
236
|
+
),
|
|
237
|
+
).toBe('/products/cervena-bota');
|
|
238
|
+
});
|
|
239
|
+
|
|
240
|
+
test('resolves static patterns before param patterns', () => {
|
|
241
|
+
const localisedUrls = {
|
|
242
|
+
'/products/:slug': {
|
|
243
|
+
en: '/products/:slug',
|
|
244
|
+
cs: '/produkty/:slug',
|
|
245
|
+
},
|
|
246
|
+
'/products/new': {
|
|
247
|
+
en: '/products/new',
|
|
248
|
+
cs: '/produkty/novinka',
|
|
249
|
+
},
|
|
250
|
+
};
|
|
251
|
+
|
|
252
|
+
expect(
|
|
253
|
+
resolveLocalisedPath('/products/new', 'cs', ['en', 'cs'], localisedUrls),
|
|
254
|
+
).toBe('/produkty/novinka');
|
|
255
|
+
expect(
|
|
256
|
+
resolveCanonicalLocalisedPath(
|
|
257
|
+
'/produkty/novinka',
|
|
258
|
+
['en', 'cs'],
|
|
259
|
+
localisedUrls,
|
|
260
|
+
),
|
|
261
|
+
).toBe('/products/new');
|
|
262
|
+
});
|
|
263
|
+
|
|
264
|
+
test('localises and canonicalises full target pathnames through one helper', () => {
|
|
265
|
+
const localisedUrls = {
|
|
266
|
+
'/products/:slug': {
|
|
267
|
+
en: '/products/:slug',
|
|
268
|
+
cs: '/produkty/:slug',
|
|
269
|
+
},
|
|
270
|
+
};
|
|
271
|
+
|
|
272
|
+
expect(
|
|
273
|
+
localiseTargetPathname(
|
|
274
|
+
'/en/products/cervena-bota',
|
|
275
|
+
'cs',
|
|
276
|
+
['en', 'cs'],
|
|
277
|
+
localisedUrls,
|
|
278
|
+
),
|
|
279
|
+
).toBe('/cs/produkty/cervena-bota');
|
|
280
|
+
expect(
|
|
281
|
+
canonicalTargetPathname(
|
|
282
|
+
'/cs/produkty/cervena-bota',
|
|
283
|
+
['en', 'cs'],
|
|
284
|
+
localisedUrls,
|
|
285
|
+
),
|
|
286
|
+
).toBe('/products/cervena-bota');
|
|
287
|
+
});
|
|
288
|
+
|
|
289
|
+
test('resolves nested optional route params with translated ancestors', () => {
|
|
290
|
+
const localisedUrls = {
|
|
291
|
+
'/checkout': {
|
|
292
|
+
en: '/checkout',
|
|
293
|
+
cs: '/pokladna',
|
|
294
|
+
},
|
|
295
|
+
'/checkout/thank-you': {
|
|
296
|
+
en: '/checkout/thank-you',
|
|
297
|
+
cs: '/pokladna/dekujeme',
|
|
298
|
+
},
|
|
299
|
+
'/checkout/thank-you/:orderId?': {
|
|
300
|
+
en: '/checkout/thank-you/:orderId?',
|
|
301
|
+
cs: '/pokladna/dekujeme/:orderId?',
|
|
302
|
+
},
|
|
303
|
+
};
|
|
304
|
+
|
|
305
|
+
expect(
|
|
306
|
+
resolveLocalisedPath(
|
|
307
|
+
'/checkout/thank-you',
|
|
308
|
+
'cs',
|
|
309
|
+
['en', 'cs'],
|
|
310
|
+
localisedUrls,
|
|
311
|
+
),
|
|
312
|
+
).toBe('/pokladna/dekujeme');
|
|
313
|
+
expect(
|
|
314
|
+
resolveLocalisedPath(
|
|
315
|
+
'/pokladna/dekujeme/ABC-123',
|
|
316
|
+
'en',
|
|
317
|
+
['en', 'cs'],
|
|
318
|
+
localisedUrls,
|
|
319
|
+
),
|
|
320
|
+
).toBe('/checkout/thank-you/ABC-123');
|
|
321
|
+
});
|
|
322
|
+
});
|
|
323
|
+
|
|
324
|
+
describe('matchPathPattern decoding', () => {
|
|
325
|
+
test('decodes valid percent-encoded params', () => {
|
|
326
|
+
expect(
|
|
327
|
+
matchPathPattern('/produkty/%C4%8Derven%C3%A1', '/produkty/:slug'),
|
|
328
|
+
).toEqual({ slug: 'červená' });
|
|
329
|
+
});
|
|
330
|
+
|
|
331
|
+
test('treats malformed percent-encoding as no match instead of throwing', () => {
|
|
332
|
+
expect(() =>
|
|
333
|
+
matchPathPattern('/produkty/%E0%A4%A', '/produkty/:slug'),
|
|
334
|
+
).not.toThrow();
|
|
335
|
+
expect(
|
|
336
|
+
matchPathPattern('/produkty/%E0%A4%A', '/produkty/:slug'),
|
|
337
|
+
).toBeNull();
|
|
338
|
+
});
|
|
339
|
+
|
|
340
|
+
test('keeps literal bracket segments in pathnames (no pattern rewrite)', () => {
|
|
341
|
+
expect(matchPathPattern('/produkty/[x]', '/produkty/:slug')).toEqual({
|
|
342
|
+
slug: '[x]',
|
|
343
|
+
});
|
|
344
|
+
});
|
|
345
|
+
|
|
346
|
+
test('resolveLocalisedPath returns malformed paths unchanged', () => {
|
|
347
|
+
const localisedUrls = {
|
|
348
|
+
'/products/:slug': {
|
|
349
|
+
en: '/products/:slug',
|
|
350
|
+
cs: '/produkty/:slug',
|
|
351
|
+
},
|
|
352
|
+
};
|
|
353
|
+
|
|
354
|
+
expect(
|
|
355
|
+
resolveLocalisedPath(
|
|
356
|
+
'/produkty/%E0%A4%A',
|
|
357
|
+
'en',
|
|
358
|
+
['en', 'cs'],
|
|
359
|
+
localisedUrls,
|
|
360
|
+
),
|
|
361
|
+
).toBe('/produkty/%E0%A4%A');
|
|
362
|
+
});
|
|
363
|
+
});
|
|
364
|
+
|
|
365
|
+
describe('i18n server API prefix skips', () => {
|
|
366
|
+
test('collects API route prefixes and normalized BFF config prefixes', () => {
|
|
367
|
+
expect(
|
|
368
|
+
collectApiPrefixes(
|
|
369
|
+
[
|
|
370
|
+
{ entryName: 'main', isApi: false, urlPath: '/' },
|
|
371
|
+
{ isApi: true, urlPath: '/bff-api' },
|
|
372
|
+
{ isApi: true, urlPath: '/rpc/*' },
|
|
373
|
+
{ isApi: true, urlPath: '/' },
|
|
374
|
+
{ isApi: true },
|
|
375
|
+
],
|
|
376
|
+
['bff-api/', '/internal-api'],
|
|
377
|
+
),
|
|
378
|
+
).toEqual(['/bff-api', '/rpc', '/internal-api']);
|
|
379
|
+
});
|
|
380
|
+
|
|
381
|
+
test('matches API prefixes by exact path or slash-delimited segment', () => {
|
|
382
|
+
const prefixes = ['/bff-api'];
|
|
383
|
+
|
|
384
|
+
expect(matchesApiPrefix('/bff-api', prefixes)).toBe(true);
|
|
385
|
+
expect(matchesApiPrefix('/bff-api/ping', prefixes)).toBe(true);
|
|
386
|
+
expect(matchesApiPrefix('/bff-api-v2', prefixes)).toBe(false);
|
|
387
|
+
expect(matchesApiPrefix('/bff-api-v2/ping', prefixes)).toBe(false);
|
|
388
|
+
});
|
|
389
|
+
|
|
390
|
+
test('skips language detector and redirect middleware for API routes', async () => {
|
|
391
|
+
const middlewares: any[] = [];
|
|
392
|
+
const routes = [
|
|
393
|
+
{ entryName: 'main', entryPath: '', urlPath: '/' },
|
|
394
|
+
{ entryPath: '', isApi: true, urlPath: '/bff-api' },
|
|
395
|
+
];
|
|
396
|
+
let prepare: (() => void) | undefined;
|
|
397
|
+
|
|
398
|
+
i18nServerPlugin({
|
|
399
|
+
localeDetection: {
|
|
400
|
+
fallbackLanguage: 'en',
|
|
401
|
+
languages: ['en', 'cs'],
|
|
402
|
+
localePathRedirect: true,
|
|
403
|
+
},
|
|
404
|
+
staticRoutePrefixes: [],
|
|
405
|
+
}).setup({
|
|
406
|
+
getServerConfig: () => ({}),
|
|
407
|
+
getServerContext: () => ({ middlewares, routes }),
|
|
408
|
+
onPrepare: fn => {
|
|
409
|
+
prepare = fn;
|
|
410
|
+
},
|
|
411
|
+
} as any);
|
|
412
|
+
|
|
413
|
+
prepare?.();
|
|
414
|
+
|
|
415
|
+
const detectorMiddleware = middlewares.find(
|
|
416
|
+
middleware => middleware.name === 'i18n-language-detector',
|
|
417
|
+
);
|
|
418
|
+
const redirectMiddleware = middlewares.find(
|
|
419
|
+
middleware => middleware.name === 'i18n-server-middleware',
|
|
420
|
+
);
|
|
421
|
+
|
|
422
|
+
expect(detectorMiddleware).toBeDefined();
|
|
423
|
+
expect(redirectMiddleware).toBeDefined();
|
|
424
|
+
|
|
425
|
+
for (const middleware of [detectorMiddleware, redirectMiddleware]) {
|
|
426
|
+
let nextCalls = 0;
|
|
427
|
+
const response = await middleware.handler(
|
|
428
|
+
createRequestContext('/bff-api/ping'),
|
|
429
|
+
async () => {
|
|
430
|
+
nextCalls++;
|
|
431
|
+
},
|
|
432
|
+
);
|
|
433
|
+
|
|
434
|
+
expect(response).toBeUndefined();
|
|
435
|
+
expect(nextCalls).toBe(1);
|
|
436
|
+
}
|
|
437
|
+
});
|
|
438
|
+
|
|
439
|
+
test('canonical redirect survives malformed percent-encoding', async () => {
|
|
440
|
+
const middlewares: any[] = [];
|
|
441
|
+
const routes = [{ entryName: 'main', entryPath: '', urlPath: '/' }];
|
|
442
|
+
let prepare: (() => void) | undefined;
|
|
443
|
+
|
|
444
|
+
i18nServerPlugin({
|
|
445
|
+
localeDetection: {
|
|
446
|
+
fallbackLanguage: 'en',
|
|
447
|
+
languages: ['en', 'cs'],
|
|
448
|
+
localePathRedirect: true,
|
|
449
|
+
localisedUrls: {
|
|
450
|
+
'/products/:slug': {
|
|
451
|
+
en: '/products/:slug',
|
|
452
|
+
cs: '/produkty/:slug',
|
|
453
|
+
},
|
|
454
|
+
},
|
|
455
|
+
},
|
|
456
|
+
staticRoutePrefixes: [],
|
|
457
|
+
}).setup({
|
|
458
|
+
getServerConfig: () => ({}),
|
|
459
|
+
getServerContext: () => ({ middlewares, routes }),
|
|
460
|
+
onPrepare: fn => {
|
|
461
|
+
prepare = fn;
|
|
462
|
+
},
|
|
463
|
+
} as any);
|
|
464
|
+
|
|
465
|
+
prepare?.();
|
|
466
|
+
|
|
467
|
+
const redirectMiddleware = middlewares.find(
|
|
468
|
+
middleware => middleware.name === 'i18n-server-middleware',
|
|
469
|
+
);
|
|
470
|
+
const createContext = (pathname: string) =>
|
|
471
|
+
({
|
|
472
|
+
req: {
|
|
473
|
+
url: `http://localhost${pathname}`,
|
|
474
|
+
header: () => ({ host: 'localhost' }),
|
|
475
|
+
},
|
|
476
|
+
get: () => null,
|
|
477
|
+
redirect: (url: string) => ({ redirectedTo: url }),
|
|
478
|
+
}) as any;
|
|
479
|
+
|
|
480
|
+
// Sanity: well-formed non-canonical slugs still redirect.
|
|
481
|
+
const redirected = await redirectMiddleware.handler(
|
|
482
|
+
createContext('/cs/products/bota'),
|
|
483
|
+
async () => {},
|
|
484
|
+
);
|
|
485
|
+
expect(redirected).toEqual({ redirectedTo: '/cs/produkty/bota' });
|
|
486
|
+
|
|
487
|
+
// Malformed encoding must fall through to next() instead of throwing.
|
|
488
|
+
let nextCalls = 0;
|
|
489
|
+
const response = await redirectMiddleware.handler(
|
|
490
|
+
createContext('/cs/produkty/%E0%A4%A'),
|
|
491
|
+
async () => {
|
|
492
|
+
nextCalls++;
|
|
493
|
+
},
|
|
494
|
+
);
|
|
495
|
+
expect(response).toBeUndefined();
|
|
496
|
+
expect(nextCalls).toBe(1);
|
|
497
|
+
});
|
|
498
|
+
|
|
499
|
+
test('uses /api as the BFF prefix when BFF config is present without prefix', async () => {
|
|
500
|
+
const middlewares: any[] = [];
|
|
501
|
+
const routes = [{ entryName: 'main', entryPath: '', urlPath: '/' }];
|
|
502
|
+
let prepare: (() => void) | undefined;
|
|
503
|
+
|
|
504
|
+
i18nServerPlugin({
|
|
505
|
+
localeDetection: {
|
|
506
|
+
fallbackLanguage: 'en',
|
|
507
|
+
languages: ['en', 'cs'],
|
|
508
|
+
localePathRedirect: true,
|
|
509
|
+
},
|
|
510
|
+
staticRoutePrefixes: [],
|
|
511
|
+
}).setup({
|
|
512
|
+
getServerConfig: () => ({ bff: {} }),
|
|
513
|
+
getServerContext: () => ({ middlewares, routes }),
|
|
514
|
+
onPrepare: fn => {
|
|
515
|
+
prepare = fn;
|
|
516
|
+
},
|
|
517
|
+
} as any);
|
|
518
|
+
|
|
519
|
+
prepare?.();
|
|
520
|
+
|
|
521
|
+
const redirectMiddleware = middlewares.find(
|
|
522
|
+
middleware => middleware.name === 'i18n-server-middleware',
|
|
523
|
+
);
|
|
524
|
+
|
|
525
|
+
let nextCalls = 0;
|
|
526
|
+
const response = await redirectMiddleware.handler(
|
|
527
|
+
createRequestContext('/api/ping'),
|
|
528
|
+
async () => {
|
|
529
|
+
nextCalls++;
|
|
530
|
+
},
|
|
531
|
+
);
|
|
532
|
+
|
|
533
|
+
expect(response).toBeUndefined();
|
|
534
|
+
expect(nextCalls).toBe(1);
|
|
535
|
+
});
|
|
536
|
+
});
|