@bleedingdev/modern-js-plugin-i18n 3.9.0-ultramodern.3 → 3.9.0-ultramodern.5
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/cli/index.js +0 -22
- package/dist/cjs/runtime/Link.js +19 -32
- package/dist/cjs/runtime/context.js +26 -61
- package/dist/cjs/runtime/contextHelpers.js +5 -5
- package/dist/cjs/runtime/core.js +17 -14
- package/dist/cjs/runtime/hooks.js +7 -8
- package/dist/cjs/runtime/localizedPaths.js +11 -9
- package/dist/cjs/runtime/providerComposition.js +37 -13
- package/dist/cjs/runtime/routerAdapter.js +35 -140
- package/dist/cjs/runtime/utils.js +17 -19
- package/dist/cjs/server/index.js +10 -7
- package/dist/cjs/server/redirectPolicy.js +36 -31
- package/dist/cjs/shared/urlStrategy.js +18 -0
- package/dist/esm/cli/index.mjs +0 -22
- package/dist/esm/runtime/Link.mjs +19 -32
- package/dist/esm/runtime/context.mjs +18 -62
- package/dist/esm/runtime/contextHelpers.mjs +5 -5
- package/dist/esm/runtime/core.mjs +8 -5
- package/dist/esm/runtime/hooks.mjs +7 -8
- package/dist/esm/runtime/localizedPaths.mjs +11 -9
- package/dist/esm/runtime/providerComposition.mjs +38 -14
- package/dist/esm/runtime/routerAdapter.mjs +30 -141
- package/dist/esm/runtime/utils.mjs +15 -17
- package/dist/esm/server/index.mjs +10 -7
- package/dist/esm/server/redirectPolicy.mjs +29 -24
- package/dist/esm/shared/urlStrategy.mjs +0 -0
- package/dist/esm-node/cli/index.mjs +0 -22
- package/dist/esm-node/runtime/Link.mjs +19 -32
- package/dist/esm-node/runtime/context.mjs +18 -62
- package/dist/esm-node/runtime/contextHelpers.mjs +5 -5
- package/dist/esm-node/runtime/core.mjs +8 -5
- package/dist/esm-node/runtime/hooks.mjs +7 -8
- package/dist/esm-node/runtime/localizedPaths.mjs +11 -9
- package/dist/esm-node/runtime/providerComposition.mjs +38 -14
- package/dist/esm-node/runtime/routerAdapter.mjs +30 -141
- package/dist/esm-node/runtime/utils.mjs +15 -17
- package/dist/esm-node/server/index.mjs +10 -7
- package/dist/esm-node/server/redirectPolicy.mjs +29 -24
- package/dist/esm-node/shared/urlStrategy.mjs +1 -0
- package/dist/types/runtime/context.d.ts +8 -11
- package/dist/types/runtime/contextHelpers.d.ts +2 -2
- package/dist/types/runtime/core.d.ts +11 -2
- package/dist/types/runtime/hooks.d.ts +4 -5
- package/dist/types/runtime/localizedPaths.d.ts +4 -4
- package/dist/types/runtime/providerComposition.d.ts +13 -1
- package/dist/types/runtime/routerAdapter.d.ts +23 -6
- package/dist/types/runtime/utils.d.ts +4 -12
- package/dist/types/server/index.d.ts +2 -0
- package/dist/types/server/redirectPolicy.d.ts +5 -13
- package/dist/types/shared/type.d.ts +0 -14
- package/dist/types/shared/urlStrategy.d.ts +7 -0
- package/package.json +21 -11
- package/rstest.config.mts +2 -8
- package/src/cli/index.ts +1 -43
- package/src/runtime/Link.tsx +20 -40
- package/src/runtime/context.tsx +34 -133
- package/src/runtime/contextHelpers.ts +7 -5
- package/src/runtime/core.tsx +26 -6
- package/src/runtime/hooks.ts +14 -9
- package/src/runtime/localizedPaths.ts +24 -19
- package/src/runtime/providerComposition.tsx +71 -23
- package/src/runtime/routerAdapter.tsx +73 -338
- package/src/runtime/utils.ts +40 -31
- package/src/server/index.ts +44 -9
- package/src/server/redirectPolicy.ts +70 -70
- package/src/shared/type.ts +0 -14
- package/src/shared/urlStrategy.ts +11 -0
- package/tests/federatedI18nBoundaryResolver.test.tsx +71 -0
- package/tests/i18nUtils.test.ts +31 -65
- package/tests/link.test.tsx +13 -511
- package/tests/linkTypes.test.ts +12 -3
- package/tests/localisedUrlRewriteMatrix.fork.test.ts +4 -96
- package/tests/localisedUrls.test.ts +7 -216
- package/tests/reactI18nextRuntimeBoundary.test.ts +1 -82
- package/tests/redirectPolicy.test.ts +39 -30
- package/tests/routerAdapter.test.tsx +95 -1055
- package/tests/federatedI18nBoundary.client.test.tsx +0 -129
- package/tests/federatedI18nBoundary.test.tsx +0 -132
- package/tests/peerCohort.test.ts +0 -25
package/tests/link.test.tsx
CHANGED
|
@@ -1,73 +1,10 @@
|
|
|
1
|
-
import { InternalRuntimeContext } from '@modern-js/runtime/context';
|
|
2
|
-
import type React from 'react';
|
|
3
|
-
import { act } from 'react';
|
|
4
|
-
import { createRoot, type Root } from 'react-dom/client';
|
|
5
|
-
import { ModernI18nProvider } from '../src/runtime/context';
|
|
6
1
|
import type { I18nInstance } from '../src/runtime/i18n';
|
|
7
2
|
import { detectLanguageWithPriority } from '../src/runtime/i18n/detection';
|
|
8
|
-
import { interpolateRouteParams
|
|
9
|
-
import {
|
|
10
|
-
canonicalPath,
|
|
11
|
-
localizePath,
|
|
12
|
-
useLocalizedLocation,
|
|
13
|
-
} from '../src/runtime/localizedPaths';
|
|
3
|
+
import { interpolateRouteParams } from '../src/runtime/Link';
|
|
4
|
+
import { canonicalPath, localizePath } from '../src/runtime/localizedPaths';
|
|
14
5
|
import { buildLocalizedUrl, splitUrlTarget } from '../src/runtime/utils';
|
|
15
6
|
|
|
16
|
-
(
|
|
17
|
-
globalThis as { IS_REACT_ACT_ENVIRONMENT?: boolean }
|
|
18
|
-
).IS_REACT_ACT_ENVIRONMENT = true;
|
|
19
|
-
|
|
20
|
-
const localisedUrls = {
|
|
21
|
-
'/terms-of-service': {
|
|
22
|
-
en: '/terms-of-service',
|
|
23
|
-
cs: '/podminky-pouzivani',
|
|
24
|
-
},
|
|
25
|
-
'/products': {
|
|
26
|
-
en: '/products',
|
|
27
|
-
cs: '/produkty',
|
|
28
|
-
},
|
|
29
|
-
'/products/:slug': {
|
|
30
|
-
en: '/products/:slug',
|
|
31
|
-
cs: '/produkty/:slug',
|
|
32
|
-
},
|
|
33
|
-
// Canonical key that matches no language pattern.
|
|
34
|
-
'/talks/:slug': {
|
|
35
|
-
en: '/lectures/:slug',
|
|
36
|
-
cs: '/prednasky/:slug',
|
|
37
|
-
},
|
|
38
|
-
};
|
|
39
|
-
|
|
40
7
|
const languages = ['en', 'cs'];
|
|
41
|
-
const pathsConfig = { languages, localisedUrls };
|
|
42
|
-
|
|
43
|
-
const requestContext = {
|
|
44
|
-
request: {},
|
|
45
|
-
response: {},
|
|
46
|
-
};
|
|
47
|
-
|
|
48
|
-
const capturedLinkProps: any[] = [];
|
|
49
|
-
|
|
50
|
-
// Mirrors the real TanStack Link contract: it consumes its own props
|
|
51
|
-
// (`preload`, `search`, `hash`, ...) and spreads everything else onto the
|
|
52
|
-
// anchor. Deliberately does NOT strip `prefetch` — TanStack has no such prop,
|
|
53
|
-
// so a forwarded `prefetch` would leak into the DOM and fail assertions.
|
|
54
|
-
const TanstackLink = ({ to, children, ...props }: any) => {
|
|
55
|
-
capturedLinkProps.push({ to, ...props });
|
|
56
|
-
const {
|
|
57
|
-
preload: _preload,
|
|
58
|
-
search: _search,
|
|
59
|
-
hash: _hash,
|
|
60
|
-
hashScrollIntoView: _hashScrollIntoView,
|
|
61
|
-
replace: _replace,
|
|
62
|
-
...anchorProps
|
|
63
|
-
} = props;
|
|
64
|
-
|
|
65
|
-
return (
|
|
66
|
-
<a href={to} data-router-link="tanstack" {...anchorProps}>
|
|
67
|
-
{children}
|
|
68
|
-
</a>
|
|
69
|
-
);
|
|
70
|
-
};
|
|
71
8
|
|
|
72
9
|
function createI18nInstance(language = 'en'): I18nInstance {
|
|
73
10
|
return {
|
|
@@ -82,95 +19,10 @@ function createI18nInstance(language = 'en'): I18nInstance {
|
|
|
82
19
|
};
|
|
83
20
|
}
|
|
84
21
|
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
navigate: rstest.fn(async () => undefined),
|
|
90
|
-
state: {
|
|
91
|
-
location: {
|
|
92
|
-
pathname: url.pathname,
|
|
93
|
-
searchStr: url.search,
|
|
94
|
-
hash: url.hash,
|
|
95
|
-
},
|
|
96
|
-
matches: [{ params: { lang } }],
|
|
97
|
-
},
|
|
98
|
-
};
|
|
99
|
-
}
|
|
100
|
-
|
|
101
|
-
function createTanstackRuntimeContext(router: unknown) {
|
|
102
|
-
return {
|
|
103
|
-
isBrowser: true,
|
|
104
|
-
requestContext,
|
|
105
|
-
context: requestContext,
|
|
106
|
-
routerFramework: 'tanstack',
|
|
107
|
-
routerInstance: router,
|
|
108
|
-
routerRuntime: {
|
|
109
|
-
framework: 'tanstack',
|
|
110
|
-
instance: router,
|
|
111
|
-
},
|
|
112
|
-
router: {
|
|
113
|
-
Link: TanstackLink,
|
|
114
|
-
useRouter: () => router,
|
|
115
|
-
},
|
|
116
|
-
} as any;
|
|
117
|
-
}
|
|
118
|
-
|
|
119
|
-
function providerValue(language: string) {
|
|
120
|
-
return {
|
|
121
|
-
language,
|
|
122
|
-
i18nInstance: createI18nInstance(language),
|
|
123
|
-
languages,
|
|
124
|
-
localePathRedirect: true,
|
|
125
|
-
localisedUrls,
|
|
126
|
-
};
|
|
127
|
-
}
|
|
128
|
-
|
|
129
|
-
async function renderWithRuntime(node: React.ReactNode, runtimeContext: any) {
|
|
130
|
-
const container = document.createElement('div');
|
|
131
|
-
document.body.appendChild(container);
|
|
132
|
-
const root = createRoot(container);
|
|
133
|
-
|
|
134
|
-
await act(async () => {
|
|
135
|
-
root.render(
|
|
136
|
-
<InternalRuntimeContext.Provider value={runtimeContext}>
|
|
137
|
-
{node}
|
|
138
|
-
</InternalRuntimeContext.Provider>,
|
|
139
|
-
);
|
|
140
|
-
});
|
|
141
|
-
|
|
142
|
-
return { container, root };
|
|
143
|
-
}
|
|
144
|
-
|
|
145
|
-
function cleanup(rendered?: { container: HTMLElement; root: Root }) {
|
|
146
|
-
if (!rendered) {
|
|
147
|
-
return;
|
|
148
|
-
}
|
|
149
|
-
act(() => {
|
|
150
|
-
rendered.root.unmount();
|
|
151
|
-
});
|
|
152
|
-
rendered.container.remove();
|
|
153
|
-
}
|
|
154
|
-
|
|
155
|
-
describe('splitUrlTarget', () => {
|
|
156
|
-
test('splits pathname, search and hash', () => {
|
|
157
|
-
expect(splitUrlTarget('/talks?tag=x#abstract')).toEqual({
|
|
158
|
-
pathname: '/talks',
|
|
159
|
-
search: '?tag=x',
|
|
160
|
-
hash: '#abstract',
|
|
161
|
-
});
|
|
162
|
-
expect(splitUrlTarget('/#work-with-me')).toEqual({
|
|
163
|
-
pathname: '/',
|
|
164
|
-
search: '',
|
|
165
|
-
hash: '#work-with-me',
|
|
166
|
-
});
|
|
167
|
-
expect(splitUrlTarget('/talks')).toEqual({
|
|
168
|
-
pathname: '/talks',
|
|
169
|
-
search: '',
|
|
170
|
-
hash: '#'.slice(1) === '' ? '' : '',
|
|
171
|
-
});
|
|
172
|
-
expect(splitUrlTarget('?q=1#x')).toEqual({
|
|
173
|
-
pathname: '',
|
|
22
|
+
describe('splitUrlTarget public compatibility export', () => {
|
|
23
|
+
test('retains the shared suffix splitter', () => {
|
|
24
|
+
expect(splitUrlTarget('/a?q=1#x')).toEqual({
|
|
25
|
+
pathname: '/a',
|
|
174
26
|
search: '?q=1',
|
|
175
27
|
hash: '#x',
|
|
176
28
|
});
|
|
@@ -190,39 +42,8 @@ describe('buildLocalizedUrl suffix handling', () => {
|
|
|
190
42
|
);
|
|
191
43
|
});
|
|
192
44
|
|
|
193
|
-
test('query and hash survive localized slug mapping', () => {
|
|
194
|
-
expect(
|
|
195
|
-
buildLocalizedUrl(
|
|
196
|
-
'/products/bota?tag=x#detail',
|
|
197
|
-
'cs',
|
|
198
|
-
languages,
|
|
199
|
-
localisedUrls,
|
|
200
|
-
),
|
|
201
|
-
).toBe('/cs/produkty/bota?tag=x#detail');
|
|
202
|
-
});
|
|
203
|
-
|
|
204
45
|
test('root path', () => {
|
|
205
|
-
expect(buildLocalizedUrl('/', 'cs', languages
|
|
206
|
-
});
|
|
207
|
-
|
|
208
|
-
test('localizes canonical keys that match no language pattern', () => {
|
|
209
|
-
expect(
|
|
210
|
-
buildLocalizedUrl(
|
|
211
|
-
'/talks/ai-slop#abstract',
|
|
212
|
-
'cs',
|
|
213
|
-
languages,
|
|
214
|
-
localisedUrls,
|
|
215
|
-
),
|
|
216
|
-
).toBe('/cs/prednasky/ai-slop#abstract');
|
|
217
|
-
expect(
|
|
218
|
-
buildLocalizedUrl('/talks/ai-slop', 'en', languages, localisedUrls),
|
|
219
|
-
).toBe('/en/lectures/ai-slop');
|
|
220
|
-
});
|
|
221
|
-
|
|
222
|
-
test('re-localizes already-localized paths', () => {
|
|
223
|
-
expect(
|
|
224
|
-
buildLocalizedUrl('/cs/produkty/bota#x', 'en', languages, localisedUrls),
|
|
225
|
-
).toBe('/en/products/bota#x');
|
|
46
|
+
expect(buildLocalizedUrl('/', 'cs', languages)).toBe('/cs');
|
|
226
47
|
});
|
|
227
48
|
});
|
|
228
49
|
|
|
@@ -252,23 +73,14 @@ describe('interpolateRouteParams', () => {
|
|
|
252
73
|
});
|
|
253
74
|
});
|
|
254
75
|
|
|
255
|
-
describe('
|
|
256
|
-
test('
|
|
257
|
-
expect(localizePath('/products
|
|
258
|
-
'/cs/
|
|
76
|
+
describe('native URL strategy helpers', () => {
|
|
77
|
+
test('preserves suffixes when adding or stripping only a language prefix', () => {
|
|
78
|
+
expect(localizePath('/products?q=1#x', 'cs', { languages })).toBe(
|
|
79
|
+
'/cs/products?q=1#x',
|
|
259
80
|
);
|
|
260
|
-
expect(
|
|
261
|
-
|
|
262
|
-
|
|
263
|
-
test('canonicalPath strips language and reverse-maps localized slugs', () => {
|
|
264
|
-
expect(canonicalPath('/cs/produkty/bota', pathsConfig)).toBe(
|
|
265
|
-
'/products/bota',
|
|
81
|
+
expect(canonicalPath('/CS/products?q=1#x', { languages })).toBe(
|
|
82
|
+
'/products?q=1#x',
|
|
266
83
|
);
|
|
267
|
-
expect(canonicalPath('/en/lectures/x?q=1#h', pathsConfig)).toBe(
|
|
268
|
-
'/talks/x?q=1#h',
|
|
269
|
-
);
|
|
270
|
-
expect(canonicalPath('/cs', pathsConfig)).toBe('/');
|
|
271
|
-
expect(canonicalPath('/en/products', pathsConfig)).toBe('/products');
|
|
272
84
|
});
|
|
273
85
|
});
|
|
274
86
|
|
|
@@ -325,313 +137,3 @@ describe('language detection priority', () => {
|
|
|
325
137
|
}
|
|
326
138
|
});
|
|
327
139
|
});
|
|
328
|
-
|
|
329
|
-
describe('framework Link', () => {
|
|
330
|
-
let rendered: { container: HTMLElement; root: Root } | undefined;
|
|
331
|
-
|
|
332
|
-
afterEach(() => {
|
|
333
|
-
cleanup(rendered);
|
|
334
|
-
rendered = undefined;
|
|
335
|
-
capturedLinkProps.length = 0;
|
|
336
|
-
});
|
|
337
|
-
|
|
338
|
-
test('localizes canonical paths through the TanStack Link', async () => {
|
|
339
|
-
const router = createTanstackRouter('/cs/podminky-pouzivani', 'cs');
|
|
340
|
-
rendered = await renderWithRuntime(
|
|
341
|
-
<ModernI18nProvider value={providerValue('cs')}>
|
|
342
|
-
<Link to="/products/$slug" params={{ slug: 'bota' }} data-testid="p">
|
|
343
|
-
Product
|
|
344
|
-
</Link>
|
|
345
|
-
</ModernI18nProvider>,
|
|
346
|
-
createTanstackRuntimeContext(router),
|
|
347
|
-
);
|
|
348
|
-
|
|
349
|
-
const link = rendered.container.querySelector('[data-testid="p"]');
|
|
350
|
-
expect(link?.getAttribute('href')).toBe('/cs/produkty/bota');
|
|
351
|
-
expect(link?.getAttribute('data-router-link')).toBe('tanstack');
|
|
352
|
-
});
|
|
353
|
-
|
|
354
|
-
test('passes hash natively for cross-page hash targets', async () => {
|
|
355
|
-
const router = createTanstackRouter('/cs/podminky-pouzivani', 'cs');
|
|
356
|
-
rendered = await renderWithRuntime(
|
|
357
|
-
<ModernI18nProvider value={providerValue('cs')}>
|
|
358
|
-
<Link to="/#work-with-me" data-testid="cta">
|
|
359
|
-
CTA
|
|
360
|
-
</Link>
|
|
361
|
-
</ModernI18nProvider>,
|
|
362
|
-
createTanstackRuntimeContext(router),
|
|
363
|
-
);
|
|
364
|
-
|
|
365
|
-
const props = capturedLinkProps.at(-1);
|
|
366
|
-
expect(props.to).toBe('/cs');
|
|
367
|
-
expect(props.hash).toBe('work-with-me');
|
|
368
|
-
});
|
|
369
|
-
|
|
370
|
-
test('passes query and hash from the target natively', async () => {
|
|
371
|
-
const router = createTanstackRouter('/en/products', 'en');
|
|
372
|
-
rendered = await renderWithRuntime(
|
|
373
|
-
<ModernI18nProvider value={providerValue('en')}>
|
|
374
|
-
<Link to="/products?tag=x#list" data-testid="q">
|
|
375
|
-
Products
|
|
376
|
-
</Link>
|
|
377
|
-
</ModernI18nProvider>,
|
|
378
|
-
createTanstackRuntimeContext(router),
|
|
379
|
-
);
|
|
380
|
-
|
|
381
|
-
const props = capturedLinkProps.at(-1);
|
|
382
|
-
expect(props.to).toBe('/en/products');
|
|
383
|
-
expect(props.search).toEqual({ tag: 'x' });
|
|
384
|
-
expect(props.hash).toBe('list');
|
|
385
|
-
});
|
|
386
|
-
|
|
387
|
-
test('preserves array search values natively', async () => {
|
|
388
|
-
const router = createTanstackRouter('/en/products', 'en');
|
|
389
|
-
rendered = await renderWithRuntime(
|
|
390
|
-
<ModernI18nProvider value={providerValue('en')}>
|
|
391
|
-
<Link
|
|
392
|
-
to="/products"
|
|
393
|
-
search={{ tag: ['boots', 'sale'], page: 2 }}
|
|
394
|
-
data-testid="q"
|
|
395
|
-
>
|
|
396
|
-
Products
|
|
397
|
-
</Link>
|
|
398
|
-
</ModernI18nProvider>,
|
|
399
|
-
createTanstackRuntimeContext(router),
|
|
400
|
-
);
|
|
401
|
-
|
|
402
|
-
const props = capturedLinkProps.at(-1);
|
|
403
|
-
expect(props.to).toBe('/en/products');
|
|
404
|
-
expect(props.search).toEqual({ tag: ['boots', 'sale'], page: '2' });
|
|
405
|
-
});
|
|
406
|
-
|
|
407
|
-
test('renders a plain anchor for external targets', async () => {
|
|
408
|
-
const router = createTanstackRouter('/en', 'en');
|
|
409
|
-
rendered = await renderWithRuntime(
|
|
410
|
-
<ModernI18nProvider value={providerValue('en')}>
|
|
411
|
-
<Link to="https://ai.bleeding.dev" data-testid="ext" prefetch="none">
|
|
412
|
-
AI
|
|
413
|
-
</Link>
|
|
414
|
-
</ModernI18nProvider>,
|
|
415
|
-
createTanstackRuntimeContext(router),
|
|
416
|
-
);
|
|
417
|
-
|
|
418
|
-
const link = rendered.container.querySelector('[data-testid="ext"]');
|
|
419
|
-
expect(link?.getAttribute('href')).toBe('https://ai.bleeding.dev');
|
|
420
|
-
expect(link?.getAttribute('data-router-link')).toBeNull();
|
|
421
|
-
expect(link?.hasAttribute('prefetch')).toBe(false);
|
|
422
|
-
});
|
|
423
|
-
|
|
424
|
-
test('renders a plain anchor for same-page hash targets', async () => {
|
|
425
|
-
const router = createTanstackRouter('/en', 'en');
|
|
426
|
-
rendered = await renderWithRuntime(
|
|
427
|
-
<ModernI18nProvider value={providerValue('en')}>
|
|
428
|
-
<Link to="#work-with-me" data-testid="anchor">
|
|
429
|
-
Jump
|
|
430
|
-
</Link>
|
|
431
|
-
</ModernI18nProvider>,
|
|
432
|
-
createTanstackRuntimeContext(router),
|
|
433
|
-
);
|
|
434
|
-
|
|
435
|
-
const link = rendered.container.querySelector('[data-testid="anchor"]');
|
|
436
|
-
expect(link?.getAttribute('href')).toBe('#work-with-me');
|
|
437
|
-
expect(link?.getAttribute('data-router-link')).toBeNull();
|
|
438
|
-
});
|
|
439
|
-
|
|
440
|
-
test('falls back to a localized anchor without a router', async () => {
|
|
441
|
-
rendered = await renderWithRuntime(
|
|
442
|
-
<ModernI18nProvider value={providerValue('cs')}>
|
|
443
|
-
<Link
|
|
444
|
-
to="/products/$slug?tag=x#detail"
|
|
445
|
-
params={{ slug: 'bota' }}
|
|
446
|
-
data-testid="f"
|
|
447
|
-
prefetch="viewport"
|
|
448
|
-
>
|
|
449
|
-
Product
|
|
450
|
-
</Link>
|
|
451
|
-
</ModernI18nProvider>,
|
|
452
|
-
{ isBrowser: true, requestContext, context: requestContext } as any,
|
|
453
|
-
);
|
|
454
|
-
|
|
455
|
-
const link = rendered.container.querySelector('[data-testid="f"]');
|
|
456
|
-
expect(link?.getAttribute('href')).toBe('/cs/produkty/bota?tag=x#detail');
|
|
457
|
-
expect(link?.hasAttribute('prefetch')).toBe(false);
|
|
458
|
-
});
|
|
459
|
-
|
|
460
|
-
test('serializes array search values for fallback anchors', async () => {
|
|
461
|
-
rendered = await renderWithRuntime(
|
|
462
|
-
<ModernI18nProvider value={providerValue('en')}>
|
|
463
|
-
<Link
|
|
464
|
-
to="/products"
|
|
465
|
-
search={{ tag: ['boots', 'sale'], page: 2 }}
|
|
466
|
-
data-testid="q"
|
|
467
|
-
>
|
|
468
|
-
Products
|
|
469
|
-
</Link>
|
|
470
|
-
</ModernI18nProvider>,
|
|
471
|
-
{ isBrowser: true, requestContext, context: requestContext } as any,
|
|
472
|
-
);
|
|
473
|
-
|
|
474
|
-
const link = rendered.container.querySelector('[data-testid="q"]');
|
|
475
|
-
expect(link?.getAttribute('href')).toBe(
|
|
476
|
-
'/en/products?tag=boots&tag=sale&page=2',
|
|
477
|
-
);
|
|
478
|
-
});
|
|
479
|
-
|
|
480
|
-
test('empty search prop clears query from target fallback anchors', async () => {
|
|
481
|
-
rendered = await renderWithRuntime(
|
|
482
|
-
<ModernI18nProvider value={providerValue('en')}>
|
|
483
|
-
<Link to="/products?tag=x" search="" data-testid="clear-query">
|
|
484
|
-
Products
|
|
485
|
-
</Link>
|
|
486
|
-
</ModernI18nProvider>,
|
|
487
|
-
{ isBrowser: true, requestContext, context: requestContext } as any,
|
|
488
|
-
);
|
|
489
|
-
|
|
490
|
-
const link = rendered.container.querySelector(
|
|
491
|
-
'[data-testid="clear-query"]',
|
|
492
|
-
);
|
|
493
|
-
expect(link?.getAttribute('href')).toBe('/en/products');
|
|
494
|
-
});
|
|
495
|
-
|
|
496
|
-
test('maps prefetch to the TanStack preload prop', async () => {
|
|
497
|
-
const router = createTanstackRouter('/en/products', 'en');
|
|
498
|
-
rendered = await renderWithRuntime(
|
|
499
|
-
<ModernI18nProvider value={providerValue('en')}>
|
|
500
|
-
<Link to="/products" data-testid="pf" prefetch="intent">
|
|
501
|
-
Products
|
|
502
|
-
</Link>
|
|
503
|
-
</ModernI18nProvider>,
|
|
504
|
-
createTanstackRuntimeContext(router),
|
|
505
|
-
);
|
|
506
|
-
|
|
507
|
-
const props = capturedLinkProps.at(-1);
|
|
508
|
-
expect(props.preload).toBe('intent');
|
|
509
|
-
expect(props.prefetch).toBeUndefined();
|
|
510
|
-
|
|
511
|
-
const link = rendered.container.querySelector('[data-testid="pf"]');
|
|
512
|
-
expect(link?.hasAttribute('prefetch')).toBe(false);
|
|
513
|
-
});
|
|
514
|
-
|
|
515
|
-
test('maps prefetch="none" to preload={false}; explicit preload wins', async () => {
|
|
516
|
-
const router = createTanstackRouter('/en/products', 'en');
|
|
517
|
-
rendered = await renderWithRuntime(
|
|
518
|
-
<ModernI18nProvider value={providerValue('en')}>
|
|
519
|
-
<Link to="/products" data-testid="none" prefetch="none">
|
|
520
|
-
Products
|
|
521
|
-
</Link>
|
|
522
|
-
<Link
|
|
523
|
-
to="/products"
|
|
524
|
-
data-testid="explicit"
|
|
525
|
-
prefetch="intent"
|
|
526
|
-
preload="viewport"
|
|
527
|
-
>
|
|
528
|
-
Products
|
|
529
|
-
</Link>
|
|
530
|
-
</ModernI18nProvider>,
|
|
531
|
-
createTanstackRuntimeContext(router),
|
|
532
|
-
);
|
|
533
|
-
|
|
534
|
-
const noneProps = capturedLinkProps[capturedLinkProps.length - 2];
|
|
535
|
-
expect(noneProps.preload).toBe(false);
|
|
536
|
-
expect(noneProps.prefetch).toBeUndefined();
|
|
537
|
-
|
|
538
|
-
const explicitProps = capturedLinkProps.at(-1);
|
|
539
|
-
expect(explicitProps.preload).toBe('viewport');
|
|
540
|
-
expect(explicitProps.prefetch).toBeUndefined();
|
|
541
|
-
});
|
|
542
|
-
|
|
543
|
-
test('marks the canonical target active on any localized variant', async () => {
|
|
544
|
-
const router = createTanstackRouter('/cs/podminky-pouzivani', 'cs');
|
|
545
|
-
rendered = await renderWithRuntime(
|
|
546
|
-
<ModernI18nProvider value={providerValue('cs')}>
|
|
547
|
-
<Link
|
|
548
|
-
to="/terms-of-service"
|
|
549
|
-
data-testid="active-link"
|
|
550
|
-
activeProps={{ className: 'is-active' }}
|
|
551
|
-
className="nav"
|
|
552
|
-
>
|
|
553
|
-
Terms
|
|
554
|
-
</Link>
|
|
555
|
-
<Link to="/products" data-testid="inactive-link">
|
|
556
|
-
Products
|
|
557
|
-
</Link>
|
|
558
|
-
</ModernI18nProvider>,
|
|
559
|
-
createTanstackRuntimeContext(router),
|
|
560
|
-
);
|
|
561
|
-
|
|
562
|
-
const active = rendered.container.querySelector(
|
|
563
|
-
'[data-testid="active-link"]',
|
|
564
|
-
);
|
|
565
|
-
expect(active?.getAttribute('data-status')).toBe('active');
|
|
566
|
-
expect(active?.getAttribute('aria-current')).toBe('page');
|
|
567
|
-
expect(active?.getAttribute('class')).toBe('nav is-active');
|
|
568
|
-
|
|
569
|
-
const inactive = rendered.container.querySelector(
|
|
570
|
-
'[data-testid="inactive-link"]',
|
|
571
|
-
);
|
|
572
|
-
expect(inactive?.getAttribute('data-status')).toBeNull();
|
|
573
|
-
expect(inactive?.getAttribute('aria-current')).toBeNull();
|
|
574
|
-
});
|
|
575
|
-
|
|
576
|
-
test('prefix-matches nested locations unless exact is requested', async () => {
|
|
577
|
-
const router = createTanstackRouter('/en/products/shoe', 'en');
|
|
578
|
-
rendered = await renderWithRuntime(
|
|
579
|
-
<ModernI18nProvider value={providerValue('en')}>
|
|
580
|
-
<Link to="/products" data-testid="prefix">
|
|
581
|
-
Products
|
|
582
|
-
</Link>
|
|
583
|
-
<Link
|
|
584
|
-
to="/products"
|
|
585
|
-
activeOptions={{ exact: true }}
|
|
586
|
-
data-testid="exact"
|
|
587
|
-
>
|
|
588
|
-
Products
|
|
589
|
-
</Link>
|
|
590
|
-
<Link to="/" data-testid="root">
|
|
591
|
-
Home
|
|
592
|
-
</Link>
|
|
593
|
-
</ModernI18nProvider>,
|
|
594
|
-
createTanstackRuntimeContext(router),
|
|
595
|
-
);
|
|
596
|
-
|
|
597
|
-
expect(
|
|
598
|
-
rendered.container
|
|
599
|
-
.querySelector('[data-testid="prefix"]')
|
|
600
|
-
?.getAttribute('data-status'),
|
|
601
|
-
).toBe('active');
|
|
602
|
-
expect(
|
|
603
|
-
rendered.container
|
|
604
|
-
.querySelector('[data-testid="exact"]')
|
|
605
|
-
?.getAttribute('data-status'),
|
|
606
|
-
).toBeNull();
|
|
607
|
-
expect(
|
|
608
|
-
rendered.container
|
|
609
|
-
.querySelector('[data-testid="root"]')
|
|
610
|
-
?.getAttribute('data-status'),
|
|
611
|
-
).toBeNull();
|
|
612
|
-
});
|
|
613
|
-
|
|
614
|
-
test('useLocalizedLocation exposes per-language alternates', async () => {
|
|
615
|
-
const router = createTanstackRouter('/cs/podminky-pouzivani?q=1#top', 'cs');
|
|
616
|
-
let snapshot: ReturnType<typeof useLocalizedLocation> | undefined;
|
|
617
|
-
|
|
618
|
-
const Probe = () => {
|
|
619
|
-
snapshot = useLocalizedLocation();
|
|
620
|
-
return null;
|
|
621
|
-
};
|
|
622
|
-
|
|
623
|
-
rendered = await renderWithRuntime(
|
|
624
|
-
<ModernI18nProvider value={providerValue('cs')}>
|
|
625
|
-
<Probe />
|
|
626
|
-
</ModernI18nProvider>,
|
|
627
|
-
createTanstackRuntimeContext(router),
|
|
628
|
-
);
|
|
629
|
-
|
|
630
|
-
expect(snapshot?.language).toBe('cs');
|
|
631
|
-
expect(snapshot?.canonical).toBe('/terms-of-service');
|
|
632
|
-
expect(snapshot?.alternates).toEqual({
|
|
633
|
-
en: '/en/terms-of-service?q=1#top',
|
|
634
|
-
cs: '/cs/podminky-pouzivani?q=1#top',
|
|
635
|
-
});
|
|
636
|
-
});
|
|
637
|
-
});
|
package/tests/linkTypes.test.ts
CHANGED
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import { execFileSync } from 'node:child_process';
|
|
2
|
+
import fs from 'node:fs';
|
|
2
3
|
import path from 'node:path';
|
|
3
4
|
|
|
4
5
|
const fixtureDir = path.resolve(__dirname, 'type-fixture');
|
|
@@ -18,9 +19,17 @@ function resolveTsgoBin() {
|
|
|
18
19
|
}
|
|
19
20
|
|
|
20
21
|
const tsgoBin = resolveTsgoBin();
|
|
21
|
-
const
|
|
22
|
-
|
|
23
|
-
|
|
22
|
+
for (const artifact of [
|
|
23
|
+
'dist/types/runtime/context.d.ts',
|
|
24
|
+
'dist/cjs/cli/index.js',
|
|
25
|
+
'dist/cjs/shared/utils.js',
|
|
26
|
+
]) {
|
|
27
|
+
if (!fs.existsSync(path.resolve(__dirname, '..', artifact))) {
|
|
28
|
+
throw new Error(
|
|
29
|
+
`Missing plugin-i18n build artifact ${artifact}. Run pnpm build:required before unit tests.`,
|
|
30
|
+
);
|
|
31
|
+
}
|
|
32
|
+
}
|
|
24
33
|
|
|
25
34
|
describe('Link type-level tests', () => {
|
|
26
35
|
test('fixture type-checks correctly: valid uses compile, invalid uses are rejected', () => {
|
|
@@ -1,107 +1,15 @@
|
|
|
1
|
-
import type { LocalisedUrlsMap } from '@modern-js/i18n-runtime-extensions';
|
|
2
1
|
import { describe, expect, test } from '@rstest/core';
|
|
3
2
|
import {
|
|
4
3
|
interpolateRouteParams,
|
|
5
4
|
normalizeSearch,
|
|
6
5
|
} from '../src/runtime/linkHelpers';
|
|
7
|
-
import { canonicalPath, localizePath } from '../src/runtime/localizedPaths';
|
|
8
|
-
import { buildLocalizedUrl } from '../src/runtime/utils';
|
|
9
|
-
|
|
10
|
-
const languages = ['en', 'cs'];
|
|
11
|
-
|
|
12
|
-
const localisedUrls = {
|
|
13
|
-
'/products/:slug': {
|
|
14
|
-
en: '/products/:slug',
|
|
15
|
-
cs: '/produkty/:slug',
|
|
16
|
-
},
|
|
17
|
-
'/files/*': {
|
|
18
|
-
en: '/files/*',
|
|
19
|
-
cs: '/soubory/*',
|
|
20
|
-
},
|
|
21
|
-
'/app/products/:slug': {
|
|
22
|
-
en: '/app/products/:slug',
|
|
23
|
-
cs: '/app/produkty/:slug',
|
|
24
|
-
},
|
|
25
|
-
} satisfies LocalisedUrlsMap;
|
|
26
|
-
|
|
27
|
-
const pathsConfig = {
|
|
28
|
-
languages,
|
|
29
|
-
localisedUrls,
|
|
30
|
-
};
|
|
31
|
-
|
|
32
|
-
describe('fork-owned localised URL rewrite matrix', () => {
|
|
33
|
-
const rewriteScenarios = [
|
|
34
|
-
{
|
|
35
|
-
name: 'adds the target locale prefix and localises canonical segments',
|
|
36
|
-
target: '/products/red-shoe',
|
|
37
|
-
language: 'cs',
|
|
38
|
-
expected: '/cs/produkty/red-shoe',
|
|
39
|
-
},
|
|
40
|
-
{
|
|
41
|
-
name: 'strips a case-insensitive source locale before relocalising',
|
|
42
|
-
target: '/CS/produkty/red%20shoe?tag=boots#details',
|
|
43
|
-
language: 'en',
|
|
44
|
-
expected: '/en/products/red%20shoe?tag=boots#details',
|
|
45
|
-
},
|
|
46
|
-
{
|
|
47
|
-
name: 'preserves repeated query keys and the fragment',
|
|
48
|
-
target: '/products/red-shoe?tag=boots&tag=sale#details',
|
|
49
|
-
language: 'cs',
|
|
50
|
-
expected: '/cs/produkty/red-shoe?tag=boots&tag=sale#details',
|
|
51
|
-
},
|
|
52
|
-
{
|
|
53
|
-
name: 'keeps a base-path segment inside the localised route map',
|
|
54
|
-
target: '/app/products/red-shoe?ref=nav#details',
|
|
55
|
-
language: 'cs',
|
|
56
|
-
expected: '/cs/app/produkty/red-shoe?ref=nav#details',
|
|
57
|
-
},
|
|
58
|
-
{
|
|
59
|
-
name: 'keeps the default locale explicitly prefixed',
|
|
60
|
-
target: '/products/red-shoe',
|
|
61
|
-
language: 'en',
|
|
62
|
-
expected: '/en/products/red-shoe',
|
|
63
|
-
},
|
|
64
|
-
{
|
|
65
|
-
name: 'does not double-prefix an already-localised URL',
|
|
66
|
-
target: '/cs/produkty/red-shoe?tag=boots#details',
|
|
67
|
-
language: 'cs',
|
|
68
|
-
expected: '/cs/produkty/red-shoe?tag=boots#details',
|
|
69
|
-
},
|
|
70
|
-
];
|
|
71
|
-
|
|
72
|
-
for (const scenario of rewriteScenarios) {
|
|
73
|
-
test(scenario.name, () => {
|
|
74
|
-
expect(
|
|
75
|
-
buildLocalizedUrl(
|
|
76
|
-
scenario.target,
|
|
77
|
-
scenario.language,
|
|
78
|
-
languages,
|
|
79
|
-
localisedUrls,
|
|
80
|
-
),
|
|
81
|
-
).toBe(scenario.expected);
|
|
82
|
-
});
|
|
83
|
-
}
|
|
84
|
-
|
|
85
|
-
test('canonical and localized path helpers strip and add prefixes exactly', () => {
|
|
86
|
-
expect(
|
|
87
|
-
canonicalPath('/CS/produkty/red-shoe?tag=boots#details', pathsConfig),
|
|
88
|
-
).toBe('/products/red-shoe?tag=boots#details');
|
|
89
|
-
expect(
|
|
90
|
-
localizePath('/products/red-shoe?tag=boots#details', 'cs', pathsConfig),
|
|
91
|
-
).toBe('/cs/produkty/red-shoe?tag=boots#details');
|
|
92
|
-
});
|
|
93
6
|
|
|
7
|
+
describe('native link target normalization', () => {
|
|
94
8
|
test('splat params preserve separators and percent-encode each segment', () => {
|
|
95
|
-
|
|
96
|
-
'*': 'resume drafts/Q1 deck.pdf',
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
expect(target).toBe('/files/resume%20drafts/Q1%20deck.pdf');
|
|
100
|
-
expect(buildLocalizedUrl(target, 'cs', languages, localisedUrls)).toBe(
|
|
101
|
-
'/cs/soubory/resume%20drafts/Q1%20deck.pdf',
|
|
102
|
-
);
|
|
9
|
+
expect(
|
|
10
|
+
interpolateRouteParams('/files/*', { '*': 'resume drafts/Q1 deck.pdf' }),
|
|
11
|
+
).toBe('/files/resume%20drafts/Q1%20deck.pdf');
|
|
103
12
|
});
|
|
104
|
-
|
|
105
13
|
const searchScenarios: Array<{
|
|
106
14
|
name: string;
|
|
107
15
|
search: Parameters<typeof normalizeSearch>[0];
|