@bleedingdev/modern-js-plugin-i18n 3.9.0-ultramodern.4 → 3.9.0-ultramodern.6

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (79) hide show
  1. package/dist/cjs/cli/index.js +0 -22
  2. package/dist/cjs/runtime/Link.js +19 -32
  3. package/dist/cjs/runtime/context.js +26 -61
  4. package/dist/cjs/runtime/contextHelpers.js +5 -5
  5. package/dist/cjs/runtime/core.js +17 -14
  6. package/dist/cjs/runtime/hooks.js +7 -8
  7. package/dist/cjs/runtime/localizedPaths.js +11 -9
  8. package/dist/cjs/runtime/providerComposition.js +37 -13
  9. package/dist/cjs/runtime/routerAdapter.js +35 -140
  10. package/dist/cjs/runtime/utils.js +17 -19
  11. package/dist/cjs/server/index.js +10 -7
  12. package/dist/cjs/server/redirectPolicy.js +36 -31
  13. package/dist/cjs/shared/urlStrategy.js +18 -0
  14. package/dist/esm/cli/index.mjs +0 -22
  15. package/dist/esm/runtime/Link.mjs +19 -32
  16. package/dist/esm/runtime/context.mjs +18 -62
  17. package/dist/esm/runtime/contextHelpers.mjs +5 -5
  18. package/dist/esm/runtime/core.mjs +8 -5
  19. package/dist/esm/runtime/hooks.mjs +7 -8
  20. package/dist/esm/runtime/localizedPaths.mjs +11 -9
  21. package/dist/esm/runtime/providerComposition.mjs +38 -14
  22. package/dist/esm/runtime/routerAdapter.mjs +30 -141
  23. package/dist/esm/runtime/utils.mjs +15 -17
  24. package/dist/esm/server/index.mjs +10 -7
  25. package/dist/esm/server/redirectPolicy.mjs +29 -24
  26. package/dist/esm/shared/urlStrategy.mjs +0 -0
  27. package/dist/esm-node/cli/index.mjs +0 -22
  28. package/dist/esm-node/runtime/Link.mjs +19 -32
  29. package/dist/esm-node/runtime/context.mjs +18 -62
  30. package/dist/esm-node/runtime/contextHelpers.mjs +5 -5
  31. package/dist/esm-node/runtime/core.mjs +8 -5
  32. package/dist/esm-node/runtime/hooks.mjs +7 -8
  33. package/dist/esm-node/runtime/localizedPaths.mjs +11 -9
  34. package/dist/esm-node/runtime/providerComposition.mjs +38 -14
  35. package/dist/esm-node/runtime/routerAdapter.mjs +30 -141
  36. package/dist/esm-node/runtime/utils.mjs +15 -17
  37. package/dist/esm-node/server/index.mjs +10 -7
  38. package/dist/esm-node/server/redirectPolicy.mjs +29 -24
  39. package/dist/esm-node/shared/urlStrategy.mjs +1 -0
  40. package/dist/types/runtime/context.d.ts +8 -11
  41. package/dist/types/runtime/contextHelpers.d.ts +2 -2
  42. package/dist/types/runtime/core.d.ts +11 -2
  43. package/dist/types/runtime/hooks.d.ts +4 -5
  44. package/dist/types/runtime/localizedPaths.d.ts +4 -4
  45. package/dist/types/runtime/providerComposition.d.ts +13 -1
  46. package/dist/types/runtime/routerAdapter.d.ts +23 -6
  47. package/dist/types/runtime/utils.d.ts +4 -12
  48. package/dist/types/server/index.d.ts +2 -0
  49. package/dist/types/server/redirectPolicy.d.ts +5 -13
  50. package/dist/types/shared/type.d.ts +0 -14
  51. package/dist/types/shared/urlStrategy.d.ts +7 -0
  52. package/package.json +21 -11
  53. package/rstest.config.mts +2 -8
  54. package/src/cli/index.ts +1 -43
  55. package/src/runtime/Link.tsx +20 -40
  56. package/src/runtime/context.tsx +34 -133
  57. package/src/runtime/contextHelpers.ts +7 -5
  58. package/src/runtime/core.tsx +26 -6
  59. package/src/runtime/hooks.ts +14 -9
  60. package/src/runtime/localizedPaths.ts +24 -19
  61. package/src/runtime/providerComposition.tsx +71 -23
  62. package/src/runtime/routerAdapter.tsx +73 -338
  63. package/src/runtime/utils.ts +40 -31
  64. package/src/server/index.ts +44 -9
  65. package/src/server/redirectPolicy.ts +70 -70
  66. package/src/shared/type.ts +0 -14
  67. package/src/shared/urlStrategy.ts +11 -0
  68. package/tests/federatedI18nBoundaryResolver.test.tsx +71 -0
  69. package/tests/i18nUtils.test.ts +31 -65
  70. package/tests/link.test.tsx +13 -511
  71. package/tests/linkTypes.test.ts +12 -3
  72. package/tests/localisedUrlRewriteMatrix.fork.test.ts +4 -96
  73. package/tests/localisedUrls.test.ts +7 -216
  74. package/tests/reactI18nextRuntimeBoundary.test.ts +1 -82
  75. package/tests/redirectPolicy.test.ts +39 -30
  76. package/tests/routerAdapter.test.tsx +95 -1055
  77. package/tests/federatedI18nBoundary.client.test.tsx +0 -129
  78. package/tests/federatedI18nBoundary.test.tsx +0 -132
  79. package/tests/peerCohort.test.ts +0 -25
@@ -1,76 +1,77 @@
1
- import {
2
- isDefaultLocaleRedirectSkipPath,
3
- localiseTargetPathname,
4
- matchesPathPrefix,
5
- shouldSkipLocaleRedirect,
6
- stripLanguagePrefix,
7
- } from '@modern-js/i18n-runtime-extensions';
8
- import type { LocaleDetectionOptions } from '../shared/type';
1
+ import type { I18nUrlStrategy } from '../shared/urlStrategy';
9
2
 
10
3
  interface LocaleRedirectRequest {
11
4
  url: string;
12
- header: () => {
13
- host?: string;
14
- };
5
+ header?: () => { host?: string };
15
6
  }
16
7
 
17
8
  const stripUrlPathPrefix = (pathname: string, urlPath: string): string => {
18
9
  const basePath = urlPath.replace('/*', '');
19
-
20
10
  if (!basePath || basePath === '/') {
21
11
  return pathname;
22
12
  }
23
-
24
- const remainingPath = pathname.startsWith(basePath)
25
- ? pathname.slice(basePath.length)
13
+ return pathname === basePath || pathname.startsWith(`${basePath}/`)
14
+ ? pathname.slice(basePath.length) || '/'
26
15
  : pathname;
27
-
28
- return remainingPath || '/';
29
16
  };
30
17
 
31
- /**
32
- * Check if pathname should ignore automatic locale redirect.
33
- */
34
18
  export const shouldIgnoreRedirect = (
35
19
  pathname: string,
36
20
  urlPath: string,
37
21
  ignoreRedirectRoutes?: string[] | ((pathname: string) => boolean),
22
+ urlStrategy?: I18nUrlStrategy,
23
+ languages: string[] = [],
38
24
  ): boolean => {
39
25
  const remainingPath = stripUrlPathPrefix(pathname, urlPath);
40
-
41
- return shouldSkipLocaleRedirect(remainingPath, [], ignoreRedirectRoutes);
26
+ if (urlStrategy?.shouldSkipRedirect?.(remainingPath, languages)) {
27
+ return true;
28
+ }
29
+ if (!ignoreRedirectRoutes) {
30
+ return false;
31
+ }
32
+ const segments = remainingPath.split('/').filter(Boolean);
33
+ if (
34
+ segments[0] &&
35
+ languages.some(
36
+ language => language.toLowerCase() === segments[0].toLowerCase(),
37
+ )
38
+ ) {
39
+ segments.shift();
40
+ }
41
+ const normalizedPath = `/${segments.join('/')}`;
42
+ return typeof ignoreRedirectRoutes === 'function'
43
+ ? ignoreRedirectRoutes(normalizedPath)
44
+ : ignoreRedirectRoutes.some(
45
+ pattern =>
46
+ normalizedPath === pattern ||
47
+ normalizedPath.startsWith(`${pattern}/`),
48
+ );
42
49
  };
43
50
 
44
- /**
45
- * Check if pathname is a static or federation resource request.
46
- *
47
- * This includes configured staticRoutePrefixes, the shared default skip policy
48
- * from ADR-0002, and language-prefixed variants such as /en/static/app.js.
49
- */
51
+ /** Native configured/static exclusions remain in effect for every strategy. */
50
52
  export const isStaticResourceRequest = (
51
53
  pathname: string,
52
54
  staticRoutePrefixes: string[],
53
55
  languages: string[] = [],
54
56
  ): boolean => {
55
- if (isDefaultLocaleRedirectSkipPath(pathname, languages)) {
56
- return true;
57
- }
58
-
59
- const matchesStaticRoutePrefix = (targetPathname: string): boolean =>
60
- staticRoutePrefixes.some(prefix =>
61
- matchesPathPrefix(targetPathname, prefix),
57
+ const prefixes = [...staticRoutePrefixes, '/static', '/upload'];
58
+ const matches = (target: string) =>
59
+ prefixes.some(
60
+ prefix => target === prefix || target.startsWith(`${prefix}/`),
62
61
  );
63
-
64
- if (matchesStaticRoutePrefix(pathname)) {
62
+ if (matches(pathname)) {
65
63
  return true;
66
64
  }
67
-
68
- const pathWithoutLanguage = stripLanguagePrefix(pathname, languages);
69
-
70
- return (
71
- pathWithoutLanguage !== pathname &&
72
- matchesStaticRoutePrefix(pathWithoutLanguage)
73
- );
65
+ const segments = pathname.split('/').filter(Boolean);
66
+ if (
67
+ segments[0] &&
68
+ languages.some(
69
+ language => language.toLowerCase() === segments[0].toLowerCase(),
70
+ )
71
+ ) {
72
+ return matches(`/${segments.slice(1).join('/')}`);
73
+ }
74
+ return false;
74
75
  };
75
76
 
76
77
  export const getLanguageFromPath = (
@@ -78,17 +79,16 @@ export const getLanguageFromPath = (
78
79
  urlPath: string,
79
80
  languages: string[],
80
81
  ): string | null => {
81
- const url = new URL(req.url, `http://${req.header().host}`);
82
- const pathname = url.pathname;
83
- const remainingPath = stripUrlPathPrefix(pathname, urlPath);
84
- const segments = remainingPath.split('/').filter(Boolean);
85
- const firstSegment = segments[0];
86
-
87
- if (languages.includes(firstSegment)) {
88
- return firstSegment;
89
- }
90
-
91
- return null;
82
+ const host = req.header?.().host;
83
+ const url = new URL(req.url, host ? `http://${host}` : undefined);
84
+ const firstSegment = stripUrlPathPrefix(url.pathname, urlPath)
85
+ .split('/')
86
+ .filter(Boolean)[0];
87
+ return (
88
+ languages.find(
89
+ language => language.toLowerCase() === firstSegment?.toLowerCase(),
90
+ ) ?? null
91
+ );
92
92
  };
93
93
 
94
94
  export const buildLocalizedUrl = (
@@ -96,24 +96,24 @@ export const buildLocalizedUrl = (
96
96
  urlPath: string,
97
97
  language: string,
98
98
  languages: string[],
99
- localisedUrls?: LocaleDetectionOptions['localisedUrls'],
99
+ urlStrategy?: I18nUrlStrategy,
100
100
  ): string => {
101
101
  const url = new URL(req.url);
102
- const pathname = url.pathname;
103
102
  const basePath = urlPath.replace('/*', '');
104
- const remainingPath = stripUrlPathPrefix(pathname, urlPath);
105
-
106
- const newPathname = localiseTargetPathname(
107
- remainingPath,
108
- language,
109
- languages,
110
- localisedUrls,
111
- );
112
- const suffix = `${url.search}${url.hash}`;
113
- const localizedUrl =
114
- basePath === '/' ? newPathname + suffix : basePath + newPathname + suffix;
115
-
116
- return localizedUrl;
103
+ const remainingPath = stripUrlPathPrefix(url.pathname, urlPath);
104
+ const segments = remainingPath.split('/').filter(Boolean);
105
+ if (
106
+ segments[0] &&
107
+ languages.some(item => item.toLowerCase() === segments[0].toLowerCase())
108
+ ) {
109
+ segments[0] = language;
110
+ } else {
111
+ segments.unshift(language);
112
+ }
113
+ const pathname = urlStrategy
114
+ ? urlStrategy.localizePathname(remainingPath, language, languages)
115
+ : `/${segments.join('/')}`;
116
+ return `${basePath === '/' ? '' : basePath}${pathname}${url.search}${url.hash}`;
117
117
  };
118
118
 
119
119
  export const createLocaleRedirectResponse = (location: string): Response =>
@@ -1,4 +1,3 @@
1
- import type { LocalisedUrlsOption } from '@modern-js/i18n-runtime-extensions';
2
1
  import type {
3
2
  LanguageDetectorOptions,
4
3
  Resources,
@@ -11,19 +10,6 @@ export interface BaseLocaleDetectionOptions {
11
10
  fallbackLanguage?: string;
12
11
  detection?: LanguageDetectorOptions;
13
12
  ignoreRedirectRoutes?: string[] | ((pathname: string) => boolean);
14
- /**
15
- * Enables localised pathnames in addition to the locale prefix.
16
- *
17
- * - non-empty object: map canonical route paths to every configured
18
- * language; route generation then validates that every localisable route
19
- * path has entries for all configured languages.
20
- * - absent / `false` / `true` / empty object: keep only locale-prefix
21
- * behavior (`/en/about`).
22
- *
23
- * Strictly opt-in: without a map, `localePathRedirect` + `languages` behave
24
- * exactly like upstream Modern.js.
25
- */
26
- localisedUrls?: LocalisedUrlsOption;
27
13
  }
28
14
 
29
15
  export interface LocaleDetectionOptions extends BaseLocaleDetectionOptions {
@@ -0,0 +1,11 @@
1
+ /** Per-plugin URL policy. Search and hash remain owned by native URL handling. */
2
+ export interface I18nUrlStrategy {
3
+ localizePathname(
4
+ pathname: string,
5
+ language: string,
6
+ languages: readonly string[],
7
+ ): string;
8
+ canonicalPathname(pathname: string, languages: readonly string[]): string;
9
+ /** Adds exclusions; it cannot enable redirects excluded by native rules. */
10
+ shouldSkipRedirect?(pathname: string, languages: readonly string[]): boolean;
11
+ }
@@ -0,0 +1,71 @@
1
+ import { RuntimeComponentResolverContext } from '@modern-js/runtime/context';
2
+ import { describe, expect, test } from '@rstest/core';
3
+ import { useState } from 'react';
4
+ import { renderToStaticMarkup } from 'react-dom/server';
5
+ import {
6
+ FederatedI18nBoundary,
7
+ type FederatedI18nBoundaryProps,
8
+ } from '../src/runtime/context';
9
+
10
+ const props: FederatedI18nBoundaryProps = {
11
+ defaultNamespace: 'inventory',
12
+ resources: {},
13
+ children: <span>child content</span>,
14
+ };
15
+
16
+ describe('native federation resolver wrapper', () => {
17
+ test('reports missing integration when no resolver is installed', () => {
18
+ expect(() =>
19
+ renderToStaticMarkup(<FederatedI18nBoundary {...props} />),
20
+ ).toThrow(/requires the @modern-js\/i18n-integration runtime plugin/);
21
+ });
22
+
23
+ test('retains the actionable fallback when a resolver does not handle i18n', () => {
24
+ expect(() =>
25
+ renderToStaticMarkup(
26
+ <RuntimeComponentResolverContext.Provider
27
+ value={component => component}
28
+ >
29
+ <FederatedI18nBoundary {...props} />
30
+ </RuntimeComponentResolverContext.Provider>,
31
+ ),
32
+ ).toThrow(/requires the @modern-js\/i18n-integration runtime plugin/);
33
+ });
34
+
35
+ test('rejects resolution back to the native wrapper', () => {
36
+ expect(() =>
37
+ renderToStaticMarkup(
38
+ <RuntimeComponentResolverContext.Provider
39
+ value={() => FederatedI18nBoundary}
40
+ >
41
+ <FederatedI18nBoundary {...props} />
42
+ </RuntimeComponentResolverContext.Provider>,
43
+ ),
44
+ ).toThrow(/resolver returned its own wrapper/);
45
+ });
46
+
47
+ test('renders the resolved hook-bearing component with unchanged props', () => {
48
+ let receivedProps: FederatedI18nBoundaryProps | undefined;
49
+ function ResolvedBoundary(value: FederatedI18nBoundaryProps) {
50
+ receivedProps = value;
51
+ const [label] = useState('resolved');
52
+ return (
53
+ <section>
54
+ {label}:{value.children}
55
+ </section>
56
+ );
57
+ }
58
+ const html = renderToStaticMarkup(
59
+ <RuntimeComponentResolverContext.Provider
60
+ value={(component, { name }) =>
61
+ name === 'i18n.FederatedI18nBoundary' ? ResolvedBoundary : component
62
+ }
63
+ >
64
+ <FederatedI18nBoundary {...props} />
65
+ </RuntimeComponentResolverContext.Provider>,
66
+ );
67
+ expect(html).toBe('<section>resolved:<span>child content</span></section>');
68
+ expect(receivedProps?.resources).toBe(props.resources);
69
+ expect(receivedProps?.defaultNamespace).toBe('inventory');
70
+ });
71
+ });
@@ -1,28 +1,11 @@
1
+ import { mkdir, mkdtemp, rm, writeFile } from 'node:fs/promises';
2
+ import { tmpdir } from 'node:os';
3
+ import { join } from 'node:path';
1
4
  import { describe, expect, test } from '@rstest/core';
2
- import type { I18nInstance } from '../src/runtime/i18n';
3
- import {
4
- DEFAULT_I18NEXT_BACKEND_OPTIONS as NODE_DEFAULT_I18NEXT_BACKEND_OPTIONS,
5
- resolveDefaultLocalesDir,
6
- } from '../src/runtime/i18n/backend/defaults.node';
7
5
  import {
8
6
  FsBackendWithSave,
9
7
  resolveFsBackendConstructor,
10
8
  } from '../src/runtime/i18n/backend/middleware.node';
11
- import { initializeI18nInstance } from '../src/runtime/i18n/utils';
12
-
13
- function createBackendI18nInstance(): I18nInstance {
14
- return {
15
- language: 'en',
16
- isInitialized: false,
17
- init: async () => undefined,
18
- use: () => {},
19
- t: (key: string | string[]) => (Array.isArray(key) ? key[0] : key),
20
- options: {},
21
- store: {
22
- data: {},
23
- },
24
- };
25
- }
26
9
 
27
10
  describe('i18n runtime utils', () => {
28
11
  test('normalizes node fs backend CJS and ESM namespace shapes', () => {
@@ -45,50 +28,33 @@ describe('i18n runtime utils', () => {
45
28
  ).toBe(FakeBackend);
46
29
  });
47
30
 
48
- test('node fs backend wrapper extends a resolved constructor', () => {
49
- const backend = new FsBackendWithSave({}, {}, {}) as {
50
- type?: string;
51
- save: (language: string, namespace: string, data: unknown) => void;
52
- };
53
-
54
- expect(backend.type).toBe('backend');
55
- expect(() => backend.save('en', 'translation', {})).not.toThrow();
56
- });
57
-
58
- test('node fs backend defaults follow the detected locales directory', () => {
59
- // The default must match whichever conventional root exists at runtime
60
- // (./locales first, then ./config/public/locales), mirroring the CLI
61
- // plugin's detectLocalesDirectory. Detailed precedence cases live in
62
- // tests/backendDefaults.test.ts.
63
- expect(NODE_DEFAULT_I18NEXT_BACKEND_OPTIONS.loadPath).toBe(
64
- `${resolveDefaultLocalesDir()}/{{lng}}/{{ns}}.json`,
65
- );
66
- });
67
-
68
- test('does not poll for backend resources after init', async () => {
69
- const i18nInstance = createBackendI18nInstance();
70
- const init = rstest.fn(async () => {
71
- i18nInstance.isInitialized = true;
72
- });
73
- i18nInstance.init = init as I18nInstance['init'];
74
- const setTimeoutSpy = rstest.spyOn(globalThis, 'setTimeout');
75
-
76
- await initializeI18nInstance(
77
- i18nInstance,
78
- 'cs',
79
- 'en',
80
- ['en', 'cs'],
81
- {},
82
- {
83
- enabled: true,
84
- loadPath: '/locales/{{lng}}/{{ns}}.json',
85
- },
86
- {},
87
- );
88
-
89
- expect(init).toHaveBeenCalledTimes(1);
90
- expect(setTimeoutSpy).not.toHaveBeenCalled();
91
-
92
- setTimeoutSpy.mockRestore();
31
+ test('loads translation resources through the node fs backend', async () => {
32
+ const root = await mkdtemp(join(tmpdir(), 'i18n-backend-'));
33
+
34
+ try {
35
+ await mkdir(join(root, 'cs'), { recursive: true });
36
+ await writeFile(
37
+ join(root, 'cs', 'common.json'),
38
+ JSON.stringify({ title: 'Ahoj' }),
39
+ );
40
+ const backend = new FsBackendWithSave(
41
+ {},
42
+ { loadPath: join(root, '{{lng}}', '{{ns}}.json') },
43
+ {},
44
+ );
45
+ const resources = await new Promise<unknown>((resolve, reject) => {
46
+ backend.read('cs', 'common', (error: Error | null, data: unknown) => {
47
+ if (error) {
48
+ reject(error);
49
+ } else {
50
+ resolve(data);
51
+ }
52
+ });
53
+ });
54
+
55
+ expect(resources).toEqual({ title: 'Ahoj' });
56
+ } finally {
57
+ await rm(root, { force: true, recursive: true });
58
+ }
93
59
  });
94
60
  });