@bleedingdev/modern-js-plugin-i18n 3.9.0-ultramodern.8 → 3.9.3-ultramodern.1

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 (106) hide show
  1. package/dist/cjs/cli/index.js +11 -0
  2. package/dist/cjs/cli/localisedRoutes.js +47 -0
  3. package/dist/cjs/runtime/context.js +6 -14
  4. package/dist/cjs/runtime/contexts.js +49 -0
  5. package/dist/cjs/runtime/core.js +12 -2
  6. package/dist/cjs/runtime/i18n/detection/middleware.js +1 -1
  7. package/dist/cjs/runtime/i18n/detection/middleware.node.js +10 -1
  8. package/dist/cjs/runtime/i18n/detection/priority.js +1 -1
  9. package/dist/cjs/runtime/navigation.js +48 -0
  10. package/dist/cjs/runtime/routerAdapter.js +3 -5
  11. package/dist/cjs/runtime/sharedContexts.js +43 -0
  12. package/dist/cjs/runtime/utils.js +6 -2
  13. package/dist/cjs/server/defaultUrlStrategy.js +48 -0
  14. package/dist/cjs/server/index.js +3 -2
  15. package/dist/cjs/server/mappedUrlStrategy.js +43 -0
  16. package/dist/cjs/server/redirectPolicy.js +13 -9
  17. package/dist/cjs/shared/detection.js +118 -2
  18. package/dist/cjs/shared/mappedUrlStrategy.js +63 -0
  19. package/dist/cjs/shared/urlStrategy.js +31 -1
  20. package/dist/esm/cli/index.mjs +11 -0
  21. package/dist/esm/cli/localisedRoutes.mjs +9 -0
  22. package/dist/esm/runtime/context.mjs +2 -10
  23. package/dist/esm/runtime/contexts.mjs +5 -0
  24. package/dist/esm/runtime/core.mjs +7 -3
  25. package/dist/esm/runtime/i18n/detection/middleware.mjs +1 -1
  26. package/dist/esm/runtime/i18n/detection/middleware.node.mjs +10 -1
  27. package/dist/esm/runtime/i18n/detection/priority.mjs +1 -1
  28. package/dist/esm/runtime/navigation.mjs +7 -0
  29. package/dist/esm/runtime/routerAdapter.mjs +2 -4
  30. package/dist/esm/runtime/sharedContexts.mjs +1 -0
  31. package/dist/esm/runtime/utils.mjs +6 -2
  32. package/dist/esm/server/defaultUrlStrategy.mjs +7 -0
  33. package/dist/esm/server/index.mjs +3 -2
  34. package/dist/esm/server/mappedUrlStrategy.mjs +1 -0
  35. package/dist/esm/server/redirectPolicy.mjs +6 -2
  36. package/dist/esm/shared/detection.mjs +85 -1
  37. package/dist/esm/shared/mappedUrlStrategy.mjs +19 -0
  38. package/dist/esm/shared/urlStrategy.mjs +7 -0
  39. package/dist/esm-node/cli/index.mjs +11 -0
  40. package/dist/esm-node/cli/localisedRoutes.mjs +10 -0
  41. package/dist/esm-node/runtime/context.mjs +2 -10
  42. package/dist/esm-node/runtime/contexts.mjs +6 -0
  43. package/dist/esm-node/runtime/core.mjs +7 -3
  44. package/dist/esm-node/runtime/i18n/detection/middleware.mjs +1 -1
  45. package/dist/esm-node/runtime/i18n/detection/middleware.node.mjs +10 -1
  46. package/dist/esm-node/runtime/i18n/detection/priority.mjs +1 -1
  47. package/dist/esm-node/runtime/navigation.mjs +8 -0
  48. package/dist/esm-node/runtime/routerAdapter.mjs +2 -4
  49. package/dist/esm-node/runtime/sharedContexts.mjs +2 -0
  50. package/dist/esm-node/runtime/utils.mjs +6 -2
  51. package/dist/esm-node/server/defaultUrlStrategy.mjs +8 -0
  52. package/dist/esm-node/server/index.mjs +3 -2
  53. package/dist/esm-node/server/mappedUrlStrategy.mjs +2 -0
  54. package/dist/esm-node/server/redirectPolicy.mjs +6 -2
  55. package/dist/esm-node/shared/detection.mjs +85 -1
  56. package/dist/esm-node/shared/mappedUrlStrategy.mjs +20 -0
  57. package/dist/esm-node/shared/urlStrategy.mjs +7 -0
  58. package/dist/types/cli/localisedRoutes.d.ts +19 -0
  59. package/dist/types/runtime/context.d.ts +2 -2
  60. package/dist/types/runtime/contexts.d.ts +6 -0
  61. package/dist/types/runtime/core.d.ts +2 -1
  62. package/dist/types/runtime/i18n/detection/middleware.d.ts +1 -1
  63. package/dist/types/runtime/i18n/detection/middleware.node.d.ts +1 -5
  64. package/dist/types/runtime/navigation.d.ts +10 -0
  65. package/dist/types/runtime/sharedContexts.d.ts +1 -0
  66. package/dist/types/runtime/utils.d.ts +1 -1
  67. package/dist/types/server/defaultUrlStrategy.d.ts +11 -0
  68. package/dist/types/server/index.d.ts +1 -1
  69. package/dist/types/server/mappedUrlStrategy.d.ts +7 -0
  70. package/dist/types/server/redirectPolicy.d.ts +1 -1
  71. package/dist/types/shared/detection.d.ts +11 -1
  72. package/dist/types/shared/mappedUrlStrategy.d.ts +27 -0
  73. package/dist/types/shared/type.d.ts +10 -0
  74. package/dist/types/shared/urlStrategy.d.ts +11 -0
  75. package/package.json +43 -24
  76. package/src/cli/index.ts +43 -1
  77. package/src/cli/localisedRoutes.ts +53 -0
  78. package/src/runtime/context.tsx +6 -31
  79. package/src/runtime/contexts.ts +16 -0
  80. package/src/runtime/core.tsx +30 -3
  81. package/src/runtime/i18n/detection/middleware.node.ts +25 -7
  82. package/src/runtime/i18n/detection/middleware.ts +2 -0
  83. package/src/runtime/i18n/detection/priority.ts +2 -0
  84. package/src/runtime/navigation.tsx +20 -0
  85. package/src/runtime/routerAdapter.tsx +2 -11
  86. package/src/runtime/sharedContexts.ts +8 -0
  87. package/src/runtime/utils.ts +13 -4
  88. package/src/server/defaultUrlStrategy.ts +27 -0
  89. package/src/server/index.ts +7 -12
  90. package/src/server/mappedUrlStrategy.ts +10 -0
  91. package/src/server/redirectPolicy.ts +19 -4
  92. package/src/shared/detection.ts +34 -10
  93. package/src/shared/mappedUrlStrategy.ts +59 -0
  94. package/src/shared/type.ts +10 -0
  95. package/src/shared/urlStrategy.ts +27 -0
  96. package/rslib.config.mts +0 -4
  97. package/rstest.config.mts +0 -46
  98. package/tests/backendDefaults.test.ts +0 -30
  99. package/tests/federatedI18nBoundaryResolver.test.tsx +0 -65
  100. package/tests/i18nUtils.test.ts +0 -60
  101. package/tests/link.test.tsx +0 -139
  102. package/tests/localisedUrls.test.ts +0 -176
  103. package/tests/reactI18nextRuntimeBoundary.test.ts +0 -117
  104. package/tests/redirectPolicy.test.ts +0 -54
  105. package/tests/routerAdapter.test.tsx +0 -221
  106. package/tsconfig.json +0 -15
@@ -1,4 +1,5 @@
1
- import type { I18nUrlStrategy } from '../shared/urlStrategy';
1
+ import { isDefaultLocaleRedirectSkipPath } from '../shared/mappedUrlStrategy';
2
+ import { asI18nUrlStrategy, type I18nUrlStrategy } from '../shared/urlStrategy';
2
3
 
3
4
  interface LocaleRedirectRequest {
4
5
  url: string;
@@ -23,7 +24,20 @@ export const shouldIgnoreRedirect = (
23
24
  languages: string[] = [],
24
25
  ): boolean => {
25
26
  const remainingPath = stripUrlPathPrefix(pathname, urlPath);
26
- if (urlStrategy?.shouldSkipRedirect?.(remainingPath, languages)) {
27
+ // Federation artifacts (`backend-mf-manifest.json`, `backendRemoteEntry.cjs`,
28
+ // `mf-manifest.json`, …) are never pages: redirecting them to a locale
29
+ // prefix hands a remote consumer an HTML document. This is native policy and
30
+ // does not depend on a URL strategy being configured; a strategy can only
31
+ // add exclusions.
32
+ if (isDefaultLocaleRedirectSkipPath(remainingPath, languages)) {
33
+ return true;
34
+ }
35
+ if (
36
+ asI18nUrlStrategy(urlStrategy)?.shouldSkipRedirect?.(
37
+ remainingPath,
38
+ languages,
39
+ )
40
+ ) {
27
41
  return true;
28
42
  }
29
43
  if (!ignoreRedirectRoutes) {
@@ -110,8 +124,9 @@ export const buildLocalizedUrl = (
110
124
  } else {
111
125
  segments.unshift(language);
112
126
  }
113
- const pathname = urlStrategy
114
- ? urlStrategy.localizePathname(remainingPath, language, languages)
127
+ const strategy = asI18nUrlStrategy(urlStrategy);
128
+ const pathname = strategy
129
+ ? strategy.localizePathname(remainingPath, language, languages)
115
130
  : `/${segments.join('/')}`;
116
131
  return `${basePath === '/' ? '' : basePath}${pathname}${url.search}${url.hash}`;
117
132
  };
@@ -8,14 +8,13 @@ import type { LanguageDetectorOptions } from '../runtime/i18n/instance';
8
8
  * Detect language from request using the same detection logic as i18next
9
9
  * This ensures consistency between server-side and client-side detection
10
10
  */
11
- function detectLanguageFromRequest(
11
+ export function detectLanguageFromRequest(
12
12
  req: {
13
13
  url: string;
14
14
  headers:
15
- | {
16
- get: (name: string) => string | null;
17
- }
18
- | Headers;
15
+ | Headers
16
+ | { get: (name: string) => string | null }
17
+ | Record<string, string | string[] | undefined>;
19
18
  },
20
19
  languages: string[],
21
20
  detectionOptions?: LanguageDetectorOptions,
@@ -39,11 +38,21 @@ function detectLanguageFromRequest(
39
38
  order.length > 0 ? order : ['querystring', 'cookie', 'header'];
40
39
 
41
40
  // Helper to get header value
41
+ // req.headers is a Fetch API Headers instance when the caller passes the raw
42
+ // Request (e.g. ssrContext.request.raw), but modern.js's own SSR request object
43
+ // (ssrContext.request) carries headers as a plain, lower-cased key/value object.
42
44
  const getHeader = (name: string): string | null => {
43
- if (req.headers instanceof Headers) {
44
- return req.headers.get(name);
45
+ const headers = req.headers;
46
+ if (typeof (headers as Headers)?.get === 'function') {
47
+ return (headers as Headers).get(name);
48
+ }
49
+ const value = (headers as Record<string, string | string[] | undefined>)[
50
+ name.toLowerCase()
51
+ ];
52
+ if (Array.isArray(value)) {
53
+ return value[0] ?? null;
45
54
  }
46
- return req.headers.get(name);
55
+ return value ?? null;
47
56
  };
48
57
 
49
58
  // Try each detection method in order
@@ -94,7 +103,8 @@ function detectLanguageFromRequest(
94
103
  .map((lang: string) => {
95
104
  const [code, q] = lang.trim().split(';');
96
105
  return {
97
- code: code.split('-')[0], // Extract base language code
106
+ code,
107
+ baseCode: code.split('-')[0],
98
108
  quality: q ? parseFloat(q.split('=')[1]) : 1.0,
99
109
  };
100
110
  })
@@ -103,12 +113,17 @@ function detectLanguageFromRequest(
103
113
  b.quality - a.quality,
104
114
  );
105
115
 
106
- // Find first matching language
116
+ // Find first matching language, preferring an exact tag match
117
+ // (e.g. 'zh-Hant-TW') over the base language code (e.g. 'zh')
107
118
  for (const lang of languagesList) {
108
119
  if (languages.length === 0 || languages.includes(lang.code)) {
109
120
  detectedLang = lang.code;
110
121
  break;
111
122
  }
123
+ if (languages.includes(lang.baseCode)) {
124
+ detectedLang = lang.baseCode;
125
+ break;
126
+ }
112
127
  }
113
128
  }
114
129
  break;
@@ -122,6 +137,15 @@ function detectLanguageFromRequest(
122
137
  ) {
123
138
  return detectedLang;
124
139
  }
140
+
141
+ // Match query and cookie values like header values: exact tag first,
142
+ // then the supported base language (e.g. 'zh-CN' -> 'zh').
143
+ if (detectedLang) {
144
+ const baseLang = detectedLang.split('-')[0];
145
+ if (languages.includes(baseLang)) {
146
+ return baseLang;
147
+ }
148
+ }
125
149
  }
126
150
  } catch (error) {
127
151
  // Silently ignore errors
@@ -0,0 +1,59 @@
1
+ import {
2
+ canonicalTargetPathname,
3
+ isDefaultLocaleRedirectSkipPath,
4
+ type LocalisedUrlsOption,
5
+ localiseTargetPathname,
6
+ } from '@modern-js/i18n-runtime-extensions';
7
+ import type { I18nUrlStrategy } from './urlStrategy';
8
+
9
+ /**
10
+ * The fork's native locale-redirect exclusions (federation artifacts, static
11
+ * and upload prefixes). Re-exported from this fork-owned seam so the native
12
+ * runtime and server redirect paths apply them without importing the
13
+ * extensions package themselves.
14
+ */
15
+ export { isDefaultLocaleRedirectSkipPath } from '@modern-js/i18n-runtime-extensions';
16
+
17
+ /**
18
+ * Build the URL policy from a mapped-locale-URL configuration.
19
+ *
20
+ * `localeDetection.localisedUrls` is plain data, so unlike a `urlStrategy`
21
+ * object it survives the JSON boundary between `modern.config.ts` and the
22
+ * generated runtime registration. It is therefore the one configuration both
23
+ * sides can rely on for mapped URLs.
24
+ *
25
+ * This is shared rather than server-only on purpose. The server and the client
26
+ * have to agree on the mapping or a hydrated `<Link>` navigates to a path the
27
+ * server will not serve: SSR renders `/cs/obchodni-podminky` while a
28
+ * client-side navigation asks for `/cs/terms-of-service`. The helpers below are
29
+ * pure pathname functions from `@modern-js/i18n-runtime-extensions`, so the
30
+ * client cost is a few hundred bytes of string work and no i18n engine.
31
+ */
32
+ export const createMappedUrlStrategy = (
33
+ localisedUrls?: LocalisedUrlsOption,
34
+ ): I18nUrlStrategy => ({
35
+ localizePathname: (pathname, language, languages) =>
36
+ localiseTargetPathname(pathname, language, [...languages], localisedUrls),
37
+ canonicalPathname: (pathname, languages) =>
38
+ canonicalTargetPathname(pathname, [...languages], localisedUrls),
39
+ shouldSkipRedirect: (pathname, languages) =>
40
+ isDefaultLocaleRedirectSkipPath(pathname, [...languages]),
41
+ });
42
+
43
+ /** Only a non-empty map needs a derived policy. */
44
+ export const resolveMappedUrlStrategy = (
45
+ localeDetection: unknown,
46
+ ): I18nUrlStrategy | undefined => {
47
+ if (!localeDetection || typeof localeDetection !== 'object') {
48
+ return undefined;
49
+ }
50
+ const localisedUrls = Reflect.get(localeDetection, 'localisedUrls');
51
+ if (
52
+ !localisedUrls ||
53
+ typeof localisedUrls !== 'object' ||
54
+ Object.keys(localisedUrls).length === 0
55
+ ) {
56
+ return undefined;
57
+ }
58
+ return createMappedUrlStrategy(localisedUrls as LocalisedUrlsOption);
59
+ };
@@ -10,6 +10,16 @@ export interface BaseLocaleDetectionOptions {
10
10
  fallbackLanguage?: string;
11
11
  detection?: LanguageDetectorOptions;
12
12
  ignoreRedirectRoutes?: string[] | ((pathname: string) => boolean);
13
+ /**
14
+ * Canonical-path -> per-language path map, e.g.
15
+ * `{ '/products': { en: '/products', cs: '/produkty' } }`.
16
+ *
17
+ * Plain data on purpose: the generated runtime registration carries plugin
18
+ * options as JSON, so a map survives the trip where a `urlStrategy` object
19
+ * would lose its methods. The CLI expands file-system routes from it, and
20
+ * the server derives its URL strategy from it, with no extra plugin.
21
+ */
22
+ localisedUrls?: boolean | Record<string, Record<string, string>>;
13
23
  }
14
24
 
15
25
  export interface LocaleDetectionOptions extends BaseLocaleDetectionOptions {
@@ -9,3 +9,30 @@ export interface I18nUrlStrategy {
9
9
  /** Adds exclusions; it cannot enable redirects excluded by native rules. */
10
10
  shouldSkipRedirect?(pathname: string, languages: readonly string[]): boolean;
11
11
  }
12
+
13
+ /**
14
+ * A URL strategy reaches the runtime through the generated runtime
15
+ * registration, which carries plugin options as JSON. Functions do not survive
16
+ * that, so a configured strategy can arrive as an empty object — which is
17
+ * truthy and would make every call site throw. Anything that is not a complete
18
+ * strategy is treated as absent, and the caller falls back to the built-in
19
+ * language-prefix behaviour.
20
+ */
21
+ export const asI18nUrlStrategy = (
22
+ candidate: unknown,
23
+ ): I18nUrlStrategy | undefined => {
24
+ if (!candidate || typeof candidate !== 'object') {
25
+ return undefined;
26
+ }
27
+ const strategy = candidate as Partial<I18nUrlStrategy>;
28
+ return typeof strategy.localizePathname === 'function' &&
29
+ typeof strategy.canonicalPathname === 'function'
30
+ ? (strategy as I18nUrlStrategy)
31
+ : undefined;
32
+ };
33
+
34
+ /** `true` when a value was configured as a strategy but cannot be used as one. */
35
+ export const isUnusableI18nUrlStrategy = (candidate: unknown): boolean =>
36
+ Boolean(candidate) &&
37
+ typeof candidate === 'object' &&
38
+ asI18nUrlStrategy(candidate) === undefined;
package/rslib.config.mts DELETED
@@ -1,4 +0,0 @@
1
- import { rslibConfig } from '@modern-js/rslib';
2
- import { defineConfig } from '@rslib/core';
3
-
4
- export default defineConfig(rslibConfig);
package/rstest.config.mts DELETED
@@ -1,46 +0,0 @@
1
- import { dirname, resolve } from 'node:path';
2
- import { fileURLToPath } from 'node:url';
3
- import type { ProjectConfig } from '@rstest/core';
4
- import { withTestPreset } from '@scripts/rstest-config';
5
-
6
- const __dirname = dirname(fileURLToPath(import.meta.url));
7
-
8
- const commonConfig: ProjectConfig = {
9
- setupFiles: [resolve(__dirname, '../../../scripts/rstest-config/setup.ts')],
10
- globals: true,
11
- tools: {
12
- swc: {
13
- jsc: {
14
- transform: {
15
- react: {
16
- runtime: 'automatic',
17
- },
18
- },
19
- },
20
- },
21
- },
22
- };
23
-
24
- export default {
25
- projects: [
26
- withTestPreset({
27
- name: 'plugin-i18n-node',
28
- testEnvironment: 'node',
29
- include: [
30
- 'tests/i18nUtils.test.ts',
31
- 'tests/localisedUrls.test.ts',
32
- 'tests/backendDefaults.test.ts',
33
- 'tests/redirectPolicy.test.ts',
34
- 'tests/reactI18nextRuntimeBoundary.test.ts',
35
- 'tests/federatedI18nBoundaryResolver.test.tsx',
36
- ],
37
- extends: commonConfig,
38
- }),
39
- withTestPreset({
40
- name: 'plugin-i18n-client',
41
- testEnvironment: 'happy-dom',
42
- include: ['tests/routerAdapter.test.tsx', 'tests/link.test.tsx'],
43
- extends: commonConfig,
44
- }),
45
- ],
46
- };
@@ -1,30 +0,0 @@
1
- import fs from 'node:fs';
2
- import os from 'node:os';
3
- import path from 'node:path';
4
- import { describe, expect, test } from '@rstest/core';
5
- import { resolveDefaultLocalesDir } from '../src/runtime/i18n/backend/defaults.node';
6
-
7
- const makeTempDir = (...dirs: string[]): string => {
8
- const root = fs.mkdtempSync(path.join(os.tmpdir(), 'i18n-defaults-'));
9
- for (const dir of dirs) {
10
- fs.mkdirSync(path.join(root, dir), { recursive: true });
11
- }
12
- return root;
13
- };
14
-
15
- describe('node backend default loadPath', () => {
16
- test('prefers the project-root ./locales convention (the detection root)', () => {
17
- const root = makeTempDir('locales', 'config/public/locales');
18
- expect(resolveDefaultLocalesDir(root)).toBe('./locales');
19
- });
20
-
21
- test('falls back to ./config/public/locales when only it exists', () => {
22
- const root = makeTempDir('config/public/locales');
23
- expect(resolveDefaultLocalesDir(root)).toBe('./config/public/locales');
24
- });
25
-
26
- test('defaults to ./locales when neither conventional directory exists', () => {
27
- const root = makeTempDir();
28
- expect(resolveDefaultLocalesDir(root)).toBe('./locales');
29
- });
30
- });
@@ -1,65 +0,0 @@
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('retains the actionable fallback when a resolver does not handle i18n', () => {
18
- expect(() =>
19
- renderToStaticMarkup(
20
- <RuntimeComponentResolverContext.Provider
21
- value={component => component}
22
- >
23
- <FederatedI18nBoundary {...props} />
24
- </RuntimeComponentResolverContext.Provider>,
25
- ),
26
- ).toThrow(/requires the @modern-js\/i18n-integration runtime plugin/);
27
- });
28
-
29
- test('rejects resolution back to the native wrapper', () => {
30
- expect(() =>
31
- renderToStaticMarkup(
32
- <RuntimeComponentResolverContext.Provider
33
- value={() => FederatedI18nBoundary}
34
- >
35
- <FederatedI18nBoundary {...props} />
36
- </RuntimeComponentResolverContext.Provider>,
37
- ),
38
- ).toThrow(/resolver returned its own wrapper/);
39
- });
40
-
41
- test('renders the resolved hook-bearing component with unchanged props', () => {
42
- let receivedProps: FederatedI18nBoundaryProps | undefined;
43
- function ResolvedBoundary(value: FederatedI18nBoundaryProps) {
44
- receivedProps = value;
45
- const [label] = useState('resolved');
46
- return (
47
- <section>
48
- {label}:{value.children}
49
- </section>
50
- );
51
- }
52
- const html = renderToStaticMarkup(
53
- <RuntimeComponentResolverContext.Provider
54
- value={(component, { name }) =>
55
- name === 'i18n.FederatedI18nBoundary' ? ResolvedBoundary : component
56
- }
57
- >
58
- <FederatedI18nBoundary {...props} />
59
- </RuntimeComponentResolverContext.Provider>,
60
- );
61
- expect(html).toBe('<section>resolved:<span>child content</span></section>');
62
- expect(receivedProps?.resources).toBe(props.resources);
63
- expect(receivedProps?.defaultNamespace).toBe('inventory');
64
- });
65
- });
@@ -1,60 +0,0 @@
1
- import { mkdir, mkdtemp, rm, writeFile } from 'node:fs/promises';
2
- import { tmpdir } from 'node:os';
3
- import { join } from 'node:path';
4
- import { describe, expect, test } from '@rstest/core';
5
- import {
6
- FsBackendWithSave,
7
- resolveFsBackendConstructor,
8
- } from '../src/runtime/i18n/backend/middleware.node';
9
-
10
- describe('i18n runtime utils', () => {
11
- test('normalizes node fs backend CJS and ESM namespace shapes', () => {
12
- class FakeBackend {}
13
-
14
- expect(resolveFsBackendConstructor(FakeBackend)).toBe(FakeBackend);
15
- expect(resolveFsBackendConstructor({ default: FakeBackend })).toBe(
16
- FakeBackend,
17
- );
18
- expect(resolveFsBackendConstructor({ 'module.exports': FakeBackend })).toBe(
19
- FakeBackend,
20
- );
21
- expect(
22
- resolveFsBackendConstructor({ default: { default: FakeBackend } }),
23
- ).toBe(FakeBackend);
24
- expect(
25
- resolveFsBackendConstructor({
26
- default: { 'module.exports': FakeBackend },
27
- }),
28
- ).toBe(FakeBackend);
29
- });
30
-
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
- }
59
- });
60
- });
@@ -1,139 +0,0 @@
1
- import type { I18nInstance } from '../src/runtime/i18n';
2
- import { detectLanguageWithPriority } from '../src/runtime/i18n/detection';
3
- import { interpolateRouteParams } from '../src/runtime/Link';
4
- import { canonicalPath, localizePath } from '../src/runtime/localizedPaths';
5
- import { buildLocalizedUrl, splitUrlTarget } from '../src/runtime/utils';
6
-
7
- const languages = ['en', 'cs'];
8
-
9
- function createI18nInstance(language = 'en'): I18nInstance {
10
- return {
11
- language,
12
- isInitialized: true,
13
- init: () => Promise.resolve(undefined),
14
- use: () => {},
15
- t: (key: string | string[]) => (Array.isArray(key) ? key[0] : key),
16
- createInstance: () => createI18nInstance(language),
17
- services: {},
18
- options: {},
19
- };
20
- }
21
-
22
- describe('splitUrlTarget public compatibility export', () => {
23
- test('retains the shared suffix splitter', () => {
24
- expect(splitUrlTarget('/a?q=1#x')).toEqual({
25
- pathname: '/a',
26
- search: '?q=1',
27
- hash: '#x',
28
- });
29
- });
30
- });
31
-
32
- describe('buildLocalizedUrl suffix handling', () => {
33
- test('hash-only target keeps the hash and drops the trailing slash', () => {
34
- expect(buildLocalizedUrl('/#work-with-me', 'en', languages)).toBe(
35
- '/en#work-with-me',
36
- );
37
- });
38
-
39
- test('query-only target keeps the query', () => {
40
- expect(buildLocalizedUrl('/products?tag=x', 'en', languages)).toBe(
41
- '/en/products?tag=x',
42
- );
43
- });
44
-
45
- test('root path', () => {
46
- expect(buildLocalizedUrl('/', 'cs', languages)).toBe('/cs');
47
- });
48
- });
49
-
50
- describe('interpolateRouteParams', () => {
51
- test('interpolates $param and :param segments', () => {
52
- expect(interpolateRouteParams('/talks/$slug', { slug: 'ai slop' })).toBe(
53
- '/talks/ai%20slop',
54
- );
55
- expect(interpolateRouteParams('/talks/:slug', { slug: 'x' })).toBe(
56
- '/talks/x',
57
- );
58
- });
59
-
60
- test('drops missing optional segments', () => {
61
- expect(interpolateRouteParams('/opt/{-$slug}', {})).toBe('/opt');
62
- expect(interpolateRouteParams('/opt/:slug?', {})).toBe('/opt');
63
- expect(interpolateRouteParams('/opt/{-$slug}', { slug: 'v' })).toBe(
64
- '/opt/v',
65
- );
66
- });
67
-
68
- test('expands splat params', () => {
69
- expect(interpolateRouteParams('/files/$', { _splat: 'a/b' })).toBe(
70
- '/files/a/b',
71
- );
72
- expect(interpolateRouteParams('/files/*', { '*': 'a' })).toBe('/files/a');
73
- });
74
- });
75
-
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',
80
- );
81
- expect(canonicalPath('/CS/products?q=1#x', { languages })).toBe(
82
- '/products?q=1#x',
83
- );
84
- });
85
- });
86
-
87
- describe('language detection priority', () => {
88
- test('path locale overrides stale SSR data', async () => {
89
- const previousSsrData = (window as any)._SSR_DATA;
90
- (window as any)._SSR_DATA = { data: { i18nData: { lng: 'en' } } };
91
-
92
- try {
93
- await expect(
94
- detectLanguageWithPriority(createI18nInstance('en'), {
95
- languages,
96
- fallbackLanguage: 'en',
97
- localePathRedirect: true,
98
- i18nextDetector: false,
99
- detection: {},
100
- userInitOptions: {},
101
- pathname: '/cs/produkty',
102
- ssrContext: undefined,
103
- }),
104
- ).resolves.toEqual({ detectedLanguage: 'cs', finalLanguage: 'cs' });
105
- } finally {
106
- if (previousSsrData === undefined) {
107
- delete (window as any)._SSR_DATA;
108
- } else {
109
- (window as any)._SSR_DATA = previousSsrData;
110
- }
111
- }
112
- });
113
-
114
- test('regional SSR language resolves to supported base language', async () => {
115
- const previousSsrData = (window as any)._SSR_DATA;
116
- (window as any)._SSR_DATA = { data: { i18nData: { lng: 'en-US' } } };
117
-
118
- try {
119
- await expect(
120
- detectLanguageWithPriority(createI18nInstance('cs'), {
121
- languages,
122
- fallbackLanguage: 'cs',
123
- localePathRedirect: true,
124
- i18nextDetector: false,
125
- detection: {},
126
- userInitOptions: {},
127
- pathname: '/products',
128
- ssrContext: undefined,
129
- }),
130
- ).resolves.toEqual({ detectedLanguage: 'en', finalLanguage: 'en' });
131
- } finally {
132
- if (previousSsrData === undefined) {
133
- delete (window as any)._SSR_DATA;
134
- } else {
135
- (window as any)._SSR_DATA = previousSsrData;
136
- }
137
- }
138
- });
139
- });