@bleedingdev/modern-js-plugin-i18n 3.9.0-ultramodern.2 → 3.9.0-ultramodern.21
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 +10 -21
- package/dist/cjs/cli/localisedRoutes.js +47 -0
- package/dist/cjs/runtime/Link.js +19 -32
- package/dist/cjs/runtime/context.js +30 -73
- package/dist/cjs/runtime/contextHelpers.js +5 -5
- package/dist/cjs/runtime/contexts.js +49 -0
- package/dist/cjs/runtime/core.js +27 -14
- package/dist/cjs/runtime/hooks.js +7 -8
- package/dist/cjs/runtime/localizedPaths.js +11 -9
- package/dist/cjs/runtime/navigation.js +48 -0
- package/dist/cjs/runtime/providerComposition.js +37 -13
- package/dist/cjs/runtime/routerAdapter.js +33 -140
- package/dist/cjs/runtime/sharedContexts.js +43 -0
- package/dist/cjs/runtime/utils.js +21 -19
- package/dist/cjs/server/defaultUrlStrategy.js +48 -0
- package/dist/cjs/server/index.js +11 -7
- package/dist/cjs/server/mappedUrlStrategy.js +43 -0
- package/dist/cjs/server/redirectPolicy.js +33 -24
- package/dist/cjs/shared/mappedUrlStrategy.js +63 -0
- package/dist/cjs/shared/urlStrategy.js +48 -0
- package/dist/esm/cli/index.mjs +10 -21
- package/dist/esm/cli/localisedRoutes.mjs +9 -0
- package/dist/esm/runtime/Link.mjs +19 -32
- package/dist/esm/runtime/context.mjs +20 -72
- package/dist/esm/runtime/contextHelpers.mjs +5 -5
- package/dist/esm/runtime/contexts.mjs +5 -0
- package/dist/esm/runtime/core.mjs +13 -6
- package/dist/esm/runtime/hooks.mjs +7 -8
- package/dist/esm/runtime/localizedPaths.mjs +11 -9
- package/dist/esm/runtime/navigation.mjs +7 -0
- package/dist/esm/runtime/providerComposition.mjs +38 -14
- package/dist/esm/runtime/routerAdapter.mjs +28 -141
- package/dist/esm/runtime/sharedContexts.mjs +1 -0
- package/dist/esm/runtime/utils.mjs +19 -17
- package/dist/esm/server/defaultUrlStrategy.mjs +7 -0
- package/dist/esm/server/index.mjs +11 -7
- package/dist/esm/server/mappedUrlStrategy.mjs +1 -0
- package/dist/esm/server/redirectPolicy.mjs +33 -24
- package/dist/esm/shared/mappedUrlStrategy.mjs +19 -0
- package/dist/esm/shared/urlStrategy.mjs +7 -0
- package/dist/esm-node/cli/index.mjs +10 -21
- package/dist/esm-node/cli/localisedRoutes.mjs +10 -0
- package/dist/esm-node/runtime/Link.mjs +19 -32
- package/dist/esm-node/runtime/context.mjs +20 -72
- package/dist/esm-node/runtime/contextHelpers.mjs +5 -5
- package/dist/esm-node/runtime/contexts.mjs +6 -0
- package/dist/esm-node/runtime/core.mjs +13 -6
- package/dist/esm-node/runtime/hooks.mjs +7 -8
- package/dist/esm-node/runtime/localizedPaths.mjs +11 -9
- package/dist/esm-node/runtime/navigation.mjs +8 -0
- package/dist/esm-node/runtime/providerComposition.mjs +38 -14
- package/dist/esm-node/runtime/routerAdapter.mjs +28 -141
- package/dist/esm-node/runtime/sharedContexts.mjs +2 -0
- package/dist/esm-node/runtime/utils.mjs +19 -17
- package/dist/esm-node/server/defaultUrlStrategy.mjs +8 -0
- package/dist/esm-node/server/index.mjs +11 -7
- package/dist/esm-node/server/mappedUrlStrategy.mjs +2 -0
- package/dist/esm-node/server/redirectPolicy.mjs +33 -24
- package/dist/esm-node/shared/mappedUrlStrategy.mjs +20 -0
- package/dist/esm-node/shared/urlStrategy.mjs +8 -0
- package/dist/types/cli/localisedRoutes.d.ts +19 -0
- package/dist/types/runtime/context.d.ts +8 -11
- package/dist/types/runtime/contextHelpers.d.ts +2 -2
- package/dist/types/runtime/contexts.d.ts +6 -0
- package/dist/types/runtime/core.d.ts +12 -2
- package/dist/types/runtime/hooks.d.ts +4 -5
- package/dist/types/runtime/localizedPaths.d.ts +4 -4
- package/dist/types/runtime/navigation.d.ts +10 -0
- package/dist/types/runtime/providerComposition.d.ts +13 -1
- package/dist/types/runtime/routerAdapter.d.ts +23 -6
- package/dist/types/runtime/sharedContexts.d.ts +1 -0
- package/dist/types/runtime/utils.d.ts +4 -12
- package/dist/types/server/defaultUrlStrategy.d.ts +11 -0
- package/dist/types/server/index.d.ts +2 -0
- package/dist/types/server/mappedUrlStrategy.d.ts +7 -0
- package/dist/types/server/redirectPolicy.d.ts +5 -13
- package/dist/types/shared/mappedUrlStrategy.d.ts +27 -0
- package/dist/types/shared/type.d.ts +7 -11
- package/dist/types/shared/urlStrategy.d.ts +18 -0
- package/package.json +42 -19
- package/rslib.config.mts +8 -1
- package/rstest.config.mts +6 -5
- package/src/cli/index.ts +42 -42
- package/src/cli/localisedRoutes.ts +53 -0
- package/src/runtime/Link.tsx +20 -40
- package/src/runtime/context.tsx +33 -157
- package/src/runtime/contextHelpers.ts +7 -5
- package/src/runtime/contexts.ts +16 -0
- package/src/runtime/core.tsx +53 -6
- package/src/runtime/hooks.ts +14 -9
- package/src/runtime/i18n/instance.ts +1 -2
- package/src/runtime/localizedPaths.ts +24 -19
- package/src/runtime/navigation.tsx +20 -0
- package/src/runtime/providerComposition.tsx +71 -23
- package/src/runtime/routerAdapter.tsx +65 -339
- package/src/runtime/sharedContexts.ts +8 -0
- package/src/runtime/utils.ts +49 -31
- package/src/server/defaultUrlStrategy.ts +27 -0
- package/src/server/index.ts +39 -9
- package/src/server/mappedUrlStrategy.ts +10 -0
- package/src/server/redirectPolicy.ts +85 -70
- package/src/shared/mappedUrlStrategy.ts +59 -0
- package/src/shared/type.ts +7 -11
- package/src/shared/urlStrategy.ts +38 -0
- package/tests/backendDefaults.test.ts +2 -23
- package/tests/defaultUrlStrategy.test.ts +107 -0
- package/tests/federatedI18nBoundaryResolver.test.tsx +65 -0
- package/tests/i18nUtils.test.ts +31 -65
- package/tests/link.test.tsx +13 -511
- package/tests/localisedRouteGeneration.test.ts +200 -0
- package/tests/localisedUrls.test.ts +16 -208
- package/tests/mappedUrlStrategyDerivation.test.ts +62 -0
- package/tests/navigation.test.tsx +403 -0
- package/tests/reactI18nextRuntimeBoundary.test.ts +8 -83
- package/tests/redirectPolicy.test.ts +89 -27
- package/tests/routerAdapter.test.tsx +98 -1171
- package/tests/urlStrategySerialization.test.ts +122 -0
- package/tsconfig.json +4 -2
- package/tests/federatedI18nBoundary.client.test.tsx +0 -129
- package/tests/federatedI18nBoundary.test.tsx +0 -132
- package/tests/linkTypes.test.ts +0 -59
- package/tests/localisedUrlRewriteMatrix.fork.test.ts +0 -147
- package/tests/peerCohort.test.ts +0 -25
- package/tests/type-fixture/fixture-globals.d.ts +0 -11
- package/tests/type-fixture/i18nInstanceTypes.fixture.ts +0 -29
- package/tests/type-fixture/linkTypes.fixture.tsx +0 -51
- package/tests/type-fixture/tsconfig.json +0 -16
|
@@ -0,0 +1,403 @@
|
|
|
1
|
+
import {
|
|
2
|
+
InternalRuntimeContext,
|
|
3
|
+
RuntimeContext,
|
|
4
|
+
} from '@modern-js/runtime/context';
|
|
5
|
+
import { applyRouterRuntimeState } from '@modern-js/runtime-extensions/router-state';
|
|
6
|
+
import type React from 'react';
|
|
7
|
+
import type { ComponentType } from 'react';
|
|
8
|
+
import { act } from 'react';
|
|
9
|
+
import { createRoot, type Root } from 'react-dom/client';
|
|
10
|
+
import { useModernI18n } from '../src/runtime/context';
|
|
11
|
+
import { createI18nPlugin } from '../src/runtime/core';
|
|
12
|
+
import type { I18nInstance } from '../src/runtime/i18n';
|
|
13
|
+
import { Link } from '../src/runtime/Link';
|
|
14
|
+
import { useIntegratedRouterAdapter } from '../src/runtime/navigation';
|
|
15
|
+
|
|
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: '/obchodni-podminky',
|
|
24
|
+
},
|
|
25
|
+
};
|
|
26
|
+
|
|
27
|
+
function createI18nInstance(language = 'en'): I18nInstance {
|
|
28
|
+
return {
|
|
29
|
+
language,
|
|
30
|
+
isInitialized: true,
|
|
31
|
+
init: () => Promise.resolve(undefined),
|
|
32
|
+
use: () => {},
|
|
33
|
+
t: (key: string | string[]) => (Array.isArray(key) ? key[0] : key),
|
|
34
|
+
createInstance: () => createI18nInstance(language),
|
|
35
|
+
setLang: rstest.fn(async (next: string) => {
|
|
36
|
+
void next;
|
|
37
|
+
return undefined;
|
|
38
|
+
}),
|
|
39
|
+
changeLanguage: rstest.fn(async () => undefined),
|
|
40
|
+
services: {},
|
|
41
|
+
options: {},
|
|
42
|
+
};
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
/** A provider capability whose location can be republished. */
|
|
46
|
+
function createMutableTanstackRouter(pathname: string) {
|
|
47
|
+
let snapshot = {
|
|
48
|
+
location: { pathname, search: '', hash: '' },
|
|
49
|
+
params: { lang: pathname.split('/')[1] ?? '' },
|
|
50
|
+
};
|
|
51
|
+
const listeners = new Set<() => void>();
|
|
52
|
+
|
|
53
|
+
return {
|
|
54
|
+
navigate: rstest.fn(async () => undefined),
|
|
55
|
+
navigation: {
|
|
56
|
+
getSnapshot: () => snapshot,
|
|
57
|
+
Link: TanstackLink,
|
|
58
|
+
navigate: rstest.fn(async () => undefined),
|
|
59
|
+
subscribe: (listener: () => void) => {
|
|
60
|
+
listeners.add(listener);
|
|
61
|
+
return () => listeners.delete(listener);
|
|
62
|
+
},
|
|
63
|
+
},
|
|
64
|
+
publishPathname(nextPathname: string) {
|
|
65
|
+
snapshot = {
|
|
66
|
+
location: { pathname: nextPathname, search: '', hash: '' },
|
|
67
|
+
params: { lang: nextPathname.split('/')[1] ?? '' },
|
|
68
|
+
};
|
|
69
|
+
for (const listener of listeners) {
|
|
70
|
+
listener();
|
|
71
|
+
}
|
|
72
|
+
},
|
|
73
|
+
};
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
const TanstackLink = ({ to, children, ...props }: any) => {
|
|
77
|
+
const { prefetch: _prefetch, preload: _preload, ...anchorProps } = props;
|
|
78
|
+
return (
|
|
79
|
+
<a href={to} data-router-link="tanstack" {...anchorProps}>
|
|
80
|
+
{children}
|
|
81
|
+
</a>
|
|
82
|
+
);
|
|
83
|
+
};
|
|
84
|
+
|
|
85
|
+
/**
|
|
86
|
+
* A bare `appTools()` consumer: only `@modern-js/plugin-i18n` is registered, so
|
|
87
|
+
* nothing supplies a `NavigationProvider`.
|
|
88
|
+
*/
|
|
89
|
+
function setupBareI18nRuntime(i18nInstance: I18nInstance) {
|
|
90
|
+
let wrapRoot: ((App: ComponentType<any>) => ComponentType<any>) | undefined;
|
|
91
|
+
let beforeRender: ((context: any) => Promise<void>) | undefined;
|
|
92
|
+
|
|
93
|
+
createI18nPlugin()({
|
|
94
|
+
i18nInstance,
|
|
95
|
+
reactI18next: false,
|
|
96
|
+
localeDetection: {
|
|
97
|
+
fallbackLanguage: 'en',
|
|
98
|
+
i18nextDetector: false,
|
|
99
|
+
languages: ['en', 'cs'],
|
|
100
|
+
localePathRedirect: true,
|
|
101
|
+
localisedUrls,
|
|
102
|
+
},
|
|
103
|
+
}).setup?.({
|
|
104
|
+
getRuntimeConfig: () => ({}),
|
|
105
|
+
resolveComponent: () => undefined,
|
|
106
|
+
onBeforeRender: (callback: (context: any) => Promise<void>) => {
|
|
107
|
+
beforeRender = callback;
|
|
108
|
+
},
|
|
109
|
+
wrapRoot: (callback: (App: ComponentType<any>) => ComponentType<any>) => {
|
|
110
|
+
wrapRoot = callback;
|
|
111
|
+
},
|
|
112
|
+
} as any);
|
|
113
|
+
|
|
114
|
+
if (!wrapRoot || !beforeRender) {
|
|
115
|
+
throw new Error(
|
|
116
|
+
'Expected the i18n runtime plugin to register wrapRoot and onBeforeRender',
|
|
117
|
+
);
|
|
118
|
+
}
|
|
119
|
+
return { wrapRoot, beforeRender };
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
function createTanstackRuntimeContext(
|
|
123
|
+
router: ReturnType<typeof createMutableTanstackRouter>,
|
|
124
|
+
) {
|
|
125
|
+
const requestContext = { request: {}, response: {} };
|
|
126
|
+
const runtimeContext: Record<string, unknown> = {
|
|
127
|
+
isBrowser: true,
|
|
128
|
+
requestContext,
|
|
129
|
+
context: requestContext,
|
|
130
|
+
router: { Link: TanstackLink },
|
|
131
|
+
};
|
|
132
|
+
applyRouterRuntimeState(runtimeContext, {
|
|
133
|
+
framework: 'tanstack',
|
|
134
|
+
instance: router,
|
|
135
|
+
navigation: router.navigation,
|
|
136
|
+
} as any);
|
|
137
|
+
return runtimeContext;
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
/**
|
|
141
|
+
* The CSR boot order: `plugin-tanstack`'s `onBeforeRender` publishes only the
|
|
142
|
+
* hook API, and no router instance exists until `RouterWrapper` renders -
|
|
143
|
+
* which is below anything that wraps the app, this provider included.
|
|
144
|
+
*/
|
|
145
|
+
function createTanstackCsrRuntimeContextWithoutRouter() {
|
|
146
|
+
const requestContext = { request: {}, response: {} };
|
|
147
|
+
return {
|
|
148
|
+
isBrowser: true,
|
|
149
|
+
requestContext,
|
|
150
|
+
context: requestContext,
|
|
151
|
+
router: {
|
|
152
|
+
Link: TanstackLink,
|
|
153
|
+
// `useRouter({ warn: false })` yields nothing before the instance exists.
|
|
154
|
+
useRouter: () => null,
|
|
155
|
+
},
|
|
156
|
+
} as Record<string, unknown>;
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
async function renderBareRuntime(
|
|
160
|
+
App: ComponentType<any>,
|
|
161
|
+
runtimeContext: Record<string, unknown>,
|
|
162
|
+
i18nInstance: I18nInstance,
|
|
163
|
+
) {
|
|
164
|
+
const { wrapRoot, beforeRender } = setupBareI18nRuntime(i18nInstance);
|
|
165
|
+
// The runtime resolves the active instance in `onBeforeRender`; skipping it
|
|
166
|
+
// leaves the provider without the language-synchronisation seam, which is
|
|
167
|
+
// exactly the wiring these cases are about.
|
|
168
|
+
await beforeRender(runtimeContext);
|
|
169
|
+
const Wrapped = wrapRoot(App);
|
|
170
|
+
const container = document.createElement('div');
|
|
171
|
+
document.body.appendChild(container);
|
|
172
|
+
const root = createRoot(container);
|
|
173
|
+
await act(async () => {
|
|
174
|
+
root.render(
|
|
175
|
+
<InternalRuntimeContext.Provider value={runtimeContext as any}>
|
|
176
|
+
<RuntimeContext.Provider value={runtimeContext as any}>
|
|
177
|
+
<Wrapped />
|
|
178
|
+
</RuntimeContext.Provider>
|
|
179
|
+
</InternalRuntimeContext.Provider>,
|
|
180
|
+
);
|
|
181
|
+
});
|
|
182
|
+
return { container, root };
|
|
183
|
+
}
|
|
184
|
+
|
|
185
|
+
function cleanup(rendered?: { container: HTMLElement; root: Root }) {
|
|
186
|
+
if (!rendered) {
|
|
187
|
+
return;
|
|
188
|
+
}
|
|
189
|
+
act(() => {
|
|
190
|
+
rendered.root.unmount();
|
|
191
|
+
});
|
|
192
|
+
rendered.container.remove();
|
|
193
|
+
}
|
|
194
|
+
|
|
195
|
+
describe('bare plugin-i18n navigation adapter', () => {
|
|
196
|
+
let rendered: { container: HTMLElement; root: Root } | undefined;
|
|
197
|
+
|
|
198
|
+
afterEach(() => {
|
|
199
|
+
cleanup(rendered);
|
|
200
|
+
rendered = undefined;
|
|
201
|
+
rstest.restoreAllMocks();
|
|
202
|
+
});
|
|
203
|
+
|
|
204
|
+
test('renders mapped locale URLs through the selected router Link', async () => {
|
|
205
|
+
const router = createMutableTanstackRouter('/cs/obchodni-podminky');
|
|
206
|
+
const App = () => (
|
|
207
|
+
<Link to="/terms-of-service" data-testid="nav-terms">
|
|
208
|
+
terms
|
|
209
|
+
</Link>
|
|
210
|
+
);
|
|
211
|
+
|
|
212
|
+
rendered = await renderBareRuntime(
|
|
213
|
+
App,
|
|
214
|
+
createTanstackRuntimeContext(router),
|
|
215
|
+
createI18nInstance('cs'),
|
|
216
|
+
);
|
|
217
|
+
|
|
218
|
+
const anchor = rendered.container.querySelector(
|
|
219
|
+
'[data-testid="nav-terms"]',
|
|
220
|
+
);
|
|
221
|
+
// Without the adapter this is a plain `<a>`: no router link, no active state.
|
|
222
|
+
expect(anchor?.getAttribute('data-router-link')).toBe('tanstack');
|
|
223
|
+
expect(anchor?.getAttribute('href')).toBe('/cs/obchodni-podminky');
|
|
224
|
+
});
|
|
225
|
+
|
|
226
|
+
test('keeps active state language-invariant across mapped spellings', async () => {
|
|
227
|
+
const App = () => (
|
|
228
|
+
<Link to="/terms-of-service" data-testid="nav-terms">
|
|
229
|
+
terms
|
|
230
|
+
</Link>
|
|
231
|
+
);
|
|
232
|
+
|
|
233
|
+
for (const [language, pathname] of [
|
|
234
|
+
['en', '/en/terms-of-service'],
|
|
235
|
+
['cs', '/cs/obchodni-podminky'],
|
|
236
|
+
] as const) {
|
|
237
|
+
const attempt = await renderBareRuntime(
|
|
238
|
+
App,
|
|
239
|
+
createTanstackRuntimeContext(createMutableTanstackRouter(pathname)),
|
|
240
|
+
createI18nInstance(language),
|
|
241
|
+
);
|
|
242
|
+
try {
|
|
243
|
+
const anchor = attempt.container.querySelector(
|
|
244
|
+
'[data-testid="nav-terms"]',
|
|
245
|
+
);
|
|
246
|
+
expect(anchor?.getAttribute('data-status')).toBe('active');
|
|
247
|
+
expect(anchor?.getAttribute('aria-current')).toBe('page');
|
|
248
|
+
} finally {
|
|
249
|
+
cleanup(attempt);
|
|
250
|
+
}
|
|
251
|
+
}
|
|
252
|
+
});
|
|
253
|
+
|
|
254
|
+
test('reports no router until the TanStack instance is installed', async () => {
|
|
255
|
+
const runtimeContext = createTanstackCsrRuntimeContextWithoutRouter();
|
|
256
|
+
const App = () => {
|
|
257
|
+
const adapter = useIntegratedRouterAdapter();
|
|
258
|
+
return <span data-testid="has-router">{String(adapter.hasRouter)}</span>;
|
|
259
|
+
};
|
|
260
|
+
|
|
261
|
+
rendered = await renderBareRuntime(
|
|
262
|
+
App,
|
|
263
|
+
runtimeContext,
|
|
264
|
+
createI18nInstance('en'),
|
|
265
|
+
);
|
|
266
|
+
|
|
267
|
+
// The hook API is published but the instance is not, so claiming a router
|
|
268
|
+
// here would hand `changeLanguage()` a `navigate` that only throws.
|
|
269
|
+
expect(
|
|
270
|
+
rendered.container.querySelector('[data-testid="has-router"]')
|
|
271
|
+
?.textContent,
|
|
272
|
+
).toBe('false');
|
|
273
|
+
|
|
274
|
+
await act(async () => {
|
|
275
|
+
applyRouterRuntimeState(runtimeContext, {
|
|
276
|
+
framework: 'tanstack',
|
|
277
|
+
navigation: createMutableTanstackRouter('/en/terms-of-service')
|
|
278
|
+
.navigation,
|
|
279
|
+
} as any);
|
|
280
|
+
});
|
|
281
|
+
|
|
282
|
+
// Installing the instance has to reach a provider that already rendered.
|
|
283
|
+
expect(
|
|
284
|
+
rendered.container.querySelector('[data-testid="has-router"]')
|
|
285
|
+
?.textContent,
|
|
286
|
+
).toBe('true');
|
|
287
|
+
});
|
|
288
|
+
|
|
289
|
+
test('reports no router for a framework the adapter cannot drive', async () => {
|
|
290
|
+
// A custom provider may publish an instance under a name `navigate` has
|
|
291
|
+
// no implementation for. Claiming a router then makes `changeLanguage()`
|
|
292
|
+
// await a navigate that goes nowhere instead of the full-page fallback.
|
|
293
|
+
const runtimeContext = createTanstackCsrRuntimeContextWithoutRouter();
|
|
294
|
+
const App = () => {
|
|
295
|
+
const adapter = useIntegratedRouterAdapter();
|
|
296
|
+
return <span data-testid="has-router">{String(adapter.hasRouter)}</span>;
|
|
297
|
+
};
|
|
298
|
+
|
|
299
|
+
rendered = await renderBareRuntime(
|
|
300
|
+
App,
|
|
301
|
+
runtimeContext,
|
|
302
|
+
createI18nInstance('en'),
|
|
303
|
+
);
|
|
304
|
+
await act(async () => {
|
|
305
|
+
applyRouterRuntimeState(runtimeContext, {
|
|
306
|
+
framework: 'custom-router',
|
|
307
|
+
instance: { navigate: () => undefined, state: { location: {} } },
|
|
308
|
+
} as any);
|
|
309
|
+
});
|
|
310
|
+
|
|
311
|
+
expect(
|
|
312
|
+
rendered.container.querySelector('[data-testid="has-router"]')
|
|
313
|
+
?.textContent,
|
|
314
|
+
).toBe('false');
|
|
315
|
+
});
|
|
316
|
+
|
|
317
|
+
test('leaves links unrouted until the TanStack instance is installed', async () => {
|
|
318
|
+
const runtimeContext = createTanstackCsrRuntimeContextWithoutRouter();
|
|
319
|
+
const App = () => (
|
|
320
|
+
<Link to="/terms-of-service" data-testid="nav-terms">
|
|
321
|
+
terms
|
|
322
|
+
</Link>
|
|
323
|
+
);
|
|
324
|
+
|
|
325
|
+
rendered = await renderBareRuntime(
|
|
326
|
+
App,
|
|
327
|
+
runtimeContext,
|
|
328
|
+
createI18nInstance('en'),
|
|
329
|
+
);
|
|
330
|
+
const anchor = () =>
|
|
331
|
+
rendered?.container.querySelector('[data-testid="nav-terms"]');
|
|
332
|
+
expect(anchor()?.getAttribute('data-router-link')).toBeNull();
|
|
333
|
+
|
|
334
|
+
await act(async () => {
|
|
335
|
+
applyRouterRuntimeState(runtimeContext, {
|
|
336
|
+
framework: 'tanstack',
|
|
337
|
+
navigation: createMutableTanstackRouter('/cs/obchodni-podminky')
|
|
338
|
+
.navigation,
|
|
339
|
+
} as any);
|
|
340
|
+
});
|
|
341
|
+
|
|
342
|
+
expect(anchor()?.getAttribute('data-router-link')).toBe('tanstack');
|
|
343
|
+
});
|
|
344
|
+
|
|
345
|
+
test('replaces providers and stops observing the previous router', async () => {
|
|
346
|
+
const first = createMutableTanstackRouter('/en/products/shoe');
|
|
347
|
+
const second = createMutableTanstackRouter('/cs/produkty/bota');
|
|
348
|
+
const runtimeContext = createTanstackRuntimeContext(first);
|
|
349
|
+
const App = () => {
|
|
350
|
+
const adapter = useIntegratedRouterAdapter();
|
|
351
|
+
return <span data-testid="path">{adapter.location?.pathname}</span>;
|
|
352
|
+
};
|
|
353
|
+
rendered = await renderBareRuntime(
|
|
354
|
+
App,
|
|
355
|
+
runtimeContext,
|
|
356
|
+
createI18nInstance('en'),
|
|
357
|
+
);
|
|
358
|
+
await act(async () => {
|
|
359
|
+
applyRouterRuntimeState(runtimeContext, {
|
|
360
|
+
framework: 'custom-provider',
|
|
361
|
+
navigation: second.navigation,
|
|
362
|
+
});
|
|
363
|
+
});
|
|
364
|
+
expect(
|
|
365
|
+
rendered.container.querySelector('[data-testid="path"]')?.textContent,
|
|
366
|
+
).toBe('/cs/produkty/bota');
|
|
367
|
+
await act(async () => first.publishPathname('/en/stale'));
|
|
368
|
+
expect(
|
|
369
|
+
rendered.container.querySelector('[data-testid="path"]')?.textContent,
|
|
370
|
+
).toBe('/cs/produkty/bota');
|
|
371
|
+
await act(async () => second.publishPathname('/en/current'));
|
|
372
|
+
expect(
|
|
373
|
+
rendered.container.querySelector('[data-testid="path"]')?.textContent,
|
|
374
|
+
).toBe('/en/current');
|
|
375
|
+
});
|
|
376
|
+
|
|
377
|
+
test('follows the router location so the language tracks a client navigation', async () => {
|
|
378
|
+
const router = createMutableTanstackRouter('/en/products/shoe');
|
|
379
|
+
const App = () => {
|
|
380
|
+
const { language } = useModernI18n();
|
|
381
|
+
return <span data-testid="language">{language}</span>;
|
|
382
|
+
};
|
|
383
|
+
const i18nInstance = createI18nInstance('en');
|
|
384
|
+
|
|
385
|
+
rendered = await renderBareRuntime(
|
|
386
|
+
App,
|
|
387
|
+
createTanstackRuntimeContext(router),
|
|
388
|
+
i18nInstance,
|
|
389
|
+
);
|
|
390
|
+
expect(
|
|
391
|
+
rendered.container.querySelector('[data-testid="language"]')?.textContent,
|
|
392
|
+
).toBe('en');
|
|
393
|
+
|
|
394
|
+
await act(async () => {
|
|
395
|
+
router.publishPathname('/cs/produkty/bota');
|
|
396
|
+
});
|
|
397
|
+
|
|
398
|
+
expect(
|
|
399
|
+
rendered.container.querySelector('[data-testid="language"]')?.textContent,
|
|
400
|
+
).toBe('cs');
|
|
401
|
+
expect(i18nInstance.setLang).toHaveBeenCalledWith('cs');
|
|
402
|
+
});
|
|
403
|
+
});
|
|
@@ -1,13 +1,12 @@
|
|
|
1
1
|
import { mkdtemp, rm } from 'node:fs/promises';
|
|
2
2
|
import { tmpdir } from 'node:os';
|
|
3
|
-
import {
|
|
3
|
+
import { resolve } from 'node:path';
|
|
4
4
|
import { pathToFileURL } from 'node:url';
|
|
5
5
|
import { describe, expect, test } from '@rstest/core';
|
|
6
6
|
import { build } from 'esbuild';
|
|
7
7
|
import i18next from 'i18next';
|
|
8
8
|
import { createElement } from 'react';
|
|
9
9
|
import { renderToStaticMarkup } from 'react-dom/server';
|
|
10
|
-
import { i18nPlugin as noReactI18nextPlugin } from '../src/runtime/no-react-i18next';
|
|
11
10
|
|
|
12
11
|
const runtimeRoot = resolve(__dirname, '../src/runtime');
|
|
13
12
|
|
|
@@ -37,31 +36,6 @@ describe('react-i18next runtime boundary', () => {
|
|
|
37
36
|
).resolves.toBeDefined();
|
|
38
37
|
});
|
|
39
38
|
|
|
40
|
-
test('bundles the default runtime entry with the react-i18next adapter', async () => {
|
|
41
|
-
let adapterResolutions = 0;
|
|
42
|
-
await build({
|
|
43
|
-
bundle: true,
|
|
44
|
-
entryPoints: [resolve(runtimeRoot, 'index.tsx')],
|
|
45
|
-
format: 'esm',
|
|
46
|
-
packages: 'external',
|
|
47
|
-
platform: 'node',
|
|
48
|
-
plugins: [
|
|
49
|
-
{
|
|
50
|
-
name: 'observe-react-i18next',
|
|
51
|
-
setup(buildApi) {
|
|
52
|
-
buildApi.onResolve({ filter: /^react-i18next$/ }, args => {
|
|
53
|
-
adapterResolutions += 1;
|
|
54
|
-
return { external: true, path: args.path };
|
|
55
|
-
});
|
|
56
|
-
},
|
|
57
|
-
},
|
|
58
|
-
],
|
|
59
|
-
write: false,
|
|
60
|
-
});
|
|
61
|
-
|
|
62
|
-
expect(adapterResolutions).toBeGreaterThan(0);
|
|
63
|
-
});
|
|
64
|
-
|
|
65
39
|
test('gets router capabilities from the selected runtime provider', async () => {
|
|
66
40
|
await expect(
|
|
67
41
|
build({
|
|
@@ -90,62 +64,7 @@ describe('react-i18next runtime boundary', () => {
|
|
|
90
64
|
).resolves.toBeDefined();
|
|
91
65
|
});
|
|
92
66
|
|
|
93
|
-
test('
|
|
94
|
-
const result = await build({
|
|
95
|
-
bundle: true,
|
|
96
|
-
entryPoints: [resolve(runtimeRoot, 'pluginSetup.ts')],
|
|
97
|
-
format: 'esm',
|
|
98
|
-
metafile: true,
|
|
99
|
-
outdir: 'out',
|
|
100
|
-
packages: 'external',
|
|
101
|
-
platform: 'node',
|
|
102
|
-
splitting: true,
|
|
103
|
-
write: false,
|
|
104
|
-
});
|
|
105
|
-
|
|
106
|
-
const entryOutput = Object.values(result.metafile.outputs).find(output =>
|
|
107
|
-
output.entryPoint?.endsWith('/pluginSetup.ts'),
|
|
108
|
-
);
|
|
109
|
-
if (!entryOutput) {
|
|
110
|
-
throw new Error('pluginSetup entry output was not generated');
|
|
111
|
-
}
|
|
112
|
-
const dynamicOutputNames = entryOutput.imports
|
|
113
|
-
.filter(moduleImport => moduleImport.kind === 'dynamic-import')
|
|
114
|
-
.map(moduleImport => basename(moduleImport.path));
|
|
115
|
-
const dynamicOutputs = Object.entries(result.metafile.outputs)
|
|
116
|
-
.filter(([outputPath]) =>
|
|
117
|
-
dynamicOutputNames.includes(basename(outputPath)),
|
|
118
|
-
)
|
|
119
|
-
.map(([, output]) => output);
|
|
120
|
-
const dynamicEntries = dynamicOutputs.map(output =>
|
|
121
|
-
basename(output.entryPoint ?? ''),
|
|
122
|
-
);
|
|
123
|
-
|
|
124
|
-
expect(dynamicEntries).toEqual(
|
|
125
|
-
expect.arrayContaining(['middleware.ts', 'utils.ts']),
|
|
126
|
-
);
|
|
127
|
-
});
|
|
128
|
-
|
|
129
|
-
test('registers plugin lifecycle hooks synchronously', () => {
|
|
130
|
-
let beforeRender: unknown;
|
|
131
|
-
let rootWrapper: unknown;
|
|
132
|
-
const plugin = noReactI18nextPlugin({ reactI18next: false });
|
|
133
|
-
const setupResult = plugin.setup?.({
|
|
134
|
-
getRuntimeConfig: () => ({}),
|
|
135
|
-
onBeforeRender: hook => {
|
|
136
|
-
beforeRender = hook;
|
|
137
|
-
},
|
|
138
|
-
wrapRoot: wrapper => {
|
|
139
|
-
rootWrapper = wrapper;
|
|
140
|
-
},
|
|
141
|
-
} as any);
|
|
142
|
-
|
|
143
|
-
expect(setupResult).toBeUndefined();
|
|
144
|
-
expect(typeof beforeRender).toBe('function');
|
|
145
|
-
expect(typeof rootWrapper).toBe('function');
|
|
146
|
-
});
|
|
147
|
-
|
|
148
|
-
test('shares Modern i18n context across independently bundled runtime copies', async () => {
|
|
67
|
+
test('independently bundled runtime copies share the contexts subpath', async () => {
|
|
149
68
|
const tempDir = await mkdtemp(
|
|
150
69
|
resolve(__dirname, '.modern-i18n-runtime-boundary-'),
|
|
151
70
|
);
|
|
@@ -162,6 +81,9 @@ describe('react-i18next runtime boundary', () => {
|
|
|
162
81
|
outfile,
|
|
163
82
|
packages: 'external',
|
|
164
83
|
platform: 'node',
|
|
84
|
+
// Resolve the contexts self-reference like an installed package,
|
|
85
|
+
// not through this package's tsconfig `paths`.
|
|
86
|
+
tsconfigRaw: {},
|
|
165
87
|
});
|
|
166
88
|
return import(pathToFileURL(outfile).href);
|
|
167
89
|
}),
|
|
@@ -191,6 +113,9 @@ describe('react-i18next runtime boundary', () => {
|
|
|
191
113
|
);
|
|
192
114
|
|
|
193
115
|
expect(html).toBe('<span>cs</span>');
|
|
116
|
+
expect(
|
|
117
|
+
Object.getOwnPropertySymbols(globalThis).map(String),
|
|
118
|
+
).not.toContainEqual(expect.stringContaining('@modern-js/plugin-i18n'));
|
|
194
119
|
} finally {
|
|
195
120
|
await rm(tempDir, { force: true, recursive: true });
|
|
196
121
|
}
|
|
@@ -5,41 +5,103 @@ import {
|
|
|
5
5
|
shouldIgnoreRedirect as shouldIgnoreServerRedirect,
|
|
6
6
|
} from '../src/server/redirectPolicy';
|
|
7
7
|
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
8
|
+
describe('native locale redirect safeguards', () => {
|
|
9
|
+
test('never locale-redirects federation artifacts, with no URL strategy configured', () => {
|
|
10
|
+
// 3.9.0-ultramodern.9 regression: with `localisedUrls: {}` no strategy is
|
|
11
|
+
// derived, and the backend federation manifest was redirected to
|
|
12
|
+
// `/en/backend-mf-manifest.json` (an HTML page) — the Node backend
|
|
13
|
+
// federation proof then read a 404 instead of the manifest.
|
|
14
|
+
const languages = ['en', 'cs'];
|
|
15
|
+
for (const pathname of [
|
|
16
|
+
'/backend-mf-manifest.json',
|
|
17
|
+
'/backendRemoteEntry.cjs',
|
|
18
|
+
'/mf-manifest.json',
|
|
19
|
+
'/mf-stats.json',
|
|
20
|
+
'/remoteEntry.js',
|
|
21
|
+
'/remoteEntry.catalog.js',
|
|
22
|
+
'/backendRemoteEntry.catalog.cjs',
|
|
23
|
+
'/cs/backend-mf-manifest.json',
|
|
24
|
+
]) {
|
|
25
|
+
expect(
|
|
26
|
+
shouldIgnoreServerRedirect(
|
|
27
|
+
pathname,
|
|
28
|
+
'/*',
|
|
29
|
+
undefined,
|
|
30
|
+
undefined,
|
|
31
|
+
languages,
|
|
32
|
+
),
|
|
33
|
+
).toBe(true);
|
|
34
|
+
expect(
|
|
35
|
+
shouldIgnoreServerRedirect(
|
|
36
|
+
`/app${pathname}`,
|
|
37
|
+
'/app/*',
|
|
38
|
+
undefined,
|
|
39
|
+
undefined,
|
|
40
|
+
languages,
|
|
41
|
+
),
|
|
42
|
+
).toBe(true);
|
|
43
|
+
expect(
|
|
44
|
+
shouldIgnoreRuntimeRedirect(pathname, languages, undefined, undefined),
|
|
45
|
+
).toBe(true);
|
|
25
46
|
}
|
|
47
|
+
// A page is still redirected.
|
|
48
|
+
expect(
|
|
49
|
+
shouldIgnoreServerRedirect(
|
|
50
|
+
'/products',
|
|
51
|
+
'/*',
|
|
52
|
+
undefined,
|
|
53
|
+
undefined,
|
|
54
|
+
languages,
|
|
55
|
+
),
|
|
56
|
+
).toBe(false);
|
|
57
|
+
expect(
|
|
58
|
+
shouldIgnoreRuntimeRedirect('/products', languages, undefined, undefined),
|
|
59
|
+
).toBe(false);
|
|
26
60
|
});
|
|
27
61
|
|
|
28
|
-
test('
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
62
|
+
test('retains configured ignores across runtime and server entry prefixes', () => {
|
|
63
|
+
const rules = ['/private'];
|
|
64
|
+
expect(
|
|
65
|
+
shouldIgnoreRuntimeRedirect('/cs/private/data', ['en', 'cs'], rules),
|
|
66
|
+
).toBe(true);
|
|
67
|
+
expect(
|
|
68
|
+
shouldIgnoreRuntimeRedirect('/cs/private-data', ['en', 'cs'], rules),
|
|
69
|
+
).toBe(false);
|
|
32
70
|
expect(
|
|
33
|
-
|
|
71
|
+
shouldIgnoreServerRedirect(
|
|
72
|
+
'/app/cs/private/data',
|
|
73
|
+
'/app/*',
|
|
74
|
+
rules,
|
|
75
|
+
undefined,
|
|
76
|
+
['en', 'cs'],
|
|
77
|
+
),
|
|
34
78
|
).toBe(true);
|
|
79
|
+
expect(
|
|
80
|
+
shouldIgnoreServerRedirect(
|
|
81
|
+
'/app/cs/private-data',
|
|
82
|
+
'/app/*',
|
|
83
|
+
rules,
|
|
84
|
+
undefined,
|
|
85
|
+
['en', 'cs'],
|
|
86
|
+
),
|
|
87
|
+
).toBe(false);
|
|
35
88
|
});
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
89
|
+
test('retains configured and native static paths without fork policy', () => {
|
|
90
|
+
for (const pathname of [
|
|
91
|
+
'/static/app.js',
|
|
92
|
+
'/upload/a.png',
|
|
93
|
+
'/cs/assets/main.js',
|
|
94
|
+
]) {
|
|
95
|
+
expect(isStaticResourceRequest(pathname, ['/assets'], ['en', 'cs'])).toBe(
|
|
41
96
|
true,
|
|
42
97
|
);
|
|
43
98
|
}
|
|
99
|
+
expect(
|
|
100
|
+
isStaticResourceRequest(
|
|
101
|
+
'/assets-other/main.js',
|
|
102
|
+
['/assets'],
|
|
103
|
+
['en', 'cs'],
|
|
104
|
+
),
|
|
105
|
+
).toBe(false);
|
|
44
106
|
});
|
|
45
107
|
});
|