@bleedingdev/modern-js-plugin-i18n 3.2.0-ultramodern.119 → 3.2.0-ultramodern.120
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/runtime/I18nLink.js +7 -17
- package/dist/cjs/runtime/Link.js +252 -0
- package/dist/cjs/runtime/canonicalRoutes.js +18 -0
- package/dist/cjs/runtime/index.js +23 -0
- package/dist/cjs/runtime/localizedPaths.js +105 -0
- package/dist/cjs/runtime/utils.js +22 -5
- package/dist/cjs/shared/localisedUrls.js +32 -2
- package/dist/esm/runtime/I18nLink.mjs +6 -16
- package/dist/esm/runtime/Link.mjs +209 -0
- package/dist/esm/runtime/canonicalRoutes.mjs +0 -0
- package/dist/esm/runtime/index.mjs +4 -2
- package/dist/esm/runtime/localizedPaths.mjs +58 -0
- package/dist/esm/runtime/utils.mjs +18 -4
- package/dist/esm/shared/localisedUrls.mjs +24 -3
- package/dist/esm-node/runtime/I18nLink.mjs +6 -16
- package/dist/esm-node/runtime/Link.mjs +210 -0
- package/dist/esm-node/runtime/canonicalRoutes.mjs +1 -0
- package/dist/esm-node/runtime/index.mjs +4 -2
- package/dist/esm-node/runtime/localizedPaths.mjs +59 -0
- package/dist/esm-node/runtime/utils.mjs +18 -4
- package/dist/esm-node/shared/localisedUrls.mjs +24 -3
- package/dist/types/runtime/I18nLink.d.ts +4 -13
- package/dist/types/runtime/Link.d.ts +56 -0
- package/dist/types/runtime/canonicalRoutes.d.ts +60 -0
- package/dist/types/runtime/index.d.ts +5 -1
- package/dist/types/runtime/localizedPaths.d.ts +39 -0
- package/dist/types/runtime/utils.d.ts +12 -3
- package/dist/types/shared/localisedUrls.d.ts +8 -0
- package/package.json +13 -13
- package/rstest.config.mts +2 -2
- package/src/runtime/I18nLink.tsx +13 -46
- package/src/runtime/Link.tsx +414 -0
- package/src/runtime/canonicalRoutes.ts +93 -0
- package/src/runtime/index.tsx +24 -2
- package/src/runtime/localizedPaths.ts +118 -0
- package/src/runtime/utils.ts +24 -5
- package/src/shared/localisedUrls.ts +63 -3
- package/tests/link.test.tsx +475 -0
- package/tests/linkTypes.test.ts +28 -0
- package/tests/type-fixture/linkTypes.fixture.tsx +51 -0
- package/tests/type-fixture/tsconfig.json +15 -0
|
@@ -0,0 +1,475 @@
|
|
|
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
|
+
import type { I18nInstance } from '../src/runtime/i18n';
|
|
7
|
+
import { interpolateRouteParams, Link } from '../src/runtime/Link';
|
|
8
|
+
import {
|
|
9
|
+
canonicalPath,
|
|
10
|
+
localizePath,
|
|
11
|
+
useLocalizedLocation,
|
|
12
|
+
} from '../src/runtime/localizedPaths';
|
|
13
|
+
import { buildLocalizedUrl, splitUrlTarget } from '../src/runtime/utils';
|
|
14
|
+
|
|
15
|
+
(
|
|
16
|
+
globalThis as { IS_REACT_ACT_ENVIRONMENT?: boolean }
|
|
17
|
+
).IS_REACT_ACT_ENVIRONMENT = true;
|
|
18
|
+
|
|
19
|
+
const localisedUrls = {
|
|
20
|
+
'/terms-of-service': {
|
|
21
|
+
en: '/terms-of-service',
|
|
22
|
+
cs: '/podminky-pouzivani',
|
|
23
|
+
},
|
|
24
|
+
'/products': {
|
|
25
|
+
en: '/products',
|
|
26
|
+
cs: '/produkty',
|
|
27
|
+
},
|
|
28
|
+
'/products/:slug': {
|
|
29
|
+
en: '/products/:slug',
|
|
30
|
+
cs: '/produkty/:slug',
|
|
31
|
+
},
|
|
32
|
+
// Canonical key that matches no language pattern.
|
|
33
|
+
'/talks/:slug': {
|
|
34
|
+
en: '/lectures/:slug',
|
|
35
|
+
cs: '/prednasky/:slug',
|
|
36
|
+
},
|
|
37
|
+
};
|
|
38
|
+
|
|
39
|
+
const languages = ['en', 'cs'];
|
|
40
|
+
const pathsConfig = { languages, localisedUrls };
|
|
41
|
+
|
|
42
|
+
const requestContext = {
|
|
43
|
+
request: {},
|
|
44
|
+
response: {},
|
|
45
|
+
};
|
|
46
|
+
|
|
47
|
+
const capturedLinkProps: any[] = [];
|
|
48
|
+
|
|
49
|
+
const TanstackLink = ({ to, children, ...props }: any) => {
|
|
50
|
+
capturedLinkProps.push({ to, ...props });
|
|
51
|
+
const {
|
|
52
|
+
prefetch: _prefetch,
|
|
53
|
+
preload: _preload,
|
|
54
|
+
search: _search,
|
|
55
|
+
hash: _hash,
|
|
56
|
+
hashScrollIntoView: _hashScrollIntoView,
|
|
57
|
+
replace: _replace,
|
|
58
|
+
...anchorProps
|
|
59
|
+
} = props;
|
|
60
|
+
|
|
61
|
+
return (
|
|
62
|
+
<a href={to} data-router-link="tanstack" {...anchorProps}>
|
|
63
|
+
{children}
|
|
64
|
+
</a>
|
|
65
|
+
);
|
|
66
|
+
};
|
|
67
|
+
|
|
68
|
+
function createI18nInstance(language = 'en'): I18nInstance {
|
|
69
|
+
return {
|
|
70
|
+
language,
|
|
71
|
+
isInitialized: true,
|
|
72
|
+
init: () => Promise.resolve(undefined),
|
|
73
|
+
use: () => {},
|
|
74
|
+
createInstance: () => createI18nInstance(language),
|
|
75
|
+
services: {},
|
|
76
|
+
options: {},
|
|
77
|
+
};
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
function createTanstackRouter(target = '/en/terms-of-service', lang = 'en') {
|
|
81
|
+
const url = new URL(target, 'https://modernjs.test');
|
|
82
|
+
|
|
83
|
+
return {
|
|
84
|
+
navigate: rstest.fn(async () => undefined),
|
|
85
|
+
state: {
|
|
86
|
+
location: {
|
|
87
|
+
pathname: url.pathname,
|
|
88
|
+
searchStr: url.search,
|
|
89
|
+
hash: url.hash,
|
|
90
|
+
},
|
|
91
|
+
matches: [{ params: { lang } }],
|
|
92
|
+
},
|
|
93
|
+
};
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
function createTanstackRuntimeContext(router: unknown) {
|
|
97
|
+
return {
|
|
98
|
+
isBrowser: true,
|
|
99
|
+
requestContext,
|
|
100
|
+
context: requestContext,
|
|
101
|
+
routerFramework: 'tanstack',
|
|
102
|
+
routerInstance: router,
|
|
103
|
+
routerRuntime: {
|
|
104
|
+
framework: 'tanstack',
|
|
105
|
+
instance: router,
|
|
106
|
+
},
|
|
107
|
+
router: {
|
|
108
|
+
Link: TanstackLink,
|
|
109
|
+
useRouter: () => router,
|
|
110
|
+
},
|
|
111
|
+
} as any;
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
function providerValue(language: string) {
|
|
115
|
+
return {
|
|
116
|
+
language,
|
|
117
|
+
i18nInstance: createI18nInstance(language),
|
|
118
|
+
languages,
|
|
119
|
+
localePathRedirect: true,
|
|
120
|
+
localisedUrls,
|
|
121
|
+
};
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
async function renderWithRuntime(node: React.ReactNode, runtimeContext: any) {
|
|
125
|
+
const container = document.createElement('div');
|
|
126
|
+
document.body.appendChild(container);
|
|
127
|
+
const root = createRoot(container);
|
|
128
|
+
|
|
129
|
+
await act(async () => {
|
|
130
|
+
root.render(
|
|
131
|
+
<InternalRuntimeContext.Provider value={runtimeContext}>
|
|
132
|
+
{node}
|
|
133
|
+
</InternalRuntimeContext.Provider>,
|
|
134
|
+
);
|
|
135
|
+
});
|
|
136
|
+
|
|
137
|
+
return { container, root };
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
function cleanup(rendered?: { container: HTMLElement; root: Root }) {
|
|
141
|
+
if (!rendered) {
|
|
142
|
+
return;
|
|
143
|
+
}
|
|
144
|
+
act(() => {
|
|
145
|
+
rendered.root.unmount();
|
|
146
|
+
});
|
|
147
|
+
rendered.container.remove();
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
describe('splitUrlTarget', () => {
|
|
151
|
+
test('splits pathname, search and hash', () => {
|
|
152
|
+
expect(splitUrlTarget('/talks?tag=x#abstract')).toEqual({
|
|
153
|
+
pathname: '/talks',
|
|
154
|
+
search: '?tag=x',
|
|
155
|
+
hash: '#abstract',
|
|
156
|
+
});
|
|
157
|
+
expect(splitUrlTarget('/#work-with-me')).toEqual({
|
|
158
|
+
pathname: '/',
|
|
159
|
+
search: '',
|
|
160
|
+
hash: '#work-with-me',
|
|
161
|
+
});
|
|
162
|
+
expect(splitUrlTarget('/talks')).toEqual({
|
|
163
|
+
pathname: '/talks',
|
|
164
|
+
search: '',
|
|
165
|
+
hash: '#'.slice(1) === '' ? '' : '',
|
|
166
|
+
});
|
|
167
|
+
expect(splitUrlTarget('?q=1#x')).toEqual({
|
|
168
|
+
pathname: '',
|
|
169
|
+
search: '?q=1',
|
|
170
|
+
hash: '#x',
|
|
171
|
+
});
|
|
172
|
+
});
|
|
173
|
+
});
|
|
174
|
+
|
|
175
|
+
describe('buildLocalizedUrl suffix handling', () => {
|
|
176
|
+
test('hash-only target keeps the hash and drops the trailing slash', () => {
|
|
177
|
+
expect(buildLocalizedUrl('/#work-with-me', 'en', languages)).toBe(
|
|
178
|
+
'/en#work-with-me',
|
|
179
|
+
);
|
|
180
|
+
});
|
|
181
|
+
|
|
182
|
+
test('query-only target keeps the query', () => {
|
|
183
|
+
expect(buildLocalizedUrl('/products?tag=x', 'en', languages)).toBe(
|
|
184
|
+
'/en/products?tag=x',
|
|
185
|
+
);
|
|
186
|
+
});
|
|
187
|
+
|
|
188
|
+
test('query and hash survive localized slug mapping', () => {
|
|
189
|
+
expect(
|
|
190
|
+
buildLocalizedUrl(
|
|
191
|
+
'/products/bota?tag=x#detail',
|
|
192
|
+
'cs',
|
|
193
|
+
languages,
|
|
194
|
+
localisedUrls,
|
|
195
|
+
),
|
|
196
|
+
).toBe('/cs/produkty/bota?tag=x#detail');
|
|
197
|
+
});
|
|
198
|
+
|
|
199
|
+
test('root path', () => {
|
|
200
|
+
expect(buildLocalizedUrl('/', 'cs', languages, localisedUrls)).toBe('/cs');
|
|
201
|
+
});
|
|
202
|
+
|
|
203
|
+
test('localizes canonical keys that match no language pattern', () => {
|
|
204
|
+
expect(
|
|
205
|
+
buildLocalizedUrl(
|
|
206
|
+
'/talks/ai-slop#abstract',
|
|
207
|
+
'cs',
|
|
208
|
+
languages,
|
|
209
|
+
localisedUrls,
|
|
210
|
+
),
|
|
211
|
+
).toBe('/cs/prednasky/ai-slop#abstract');
|
|
212
|
+
expect(
|
|
213
|
+
buildLocalizedUrl('/talks/ai-slop', 'en', languages, localisedUrls),
|
|
214
|
+
).toBe('/en/lectures/ai-slop');
|
|
215
|
+
});
|
|
216
|
+
|
|
217
|
+
test('re-localizes already-localized paths', () => {
|
|
218
|
+
expect(
|
|
219
|
+
buildLocalizedUrl('/cs/produkty/bota#x', 'en', languages, localisedUrls),
|
|
220
|
+
).toBe('/en/products/bota#x');
|
|
221
|
+
});
|
|
222
|
+
});
|
|
223
|
+
|
|
224
|
+
describe('interpolateRouteParams', () => {
|
|
225
|
+
test('interpolates $param and :param segments', () => {
|
|
226
|
+
expect(interpolateRouteParams('/talks/$slug', { slug: 'ai slop' })).toBe(
|
|
227
|
+
'/talks/ai%20slop',
|
|
228
|
+
);
|
|
229
|
+
expect(interpolateRouteParams('/talks/:slug', { slug: 'x' })).toBe(
|
|
230
|
+
'/talks/x',
|
|
231
|
+
);
|
|
232
|
+
});
|
|
233
|
+
|
|
234
|
+
test('drops missing optional segments', () => {
|
|
235
|
+
expect(interpolateRouteParams('/opt/{-$slug}', {})).toBe('/opt');
|
|
236
|
+
expect(interpolateRouteParams('/opt/:slug?', {})).toBe('/opt');
|
|
237
|
+
expect(interpolateRouteParams('/opt/{-$slug}', { slug: 'v' })).toBe(
|
|
238
|
+
'/opt/v',
|
|
239
|
+
);
|
|
240
|
+
});
|
|
241
|
+
|
|
242
|
+
test('expands splat params', () => {
|
|
243
|
+
expect(interpolateRouteParams('/files/$', { _splat: 'a/b' })).toBe(
|
|
244
|
+
'/files/a/b',
|
|
245
|
+
);
|
|
246
|
+
expect(interpolateRouteParams('/files/*', { '*': 'a' })).toBe('/files/a');
|
|
247
|
+
});
|
|
248
|
+
});
|
|
249
|
+
|
|
250
|
+
describe('localization utilities', () => {
|
|
251
|
+
test('localizePath maps canonical paths per language', () => {
|
|
252
|
+
expect(localizePath('/products/bota', 'cs', pathsConfig)).toBe(
|
|
253
|
+
'/cs/produkty/bota',
|
|
254
|
+
);
|
|
255
|
+
expect(localizePath('/talks/x', 'en', pathsConfig)).toBe('/en/lectures/x');
|
|
256
|
+
});
|
|
257
|
+
|
|
258
|
+
test('canonicalPath strips language and reverse-maps localized slugs', () => {
|
|
259
|
+
expect(canonicalPath('/cs/produkty/bota', pathsConfig)).toBe(
|
|
260
|
+
'/products/bota',
|
|
261
|
+
);
|
|
262
|
+
expect(canonicalPath('/en/lectures/x?q=1#h', pathsConfig)).toBe(
|
|
263
|
+
'/talks/x?q=1#h',
|
|
264
|
+
);
|
|
265
|
+
expect(canonicalPath('/cs', pathsConfig)).toBe('/');
|
|
266
|
+
expect(canonicalPath('/en/products', pathsConfig)).toBe('/products');
|
|
267
|
+
});
|
|
268
|
+
});
|
|
269
|
+
|
|
270
|
+
describe('framework Link', () => {
|
|
271
|
+
let rendered: { container: HTMLElement; root: Root } | undefined;
|
|
272
|
+
|
|
273
|
+
afterEach(() => {
|
|
274
|
+
cleanup(rendered);
|
|
275
|
+
rendered = undefined;
|
|
276
|
+
capturedLinkProps.length = 0;
|
|
277
|
+
});
|
|
278
|
+
|
|
279
|
+
test('localizes canonical paths through the TanStack Link', async () => {
|
|
280
|
+
const router = createTanstackRouter('/cs/podminky-pouzivani', 'cs');
|
|
281
|
+
rendered = await renderWithRuntime(
|
|
282
|
+
<ModernI18nProvider value={providerValue('cs')}>
|
|
283
|
+
<Link to="/products/$slug" params={{ slug: 'bota' }} data-testid="p">
|
|
284
|
+
Product
|
|
285
|
+
</Link>
|
|
286
|
+
</ModernI18nProvider>,
|
|
287
|
+
createTanstackRuntimeContext(router),
|
|
288
|
+
);
|
|
289
|
+
|
|
290
|
+
const link = rendered.container.querySelector('[data-testid="p"]');
|
|
291
|
+
expect(link?.getAttribute('href')).toBe('/cs/produkty/bota');
|
|
292
|
+
expect(link?.getAttribute('data-router-link')).toBe('tanstack');
|
|
293
|
+
});
|
|
294
|
+
|
|
295
|
+
test('passes hash natively for cross-page hash targets', async () => {
|
|
296
|
+
const router = createTanstackRouter('/cs/podminky-pouzivani', 'cs');
|
|
297
|
+
rendered = await renderWithRuntime(
|
|
298
|
+
<ModernI18nProvider value={providerValue('cs')}>
|
|
299
|
+
<Link to="/#work-with-me" data-testid="cta">
|
|
300
|
+
CTA
|
|
301
|
+
</Link>
|
|
302
|
+
</ModernI18nProvider>,
|
|
303
|
+
createTanstackRuntimeContext(router),
|
|
304
|
+
);
|
|
305
|
+
|
|
306
|
+
const props = capturedLinkProps.at(-1);
|
|
307
|
+
expect(props.to).toBe('/cs');
|
|
308
|
+
expect(props.hash).toBe('work-with-me');
|
|
309
|
+
});
|
|
310
|
+
|
|
311
|
+
test('passes query and hash from the target natively', async () => {
|
|
312
|
+
const router = createTanstackRouter('/en/products', 'en');
|
|
313
|
+
rendered = await renderWithRuntime(
|
|
314
|
+
<ModernI18nProvider value={providerValue('en')}>
|
|
315
|
+
<Link to="/products?tag=x#list" data-testid="q">
|
|
316
|
+
Products
|
|
317
|
+
</Link>
|
|
318
|
+
</ModernI18nProvider>,
|
|
319
|
+
createTanstackRuntimeContext(router),
|
|
320
|
+
);
|
|
321
|
+
|
|
322
|
+
const props = capturedLinkProps.at(-1);
|
|
323
|
+
expect(props.to).toBe('/en/products');
|
|
324
|
+
expect(props.search).toEqual({ tag: 'x' });
|
|
325
|
+
expect(props.hash).toBe('list');
|
|
326
|
+
});
|
|
327
|
+
|
|
328
|
+
test('renders a plain anchor for external targets', async () => {
|
|
329
|
+
const router = createTanstackRouter('/en', 'en');
|
|
330
|
+
rendered = await renderWithRuntime(
|
|
331
|
+
<ModernI18nProvider value={providerValue('en')}>
|
|
332
|
+
<Link to="https://ai.bleeding.dev" data-testid="ext" prefetch="none">
|
|
333
|
+
AI
|
|
334
|
+
</Link>
|
|
335
|
+
</ModernI18nProvider>,
|
|
336
|
+
createTanstackRuntimeContext(router),
|
|
337
|
+
);
|
|
338
|
+
|
|
339
|
+
const link = rendered.container.querySelector('[data-testid="ext"]');
|
|
340
|
+
expect(link?.getAttribute('href')).toBe('https://ai.bleeding.dev');
|
|
341
|
+
expect(link?.getAttribute('data-router-link')).toBeNull();
|
|
342
|
+
expect(link?.hasAttribute('prefetch')).toBe(false);
|
|
343
|
+
});
|
|
344
|
+
|
|
345
|
+
test('renders a plain anchor for same-page hash targets', async () => {
|
|
346
|
+
const router = createTanstackRouter('/en', 'en');
|
|
347
|
+
rendered = await renderWithRuntime(
|
|
348
|
+
<ModernI18nProvider value={providerValue('en')}>
|
|
349
|
+
<Link to="#work-with-me" data-testid="anchor">
|
|
350
|
+
Jump
|
|
351
|
+
</Link>
|
|
352
|
+
</ModernI18nProvider>,
|
|
353
|
+
createTanstackRuntimeContext(router),
|
|
354
|
+
);
|
|
355
|
+
|
|
356
|
+
const link = rendered.container.querySelector('[data-testid="anchor"]');
|
|
357
|
+
expect(link?.getAttribute('href')).toBe('#work-with-me');
|
|
358
|
+
expect(link?.getAttribute('data-router-link')).toBeNull();
|
|
359
|
+
});
|
|
360
|
+
|
|
361
|
+
test('falls back to a localized anchor without a router', async () => {
|
|
362
|
+
rendered = await renderWithRuntime(
|
|
363
|
+
<ModernI18nProvider value={providerValue('cs')}>
|
|
364
|
+
<Link
|
|
365
|
+
to="/products/$slug?tag=x#detail"
|
|
366
|
+
params={{ slug: 'bota' }}
|
|
367
|
+
data-testid="f"
|
|
368
|
+
prefetch="viewport"
|
|
369
|
+
>
|
|
370
|
+
Product
|
|
371
|
+
</Link>
|
|
372
|
+
</ModernI18nProvider>,
|
|
373
|
+
{ isBrowser: true, requestContext, context: requestContext } as any,
|
|
374
|
+
);
|
|
375
|
+
|
|
376
|
+
const link = rendered.container.querySelector('[data-testid="f"]');
|
|
377
|
+
expect(link?.getAttribute('href')).toBe('/cs/produkty/bota?tag=x#detail');
|
|
378
|
+
expect(link?.hasAttribute('prefetch')).toBe(false);
|
|
379
|
+
});
|
|
380
|
+
|
|
381
|
+
test('marks the canonical target active on any localized variant', async () => {
|
|
382
|
+
const router = createTanstackRouter('/cs/podminky-pouzivani', 'cs');
|
|
383
|
+
rendered = await renderWithRuntime(
|
|
384
|
+
<ModernI18nProvider value={providerValue('cs')}>
|
|
385
|
+
<Link
|
|
386
|
+
to="/terms-of-service"
|
|
387
|
+
data-testid="active-link"
|
|
388
|
+
activeProps={{ className: 'is-active' }}
|
|
389
|
+
className="nav"
|
|
390
|
+
>
|
|
391
|
+
Terms
|
|
392
|
+
</Link>
|
|
393
|
+
<Link to="/products" data-testid="inactive-link">
|
|
394
|
+
Products
|
|
395
|
+
</Link>
|
|
396
|
+
</ModernI18nProvider>,
|
|
397
|
+
createTanstackRuntimeContext(router),
|
|
398
|
+
);
|
|
399
|
+
|
|
400
|
+
const active = rendered.container.querySelector(
|
|
401
|
+
'[data-testid="active-link"]',
|
|
402
|
+
);
|
|
403
|
+
expect(active?.getAttribute('data-status')).toBe('active');
|
|
404
|
+
expect(active?.getAttribute('aria-current')).toBe('page');
|
|
405
|
+
expect(active?.getAttribute('class')).toBe('nav is-active');
|
|
406
|
+
|
|
407
|
+
const inactive = rendered.container.querySelector(
|
|
408
|
+
'[data-testid="inactive-link"]',
|
|
409
|
+
);
|
|
410
|
+
expect(inactive?.getAttribute('data-status')).toBeNull();
|
|
411
|
+
expect(inactive?.getAttribute('aria-current')).toBeNull();
|
|
412
|
+
});
|
|
413
|
+
|
|
414
|
+
test('prefix-matches nested locations unless exact is requested', async () => {
|
|
415
|
+
const router = createTanstackRouter('/en/products/shoe', 'en');
|
|
416
|
+
rendered = await renderWithRuntime(
|
|
417
|
+
<ModernI18nProvider value={providerValue('en')}>
|
|
418
|
+
<Link to="/products" data-testid="prefix">
|
|
419
|
+
Products
|
|
420
|
+
</Link>
|
|
421
|
+
<Link
|
|
422
|
+
to="/products"
|
|
423
|
+
activeOptions={{ exact: true }}
|
|
424
|
+
data-testid="exact"
|
|
425
|
+
>
|
|
426
|
+
Products
|
|
427
|
+
</Link>
|
|
428
|
+
<Link to="/" data-testid="root">
|
|
429
|
+
Home
|
|
430
|
+
</Link>
|
|
431
|
+
</ModernI18nProvider>,
|
|
432
|
+
createTanstackRuntimeContext(router),
|
|
433
|
+
);
|
|
434
|
+
|
|
435
|
+
expect(
|
|
436
|
+
rendered.container
|
|
437
|
+
.querySelector('[data-testid="prefix"]')
|
|
438
|
+
?.getAttribute('data-status'),
|
|
439
|
+
).toBe('active');
|
|
440
|
+
expect(
|
|
441
|
+
rendered.container
|
|
442
|
+
.querySelector('[data-testid="exact"]')
|
|
443
|
+
?.getAttribute('data-status'),
|
|
444
|
+
).toBeNull();
|
|
445
|
+
expect(
|
|
446
|
+
rendered.container
|
|
447
|
+
.querySelector('[data-testid="root"]')
|
|
448
|
+
?.getAttribute('data-status'),
|
|
449
|
+
).toBeNull();
|
|
450
|
+
});
|
|
451
|
+
|
|
452
|
+
test('useLocalizedLocation exposes per-language alternates', async () => {
|
|
453
|
+
const router = createTanstackRouter('/cs/podminky-pouzivani?q=1#top', 'cs');
|
|
454
|
+
let snapshot: ReturnType<typeof useLocalizedLocation> | undefined;
|
|
455
|
+
|
|
456
|
+
const Probe = () => {
|
|
457
|
+
snapshot = useLocalizedLocation();
|
|
458
|
+
return null;
|
|
459
|
+
};
|
|
460
|
+
|
|
461
|
+
rendered = await renderWithRuntime(
|
|
462
|
+
<ModernI18nProvider value={providerValue('cs')}>
|
|
463
|
+
<Probe />
|
|
464
|
+
</ModernI18nProvider>,
|
|
465
|
+
createTanstackRuntimeContext(router),
|
|
466
|
+
);
|
|
467
|
+
|
|
468
|
+
expect(snapshot?.language).toBe('cs');
|
|
469
|
+
expect(snapshot?.canonical).toBe('/terms-of-service');
|
|
470
|
+
expect(snapshot?.alternates).toEqual({
|
|
471
|
+
en: '/en/terms-of-service?q=1#top',
|
|
472
|
+
cs: '/cs/podminky-pouzivani?q=1#top',
|
|
473
|
+
});
|
|
474
|
+
});
|
|
475
|
+
});
|
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
import { execFileSync } from 'node:child_process';
|
|
2
|
+
import path from 'node:path';
|
|
3
|
+
|
|
4
|
+
const fixtureDir = path.resolve(__dirname, 'type-fixture');
|
|
5
|
+
|
|
6
|
+
const tsgoBin = path.join(
|
|
7
|
+
path.dirname(require.resolve('@typescript/native-preview/package.json')),
|
|
8
|
+
'bin/tsgo.js',
|
|
9
|
+
);
|
|
10
|
+
|
|
11
|
+
describe('Link type-level tests', () => {
|
|
12
|
+
test('fixture type-checks correctly: valid uses compile, invalid uses are rejected', () => {
|
|
13
|
+
try {
|
|
14
|
+
execFileSync(
|
|
15
|
+
process.execPath,
|
|
16
|
+
[tsgoBin, '--noEmit', '-p', 'tsconfig.json'],
|
|
17
|
+
{
|
|
18
|
+
cwd: fixtureDir,
|
|
19
|
+
stdio: 'pipe',
|
|
20
|
+
},
|
|
21
|
+
);
|
|
22
|
+
} catch (e: any) {
|
|
23
|
+
const stdout = e?.stdout ? String(e.stdout) : '';
|
|
24
|
+
const stderr = e?.stderr ? String(e.stderr) : '';
|
|
25
|
+
throw new Error(`TypeScript type-check failed:\n${stdout}\n${stderr}`);
|
|
26
|
+
}
|
|
27
|
+
}, 60_000);
|
|
28
|
+
});
|
|
@@ -0,0 +1,51 @@
|
|
|
1
|
+
import { Link } from '@modern-js/plugin-i18n/runtime';
|
|
2
|
+
|
|
3
|
+
declare module '@modern-js/plugin-i18n/runtime' {
|
|
4
|
+
interface UltramodernCanonicalRoutes {
|
|
5
|
+
'/': Record<string, never>;
|
|
6
|
+
'/talks': Record<string, never>;
|
|
7
|
+
'/talks/$slug': { slug: string };
|
|
8
|
+
}
|
|
9
|
+
}
|
|
10
|
+
|
|
11
|
+
// --- valid uses ---
|
|
12
|
+
|
|
13
|
+
// Known route with required params: must compile.
|
|
14
|
+
const _a = <Link to="/talks/$slug" params={{ slug: 'x' }} />;
|
|
15
|
+
|
|
16
|
+
// Known route without params: must compile.
|
|
17
|
+
const _b = <Link to="/talks" />;
|
|
18
|
+
|
|
19
|
+
// Root route: must compile.
|
|
20
|
+
const _c = <Link to="/" />;
|
|
21
|
+
|
|
22
|
+
// Canonical path with hash suffix: must compile.
|
|
23
|
+
const _d = <Link to="/#work-with-me" />;
|
|
24
|
+
|
|
25
|
+
// Canonical path with query and hash: must compile.
|
|
26
|
+
const _e = <Link to="/talks?tag=x#abstract" />;
|
|
27
|
+
|
|
28
|
+
// Bare hash: must compile.
|
|
29
|
+
const _f = <Link to="#hash" />;
|
|
30
|
+
|
|
31
|
+
// External HTTPS URL: must compile.
|
|
32
|
+
const _g = <Link to="https://example.com" />;
|
|
33
|
+
|
|
34
|
+
// Dynamic (computed) value — escape hatch: must compile.
|
|
35
|
+
declare function compute(): string;
|
|
36
|
+
const dynamic: string = compute();
|
|
37
|
+
const _h = <Link to={dynamic} />;
|
|
38
|
+
|
|
39
|
+
// --- invalid uses (each preceded by @ts-expect-error) ---
|
|
40
|
+
|
|
41
|
+
// Unknown route.
|
|
42
|
+
// @ts-expect-error
|
|
43
|
+
const _i = <Link to="/talkz" />;
|
|
44
|
+
|
|
45
|
+
// Known param route but params prop is missing.
|
|
46
|
+
// @ts-expect-error
|
|
47
|
+
const _j = <Link to="/talks/$slug" />;
|
|
48
|
+
|
|
49
|
+
// Known route without params but params are provided (forbidden).
|
|
50
|
+
// @ts-expect-error
|
|
51
|
+
const _k = <Link to="/talks" params={{ slug: 'x' }} />;
|
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
{
|
|
2
|
+
"compilerOptions": {
|
|
3
|
+
"jsx": "react-jsx",
|
|
4
|
+
"strict": true,
|
|
5
|
+
"moduleResolution": "Bundler",
|
|
6
|
+
"module": "Preserve",
|
|
7
|
+
"noEmit": true,
|
|
8
|
+
"skipLibCheck": true,
|
|
9
|
+
"lib": ["ESNext", "DOM"],
|
|
10
|
+
"paths": {
|
|
11
|
+
"@modern-js/plugin-i18n/runtime": ["../../src/runtime/index.tsx"]
|
|
12
|
+
}
|
|
13
|
+
},
|
|
14
|
+
"include": ["*.tsx", "*.ts"]
|
|
15
|
+
}
|