@bleedingdev/modern-js-plugin-i18n 3.5.0-ultramodern.9 → 3.5.0-ultramodern.91

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 (221) hide show
  1. package/dist/cjs/cli/index.js +2 -1
  2. package/dist/cjs/cli/locales.js +15 -15
  3. package/dist/cjs/runtime/Link.js +8 -89
  4. package/dist/cjs/runtime/context.js +78 -91
  5. package/dist/cjs/runtime/contextHelpers.js +143 -0
  6. package/dist/cjs/runtime/core.js +32 -132
  7. package/dist/cjs/runtime/hooks.js +54 -21
  8. package/dist/cjs/runtime/i18n/backend/defaults.node.js +7 -7
  9. package/dist/cjs/runtime/i18n/detection/cache.js +150 -0
  10. package/dist/cjs/runtime/i18n/detection/detector.js +100 -0
  11. package/dist/cjs/runtime/i18n/detection/index.js +8 -263
  12. package/dist/cjs/runtime/i18n/detection/initOptions.js +69 -0
  13. package/dist/cjs/runtime/i18n/detection/language.js +62 -0
  14. package/dist/cjs/runtime/i18n/detection/path.js +49 -0
  15. package/dist/cjs/runtime/i18n/detection/priority.js +66 -0
  16. package/dist/cjs/runtime/i18n/detection/ssr.js +70 -0
  17. package/dist/cjs/runtime/i18n/detection/types.js +18 -0
  18. package/dist/cjs/runtime/i18n/instance.js +34 -10
  19. package/dist/cjs/runtime/linkHelpers.js +148 -0
  20. package/dist/cjs/runtime/pluginSetup.js +102 -0
  21. package/dist/cjs/runtime/providerComposition.js +115 -0
  22. package/dist/cjs/runtime/reactI18next.js +43 -0
  23. package/dist/cjs/runtime/routerAdapter.js +16 -8
  24. package/dist/cjs/runtime/utils.js +1 -9
  25. package/dist/cjs/server/apiPrefix.js +72 -0
  26. package/dist/cjs/server/detectorOptions.js +97 -0
  27. package/dist/cjs/server/index.js +17 -144
  28. package/dist/cjs/server/redirectPolicy.js +96 -0
  29. package/dist/cjs/shared/detection.js +2 -104
  30. package/dist/cjs/shared/localisedUrls/config.js +49 -0
  31. package/dist/cjs/shared/localisedUrls/index.js +91 -0
  32. package/dist/cjs/shared/localisedUrls/normalise.js +87 -0
  33. package/dist/cjs/shared/localisedUrls/pathname.js +68 -0
  34. package/dist/cjs/shared/localisedUrls/patterns.js +130 -0
  35. package/dist/cjs/shared/localisedUrls/redirect.js +103 -0
  36. package/dist/cjs/shared/localisedUrls/resolve.js +88 -0
  37. package/dist/cjs/shared/localisedUrls/routes.js +130 -0
  38. package/dist/cjs/shared/localisedUrls/types.js +18 -0
  39. package/dist/cjs/shared/localisedUrls.js +28 -294
  40. package/dist/cjs/shared/utils.js +2 -8
  41. package/dist/esm/cli/index.mjs +2 -1
  42. package/dist/esm/cli/locales.mjs +13 -13
  43. package/dist/esm/runtime/Link.mjs +1 -82
  44. package/dist/esm/runtime/context.mjs +76 -92
  45. package/dist/esm/runtime/contextHelpers.mjs +87 -0
  46. package/dist/esm/runtime/core.mjs +30 -133
  47. package/dist/esm/runtime/hooks.mjs +56 -23
  48. package/dist/esm/runtime/i18n/backend/defaults.node.mjs +5 -5
  49. package/dist/esm/runtime/i18n/detection/cache.mjs +103 -0
  50. package/dist/esm/runtime/i18n/detection/detector.mjs +62 -0
  51. package/dist/esm/runtime/i18n/detection/index.mjs +4 -260
  52. package/dist/esm/runtime/i18n/detection/initOptions.mjs +28 -0
  53. package/dist/esm/runtime/i18n/detection/language.mjs +21 -0
  54. package/dist/esm/runtime/i18n/detection/path.mjs +11 -0
  55. package/dist/esm/runtime/i18n/detection/priority.mjs +28 -0
  56. package/dist/esm/runtime/i18n/detection/ssr.mjs +26 -0
  57. package/dist/esm/runtime/i18n/detection/types.mjs +0 -0
  58. package/dist/esm/runtime/i18n/instance.mjs +35 -8
  59. package/dist/esm/runtime/linkHelpers.mjs +98 -0
  60. package/dist/esm/runtime/pluginSetup.mjs +64 -0
  61. package/dist/esm/runtime/providerComposition.mjs +77 -0
  62. package/dist/esm/runtime/reactI18next.mjs +5 -0
  63. package/dist/esm/runtime/routerAdapter.mjs +16 -8
  64. package/dist/esm/runtime/utils.mjs +2 -10
  65. package/dist/esm/server/apiPrefix.mjs +31 -0
  66. package/dist/esm/server/detectorOptions.mjs +59 -0
  67. package/dist/esm/server/index.mjs +8 -135
  68. package/dist/esm/server/redirectPolicy.mjs +46 -0
  69. package/dist/esm/shared/detection.mjs +1 -71
  70. package/dist/esm/shared/localisedUrls/config.mjs +11 -0
  71. package/dist/esm/shared/localisedUrls/index.mjs +7 -0
  72. package/dist/esm/shared/localisedUrls/normalise.mjs +37 -0
  73. package/dist/esm/shared/localisedUrls/pathname.mjs +24 -0
  74. package/dist/esm/shared/localisedUrls/patterns.mjs +86 -0
  75. package/dist/esm/shared/localisedUrls/redirect.mjs +56 -0
  76. package/dist/esm/shared/localisedUrls/resolve.mjs +47 -0
  77. package/dist/esm/shared/localisedUrls/routes.mjs +89 -0
  78. package/dist/esm/shared/localisedUrls/types.mjs +0 -0
  79. package/dist/esm/shared/localisedUrls.mjs +1 -283
  80. package/dist/esm/shared/utils.mjs +1 -1
  81. package/dist/esm-node/cli/index.mjs +2 -1
  82. package/dist/esm-node/cli/locales.mjs +13 -13
  83. package/dist/esm-node/runtime/Link.mjs +1 -82
  84. package/dist/esm-node/runtime/context.mjs +76 -92
  85. package/dist/esm-node/runtime/contextHelpers.mjs +88 -0
  86. package/dist/esm-node/runtime/core.mjs +30 -133
  87. package/dist/esm-node/runtime/hooks.mjs +56 -23
  88. package/dist/esm-node/runtime/i18n/backend/defaults.node.mjs +5 -5
  89. package/dist/esm-node/runtime/i18n/detection/cache.mjs +104 -0
  90. package/dist/esm-node/runtime/i18n/detection/detector.mjs +63 -0
  91. package/dist/esm-node/runtime/i18n/detection/index.mjs +4 -260
  92. package/dist/esm-node/runtime/i18n/detection/initOptions.mjs +29 -0
  93. package/dist/esm-node/runtime/i18n/detection/language.mjs +22 -0
  94. package/dist/esm-node/runtime/i18n/detection/path.mjs +12 -0
  95. package/dist/esm-node/runtime/i18n/detection/priority.mjs +29 -0
  96. package/dist/esm-node/runtime/i18n/detection/ssr.mjs +27 -0
  97. package/dist/esm-node/runtime/i18n/detection/types.mjs +1 -0
  98. package/dist/esm-node/runtime/i18n/instance.mjs +35 -8
  99. package/dist/esm-node/runtime/linkHelpers.mjs +99 -0
  100. package/dist/esm-node/runtime/pluginSetup.mjs +65 -0
  101. package/dist/esm-node/runtime/providerComposition.mjs +78 -0
  102. package/dist/esm-node/runtime/reactI18next.mjs +6 -0
  103. package/dist/esm-node/runtime/routerAdapter.mjs +16 -8
  104. package/dist/esm-node/runtime/utils.mjs +2 -10
  105. package/dist/esm-node/server/apiPrefix.mjs +32 -0
  106. package/dist/esm-node/server/detectorOptions.mjs +60 -0
  107. package/dist/esm-node/server/index.mjs +8 -135
  108. package/dist/esm-node/server/redirectPolicy.mjs +47 -0
  109. package/dist/esm-node/shared/detection.mjs +1 -71
  110. package/dist/esm-node/shared/localisedUrls/config.mjs +12 -0
  111. package/dist/esm-node/shared/localisedUrls/index.mjs +8 -0
  112. package/dist/esm-node/shared/localisedUrls/normalise.mjs +38 -0
  113. package/dist/esm-node/shared/localisedUrls/pathname.mjs +25 -0
  114. package/dist/esm-node/shared/localisedUrls/patterns.mjs +87 -0
  115. package/dist/esm-node/shared/localisedUrls/redirect.mjs +57 -0
  116. package/dist/esm-node/shared/localisedUrls/resolve.mjs +48 -0
  117. package/dist/esm-node/shared/localisedUrls/routes.mjs +90 -0
  118. package/dist/esm-node/shared/localisedUrls/types.mjs +1 -0
  119. package/dist/esm-node/shared/localisedUrls.mjs +1 -283
  120. package/dist/esm-node/shared/utils.mjs +1 -1
  121. package/dist/types/cli/locales.d.ts +1 -1
  122. package/dist/types/runtime/Link.d.ts +3 -7
  123. package/dist/types/runtime/context.d.ts +21 -5
  124. package/dist/types/runtime/contextHelpers.d.ts +29 -0
  125. package/dist/types/runtime/core.d.ts +4 -8
  126. package/dist/types/runtime/hooks.d.ts +3 -7
  127. package/dist/types/runtime/i18n/backend/middleware.common.d.ts +2 -1
  128. package/dist/types/runtime/i18n/detection/cache.d.ts +14 -0
  129. package/dist/types/runtime/i18n/detection/detector.d.ts +8 -0
  130. package/dist/types/runtime/i18n/detection/index.d.ts +5 -50
  131. package/dist/types/runtime/i18n/detection/initOptions.d.ts +10 -0
  132. package/dist/types/runtime/i18n/detection/language.d.ts +11 -0
  133. package/dist/types/runtime/i18n/detection/middleware.d.ts +4 -4
  134. package/dist/types/runtime/i18n/detection/middleware.node.d.ts +4 -4
  135. package/dist/types/runtime/i18n/detection/path.d.ts +5 -0
  136. package/dist/types/runtime/i18n/detection/priority.d.ts +10 -0
  137. package/dist/types/runtime/i18n/detection/ssr.d.ts +9 -0
  138. package/dist/types/runtime/i18n/detection/types.d.ts +32 -0
  139. package/dist/types/runtime/i18n/instance.d.ts +31 -27
  140. package/dist/types/runtime/i18n/react-i18next.d.ts +1 -6
  141. package/dist/types/runtime/i18n/utils.d.ts +14 -5
  142. package/dist/types/runtime/linkHelpers.d.ts +17 -0
  143. package/dist/types/runtime/pluginSetup.d.ts +32 -0
  144. package/dist/types/runtime/providerComposition.d.ts +16 -0
  145. package/dist/types/runtime/reactI18next.d.ts +7 -0
  146. package/dist/types/runtime/routerAdapter.d.ts +7 -6
  147. package/dist/types/runtime/utils.d.ts +7 -2
  148. package/dist/types/server/apiPrefix.d.ts +7 -0
  149. package/dist/types/server/detectorOptions.d.ts +21 -0
  150. package/dist/types/server/index.d.ts +1 -6
  151. package/dist/types/server/redirectPolicy.d.ts +22 -0
  152. package/dist/types/shared/detection.d.ts +1 -11
  153. package/dist/types/shared/localisedUrls/config.d.ts +9 -0
  154. package/dist/types/shared/localisedUrls/index.d.ts +9 -0
  155. package/dist/types/shared/localisedUrls/normalise.d.ts +10 -0
  156. package/dist/types/shared/localisedUrls/pathname.d.ts +4 -0
  157. package/dist/types/shared/localisedUrls/patterns.d.ts +5 -0
  158. package/dist/types/shared/localisedUrls/redirect.d.ts +11 -0
  159. package/dist/types/shared/localisedUrls/resolve.d.ts +7 -0
  160. package/dist/types/shared/localisedUrls/routes.d.ts +3 -0
  161. package/dist/types/shared/localisedUrls/types.d.ts +14 -0
  162. package/dist/types/shared/localisedUrls.d.ts +2 -36
  163. package/dist/types/shared/utils.d.ts +0 -2
  164. package/package.json +17 -20
  165. package/rstest.config.mts +8 -1
  166. package/src/cli/index.ts +5 -4
  167. package/src/cli/locales.ts +3 -3
  168. package/src/runtime/Link.tsx +11 -138
  169. package/src/runtime/context.tsx +170 -207
  170. package/src/runtime/contextHelpers.ts +242 -0
  171. package/src/runtime/core.tsx +48 -275
  172. package/src/runtime/hooks.ts +86 -24
  173. package/src/runtime/i18n/backend/defaults.node.ts +2 -2
  174. package/src/runtime/i18n/backend/middleware.common.ts +5 -4
  175. package/src/runtime/i18n/detection/cache.ts +189 -0
  176. package/src/runtime/i18n/detection/detector.ts +166 -0
  177. package/src/runtime/i18n/detection/index.ts +10 -641
  178. package/src/runtime/i18n/detection/initOptions.ts +74 -0
  179. package/src/runtime/i18n/detection/language.ts +64 -0
  180. package/src/runtime/i18n/detection/middleware.node.ts +26 -7
  181. package/src/runtime/i18n/detection/middleware.ts +15 -7
  182. package/src/runtime/i18n/detection/path.ts +41 -0
  183. package/src/runtime/i18n/detection/priority.ts +82 -0
  184. package/src/runtime/i18n/detection/ssr.ts +47 -0
  185. package/src/runtime/i18n/detection/types.ts +41 -0
  186. package/src/runtime/i18n/instance.ts +98 -34
  187. package/src/runtime/i18n/react-i18next.ts +1 -6
  188. package/src/runtime/i18n/utils.ts +18 -9
  189. package/src/runtime/linkHelpers.ts +174 -0
  190. package/src/runtime/pluginSetup.ts +189 -0
  191. package/src/runtime/providerComposition.tsx +148 -0
  192. package/src/runtime/reactI18next.ts +20 -0
  193. package/src/runtime/routerAdapter.tsx +36 -15
  194. package/src/runtime/utils.ts +13 -35
  195. package/src/server/apiPrefix.ts +67 -0
  196. package/src/server/detectorOptions.ts +105 -0
  197. package/src/server/index.ts +14 -319
  198. package/src/server/redirectPolicy.ts +127 -0
  199. package/src/shared/detection.ts +1 -1
  200. package/src/shared/localisedUrls/config.ts +18 -0
  201. package/src/shared/localisedUrls/index.ts +24 -0
  202. package/src/shared/localisedUrls/normalise.ts +67 -0
  203. package/src/shared/localisedUrls/pathname.ts +60 -0
  204. package/src/shared/localisedUrls/patterns.ts +160 -0
  205. package/src/shared/localisedUrls/redirect.ts +93 -0
  206. package/src/shared/localisedUrls/resolve.ts +135 -0
  207. package/src/shared/localisedUrls/routes.ts +234 -0
  208. package/src/shared/localisedUrls/types.ts +18 -0
  209. package/src/shared/localisedUrls.ts +26 -623
  210. package/src/shared/utils.ts +2 -2
  211. package/tests/federatedI18nBoundary.client.test.tsx +112 -0
  212. package/tests/federatedI18nBoundary.test.tsx +120 -0
  213. package/tests/fixtures/localised-routes.golden.json +156 -0
  214. package/tests/link.test.tsx +111 -0
  215. package/tests/localisedUrlRewriteMatrix.fork.test.ts +147 -0
  216. package/tests/localisedUrls.test.ts +148 -2
  217. package/tests/reactI18nextRuntimeBoundary.test.ts +6 -3
  218. package/tests/redirectPolicy.test.ts +45 -0
  219. package/tests/routerAdapter.test.tsx +603 -0
  220. package/tests/type-fixture/fixture-globals.d.ts +11 -0
  221. package/tests/type-fixture/tsconfig.json +1 -0
@@ -0,0 +1,112 @@
1
+ import { expect, test } from '@rstest/core';
2
+ import i18next, { type i18n } from 'i18next';
3
+ import { act, useState } from 'react';
4
+ import { hydrateRoot } from 'react-dom/client';
5
+ import { renderToString } from 'react-dom/server';
6
+ import { ModernI18nProvider } from '../src/runtime/context';
7
+ import { FederatedI18nBoundary, useModernI18n } from '../src/runtime/core';
8
+
9
+ const remoteResources = {
10
+ cs: {
11
+ inventory: {
12
+ 'inventory.widgetBody': 'C1 skladová plocha vlastněná MicroVerticalem.',
13
+ },
14
+ },
15
+ en: {
16
+ inventory: {
17
+ 'inventory.widgetBody':
18
+ 'C1 inventory surface owned by the MicroVertical.',
19
+ },
20
+ },
21
+ };
22
+
23
+ function InventoryCopy() {
24
+ const { t } = useModernI18n();
25
+
26
+ return <p data-testid="inventory-copy">{t('inventory.widgetBody')}</p>;
27
+ }
28
+
29
+ function Host({ hostI18n }: { hostI18n: i18n }) {
30
+ const [language, setLanguage] = useState('en');
31
+
32
+ return (
33
+ <ModernI18nProvider
34
+ value={{
35
+ i18nInstance: hostI18n,
36
+ language,
37
+ languages: ['en', 'cs'],
38
+ }}
39
+ >
40
+ <button
41
+ onClick={async () => {
42
+ await hostI18n.changeLanguage('cs');
43
+ setLanguage('cs');
44
+ }}
45
+ type="button"
46
+ >
47
+ cs
48
+ </button>
49
+ <FederatedI18nBoundary
50
+ defaultNamespace="inventory"
51
+ fallbackLanguage="en"
52
+ resources={remoteResources}
53
+ supportedLanguages={['en', 'cs']}
54
+ >
55
+ <InventoryCopy />
56
+ </FederatedI18nBoundary>
57
+ </ModernI18nProvider>
58
+ );
59
+ }
60
+
61
+ async function createHostI18n() {
62
+ const instance = i18next.createInstance();
63
+ await instance.init({
64
+ defaultNS: 'inventory',
65
+ fallbackLng: 'en',
66
+ initImmediate: false,
67
+ lng: 'en',
68
+ resources: {
69
+ cs: {
70
+ inventory: {
71
+ 'inventory.widgetBody': 'stale host Czech copy',
72
+ },
73
+ },
74
+ en: {
75
+ inventory: {
76
+ 'inventory.widgetBody': 'stale host English copy',
77
+ },
78
+ },
79
+ },
80
+ supportedLngs: ['en', 'cs'],
81
+ });
82
+ return instance;
83
+ }
84
+
85
+ test('hydrates remote-owned copy and follows the host language', async () => {
86
+ const serverI18n = await createHostI18n();
87
+ const container = document.createElement('div');
88
+ container.innerHTML = renderToString(<Host hostI18n={serverI18n} />);
89
+ document.body.appendChild(container);
90
+
91
+ const clientI18n = await createHostI18n();
92
+ const hydrationErrors: unknown[] = [];
93
+ const root = hydrateRoot(container, <Host hostI18n={clientI18n} />, {
94
+ onRecoverableError: error => hydrationErrors.push(error),
95
+ });
96
+
97
+ await act(async () => undefined);
98
+ expect(
99
+ container.querySelector('[data-testid="inventory-copy"]')?.textContent,
100
+ ).toBe('C1 inventory surface owned by the MicroVertical.');
101
+
102
+ await act(async () => {
103
+ (container.querySelector('button') as HTMLButtonElement).click();
104
+ });
105
+ expect(
106
+ container.querySelector('[data-testid="inventory-copy"]')?.textContent,
107
+ ).toBe('C1 skladová plocha vlastněná MicroVerticalem.');
108
+ expect(hydrationErrors).toEqual([]);
109
+
110
+ await act(async () => root.unmount());
111
+ container.remove();
112
+ });
@@ -0,0 +1,120 @@
1
+ import { describe, expect, test } from '@rstest/core';
2
+ import i18next from 'i18next';
3
+ import { renderToStaticMarkup } from 'react-dom/server';
4
+ import { ModernI18nProvider } from '../src/runtime/context';
5
+ import { FederatedI18nBoundary, useModernI18n } from '../src/runtime/core';
6
+
7
+ const C0_COPY = 'C0 copy bundled by the shell';
8
+ const C1_COPY =
9
+ 'C1 operational independence: inventory UI and localization moved together.';
10
+ const HOST_ONLY_INVENTORY_COPY = 'inventory copy owned only by the shell';
11
+ const HOST_ONLY_SHELL_COPY = 'private shell copy';
12
+
13
+ function InventoryCopy() {
14
+ const { t } = useModernI18n();
15
+
16
+ return (
17
+ <p>
18
+ {t('inventory.widgetBody')}|{t('inventory.hostOnly')}|{t('shell:private')}
19
+ </p>
20
+ );
21
+ }
22
+
23
+ describe('FederatedI18nBoundary', () => {
24
+ test('renders remote-owned resources instead of stale host resources', async () => {
25
+ const hostI18n = i18next.createInstance();
26
+ await hostI18n.init({
27
+ defaultNS: 'inventory',
28
+ fallbackLng: 'en',
29
+ initImmediate: false,
30
+ lng: 'en',
31
+ ns: ['inventory'],
32
+ resources: {
33
+ en: {
34
+ inventory: {
35
+ 'inventory.hostOnly': HOST_ONLY_INVENTORY_COPY,
36
+ 'inventory.widgetBody': C0_COPY,
37
+ },
38
+ shell: {
39
+ private: HOST_ONLY_SHELL_COPY,
40
+ },
41
+ },
42
+ },
43
+ supportedLngs: ['en', 'cs'],
44
+ });
45
+
46
+ const html = renderToStaticMarkup(
47
+ <ModernI18nProvider
48
+ value={{
49
+ i18nInstance: hostI18n,
50
+ language: 'en',
51
+ languages: ['en', 'cs'],
52
+ }}
53
+ >
54
+ <FederatedI18nBoundary
55
+ defaultNamespace="inventory"
56
+ resources={{
57
+ en: {
58
+ inventory: {
59
+ 'inventory.widgetBody': C1_COPY,
60
+ },
61
+ },
62
+ }}
63
+ >
64
+ <InventoryCopy />
65
+ </FederatedI18nBoundary>
66
+ </ModernI18nProvider>,
67
+ );
68
+
69
+ expect(html).toContain(C1_COPY);
70
+ expect(html).not.toContain(C0_COPY);
71
+ expect(html).not.toContain(HOST_ONLY_INVENTORY_COPY);
72
+ expect(html).not.toContain(HOST_ONLY_SHELL_COPY);
73
+ expect(hostI18n.t('inventory.widgetBody')).toBe(C0_COPY);
74
+ expect(hostI18n.t('inventory.hostOnly')).toBe(HOST_ONLY_INVENTORY_COPY);
75
+ expect(hostI18n.t('shell:private')).toBe(HOST_ONLY_SHELL_COPY);
76
+ });
77
+
78
+ test('fails before mutation when cloneInstance does not isolate the host store', async () => {
79
+ const hostI18n = i18next.createInstance();
80
+ await hostI18n.init({
81
+ defaultNS: 'inventory',
82
+ initImmediate: false,
83
+ lng: 'en',
84
+ resources: {
85
+ en: {
86
+ inventory: {
87
+ 'inventory.widgetBody': C0_COPY,
88
+ },
89
+ },
90
+ },
91
+ });
92
+ hostI18n.cloneInstance = () => hostI18n;
93
+
94
+ expect(() =>
95
+ renderToStaticMarkup(
96
+ <ModernI18nProvider
97
+ value={{
98
+ i18nInstance: hostI18n,
99
+ language: 'en',
100
+ languages: ['en'],
101
+ }}
102
+ >
103
+ <FederatedI18nBoundary
104
+ defaultNamespace="inventory"
105
+ resources={{
106
+ en: {
107
+ inventory: {
108
+ 'inventory.widgetBody': C1_COPY,
109
+ },
110
+ },
111
+ }}
112
+ >
113
+ <InventoryCopy />
114
+ </FederatedI18nBoundary>
115
+ </ModernI18nProvider>,
116
+ ),
117
+ ).toThrow(/did not isolate the host resource store/u);
118
+ expect(hostI18n.t('inventory.widgetBody')).toBe(C0_COPY);
119
+ });
120
+ });
@@ -0,0 +1,156 @@
1
+ [
2
+ {
3
+ "id": ":lang",
4
+ "path": ":lang",
5
+ "type": "nested",
6
+ "origin": "file-system",
7
+ "routeType": "layout",
8
+ "_component": ":lang.tsx",
9
+ "children": [
10
+ {
11
+ "id": "about",
12
+ "path": "about",
13
+ "type": "nested",
14
+ "origin": "file-system",
15
+ "routeType": "page",
16
+ "_component": "about.tsx",
17
+ "modernCanonicalPath": "/about"
18
+ },
19
+ {
20
+ "id": "about__localised_o-nas",
21
+ "path": "o-nas",
22
+ "type": "nested",
23
+ "origin": "file-system",
24
+ "routeType": "page",
25
+ "_component": "about.tsx",
26
+ "modernCanonicalPath": "/about"
27
+ },
28
+ {
29
+ "id": "about__localised_ueber-uns",
30
+ "path": "ueber-uns",
31
+ "type": "nested",
32
+ "origin": "file-system",
33
+ "routeType": "page",
34
+ "_component": "about.tsx",
35
+ "modernCanonicalPath": "/about"
36
+ },
37
+ {
38
+ "id": "products",
39
+ "path": "products",
40
+ "type": "nested",
41
+ "origin": "file-system",
42
+ "routeType": "layout",
43
+ "_component": "products.tsx",
44
+ "children": [
45
+ {
46
+ "id": ":slug",
47
+ "path": ":slug",
48
+ "type": "nested",
49
+ "origin": "file-system",
50
+ "routeType": "page",
51
+ "_component": ":slug.tsx",
52
+ "modernCanonicalPath": "/products/:slug"
53
+ }
54
+ ],
55
+ "modernCanonicalPath": "/products"
56
+ },
57
+ {
58
+ "id": "products__localised_produkty",
59
+ "path": "produkty",
60
+ "type": "nested",
61
+ "origin": "file-system",
62
+ "routeType": "layout",
63
+ "_component": "products.tsx",
64
+ "children": [
65
+ {
66
+ "id": ":slug__localised_produkty",
67
+ "path": ":slug",
68
+ "type": "nested",
69
+ "origin": "file-system",
70
+ "routeType": "page",
71
+ "_component": ":slug.tsx",
72
+ "modernCanonicalPath": "/products/:slug"
73
+ }
74
+ ],
75
+ "modernCanonicalPath": "/products"
76
+ },
77
+ {
78
+ "id": "products__localised_produkte",
79
+ "path": "produkte",
80
+ "type": "nested",
81
+ "origin": "file-system",
82
+ "routeType": "layout",
83
+ "_component": "products.tsx",
84
+ "children": [
85
+ {
86
+ "id": ":slug__localised_produkte",
87
+ "path": ":slug",
88
+ "type": "nested",
89
+ "origin": "file-system",
90
+ "routeType": "page",
91
+ "_component": ":slug.tsx",
92
+ "modernCanonicalPath": "/products/:slug"
93
+ }
94
+ ],
95
+ "modernCanonicalPath": "/products"
96
+ },
97
+ {
98
+ "id": "docs",
99
+ "path": "docs",
100
+ "type": "nested",
101
+ "origin": "file-system",
102
+ "routeType": "layout",
103
+ "_component": "docs.tsx",
104
+ "children": [
105
+ {
106
+ "id": "*",
107
+ "path": "*",
108
+ "type": "nested",
109
+ "origin": "file-system",
110
+ "routeType": "page",
111
+ "_component": "*.tsx"
112
+ }
113
+ ],
114
+ "modernCanonicalPath": "/docs"
115
+ },
116
+ {
117
+ "id": "docs__localised_dokumenty",
118
+ "path": "dokumenty",
119
+ "type": "nested",
120
+ "origin": "file-system",
121
+ "routeType": "layout",
122
+ "_component": "docs.tsx",
123
+ "children": [
124
+ {
125
+ "id": "*__localised_dokumenty",
126
+ "path": "*",
127
+ "type": "nested",
128
+ "origin": "file-system",
129
+ "routeType": "page",
130
+ "_component": "*.tsx"
131
+ }
132
+ ],
133
+ "modernCanonicalPath": "/docs"
134
+ },
135
+ {
136
+ "id": "docs__localised_dokumente",
137
+ "path": "dokumente",
138
+ "type": "nested",
139
+ "origin": "file-system",
140
+ "routeType": "layout",
141
+ "_component": "docs.tsx",
142
+ "children": [
143
+ {
144
+ "id": "*__localised_dokumente",
145
+ "path": "*",
146
+ "type": "nested",
147
+ "origin": "file-system",
148
+ "routeType": "page",
149
+ "_component": "*.tsx"
150
+ }
151
+ ],
152
+ "modernCanonicalPath": "/docs"
153
+ }
154
+ ]
155
+ }
156
+ ]
@@ -4,6 +4,7 @@ import { act } from 'react';
4
4
  import { createRoot, type Root } from 'react-dom/client';
5
5
  import { ModernI18nProvider } from '../src/runtime/context';
6
6
  import type { I18nInstance } from '../src/runtime/i18n';
7
+ import { detectLanguageWithPriority } from '../src/runtime/i18n/detection';
7
8
  import { interpolateRouteParams, Link } from '../src/runtime/Link';
8
9
  import {
9
10
  canonicalPath,
@@ -270,6 +271,60 @@ describe('localization utilities', () => {
270
271
  });
271
272
  });
272
273
 
274
+ describe('language detection priority', () => {
275
+ test('path locale overrides stale SSR data', async () => {
276
+ const previousSsrData = (window as any)._SSR_DATA;
277
+ (window as any)._SSR_DATA = { data: { i18nData: { lng: 'en' } } };
278
+
279
+ try {
280
+ await expect(
281
+ detectLanguageWithPriority(createI18nInstance('en'), {
282
+ languages,
283
+ fallbackLanguage: 'en',
284
+ localePathRedirect: true,
285
+ i18nextDetector: false,
286
+ detection: {},
287
+ userInitOptions: {},
288
+ pathname: '/cs/produkty',
289
+ ssrContext: undefined,
290
+ }),
291
+ ).resolves.toEqual({ detectedLanguage: 'cs', finalLanguage: 'cs' });
292
+ } finally {
293
+ if (previousSsrData === undefined) {
294
+ delete (window as any)._SSR_DATA;
295
+ } else {
296
+ (window as any)._SSR_DATA = previousSsrData;
297
+ }
298
+ }
299
+ });
300
+
301
+ test('regional SSR language resolves to supported base language', async () => {
302
+ const previousSsrData = (window as any)._SSR_DATA;
303
+ (window as any)._SSR_DATA = { data: { i18nData: { lng: 'en-US' } } };
304
+
305
+ try {
306
+ await expect(
307
+ detectLanguageWithPriority(createI18nInstance('cs'), {
308
+ languages,
309
+ fallbackLanguage: 'cs',
310
+ localePathRedirect: true,
311
+ i18nextDetector: false,
312
+ detection: {},
313
+ userInitOptions: {},
314
+ pathname: '/products',
315
+ ssrContext: undefined,
316
+ }),
317
+ ).resolves.toEqual({ detectedLanguage: 'en', finalLanguage: 'en' });
318
+ } finally {
319
+ if (previousSsrData === undefined) {
320
+ delete (window as any)._SSR_DATA;
321
+ } else {
322
+ (window as any)._SSR_DATA = previousSsrData;
323
+ }
324
+ }
325
+ });
326
+ });
327
+
273
328
  describe('framework Link', () => {
274
329
  let rendered: { container: HTMLElement; root: Root } | undefined;
275
330
 
@@ -328,6 +383,26 @@ describe('framework Link', () => {
328
383
  expect(props.hash).toBe('list');
329
384
  });
330
385
 
386
+ test('preserves array search values natively', async () => {
387
+ const router = createTanstackRouter('/en/products', 'en');
388
+ rendered = await renderWithRuntime(
389
+ <ModernI18nProvider value={providerValue('en')}>
390
+ <Link
391
+ to="/products"
392
+ search={{ tag: ['boots', 'sale'], page: 2 }}
393
+ data-testid="q"
394
+ >
395
+ Products
396
+ </Link>
397
+ </ModernI18nProvider>,
398
+ createTanstackRuntimeContext(router),
399
+ );
400
+
401
+ const props = capturedLinkProps.at(-1);
402
+ expect(props.to).toBe('/en/products');
403
+ expect(props.search).toEqual({ tag: ['boots', 'sale'], page: '2' });
404
+ });
405
+
331
406
  test('renders a plain anchor for external targets', async () => {
332
407
  const router = createTanstackRouter('/en', 'en');
333
408
  rendered = await renderWithRuntime(
@@ -381,6 +456,42 @@ describe('framework Link', () => {
381
456
  expect(link?.hasAttribute('prefetch')).toBe(false);
382
457
  });
383
458
 
459
+ test('serializes array search values for fallback anchors', async () => {
460
+ rendered = await renderWithRuntime(
461
+ <ModernI18nProvider value={providerValue('en')}>
462
+ <Link
463
+ to="/products"
464
+ search={{ tag: ['boots', 'sale'], page: 2 }}
465
+ data-testid="q"
466
+ >
467
+ Products
468
+ </Link>
469
+ </ModernI18nProvider>,
470
+ { isBrowser: true, requestContext, context: requestContext } as any,
471
+ );
472
+
473
+ const link = rendered.container.querySelector('[data-testid="q"]');
474
+ expect(link?.getAttribute('href')).toBe(
475
+ '/en/products?tag=boots&tag=sale&page=2',
476
+ );
477
+ });
478
+
479
+ test('empty search prop clears query from target fallback anchors', async () => {
480
+ rendered = await renderWithRuntime(
481
+ <ModernI18nProvider value={providerValue('en')}>
482
+ <Link to="/products?tag=x" search="" data-testid="clear-query">
483
+ Products
484
+ </Link>
485
+ </ModernI18nProvider>,
486
+ { isBrowser: true, requestContext, context: requestContext } as any,
487
+ );
488
+
489
+ const link = rendered.container.querySelector(
490
+ '[data-testid="clear-query"]',
491
+ );
492
+ expect(link?.getAttribute('href')).toBe('/en/products');
493
+ });
494
+
384
495
  test('maps prefetch to the TanStack preload prop', async () => {
385
496
  const router = createTanstackRouter('/en/products', 'en');
386
497
  rendered = await renderWithRuntime(
@@ -0,0 +1,147 @@
1
+ import { describe, expect, test } from '@rstest/core';
2
+ import {
3
+ interpolateRouteParams,
4
+ normalizeSearch,
5
+ } from '../src/runtime/linkHelpers';
6
+ import { canonicalPath, localizePath } from '../src/runtime/localizedPaths';
7
+ import { buildLocalizedUrl } from '../src/runtime/utils';
8
+ import type { LocalisedUrlsMap } from '../src/shared/localisedUrls';
9
+
10
+ const languages = ['en', 'cs'];
11
+
12
+ const localisedUrls = {
13
+ '/products/:slug': {
14
+ en: '/products/:slug',
15
+ cs: '/produkty/:slug',
16
+ },
17
+ '/files/*': {
18
+ en: '/files/*',
19
+ cs: '/soubory/*',
20
+ },
21
+ '/app/products/:slug': {
22
+ en: '/app/products/:slug',
23
+ cs: '/app/produkty/:slug',
24
+ },
25
+ } satisfies LocalisedUrlsMap;
26
+
27
+ const pathsConfig = {
28
+ languages,
29
+ localisedUrls,
30
+ };
31
+
32
+ describe('fork-owned localised URL rewrite matrix', () => {
33
+ const rewriteScenarios = [
34
+ {
35
+ name: 'adds the target locale prefix and localises canonical segments',
36
+ target: '/products/red-shoe',
37
+ language: 'cs',
38
+ expected: '/cs/produkty/red-shoe',
39
+ },
40
+ {
41
+ name: 'strips a case-insensitive source locale before relocalising',
42
+ target: '/CS/produkty/red%20shoe?tag=boots#details',
43
+ language: 'en',
44
+ expected: '/en/products/red%20shoe?tag=boots#details',
45
+ },
46
+ {
47
+ name: 'preserves repeated query keys and the fragment',
48
+ target: '/products/red-shoe?tag=boots&tag=sale#details',
49
+ language: 'cs',
50
+ expected: '/cs/produkty/red-shoe?tag=boots&tag=sale#details',
51
+ },
52
+ {
53
+ name: 'keeps a base-path segment inside the localised route map',
54
+ target: '/app/products/red-shoe?ref=nav#details',
55
+ language: 'cs',
56
+ expected: '/cs/app/produkty/red-shoe?ref=nav#details',
57
+ },
58
+ {
59
+ name: 'keeps the default locale explicitly prefixed',
60
+ target: '/products/red-shoe',
61
+ language: 'en',
62
+ expected: '/en/products/red-shoe',
63
+ },
64
+ {
65
+ name: 'does not double-prefix an already-localised URL',
66
+ target: '/cs/produkty/red-shoe?tag=boots#details',
67
+ language: 'cs',
68
+ expected: '/cs/produkty/red-shoe?tag=boots#details',
69
+ },
70
+ ];
71
+
72
+ for (const scenario of rewriteScenarios) {
73
+ test(scenario.name, () => {
74
+ expect(
75
+ buildLocalizedUrl(
76
+ scenario.target,
77
+ scenario.language,
78
+ languages,
79
+ localisedUrls,
80
+ ),
81
+ ).toBe(scenario.expected);
82
+ });
83
+ }
84
+
85
+ test('canonical and localized path helpers strip and add prefixes exactly', () => {
86
+ expect(
87
+ canonicalPath('/CS/produkty/red-shoe?tag=boots#details', pathsConfig),
88
+ ).toBe('/products/red-shoe?tag=boots#details');
89
+ expect(
90
+ localizePath('/products/red-shoe?tag=boots#details', 'cs', pathsConfig),
91
+ ).toBe('/cs/produkty/red-shoe?tag=boots#details');
92
+ });
93
+
94
+ test('splat params preserve separators and percent-encode each segment', () => {
95
+ const target = interpolateRouteParams('/files/*', {
96
+ '*': 'resume drafts/Q1 deck.pdf',
97
+ });
98
+
99
+ expect(target).toBe('/files/resume%20drafts/Q1%20deck.pdf');
100
+ expect(buildLocalizedUrl(target, 'cs', languages, localisedUrls)).toBe(
101
+ '/cs/soubory/resume%20drafts/Q1%20deck.pdf',
102
+ );
103
+ });
104
+
105
+ const searchScenarios: Array<{
106
+ name: string;
107
+ search: Parameters<typeof normalizeSearch>[0];
108
+ searchFromTo: string;
109
+ expected: ReturnType<typeof normalizeSearch>;
110
+ }> = [
111
+ {
112
+ name: 'object array values are preserved',
113
+ search: { tag: ['boots', 'sale'], page: 2 },
114
+ searchFromTo: '?ignored=1',
115
+ expected: {
116
+ searchString: '?tag=boots&tag=sale&page=2',
117
+ searchObject: { tag: ['boots', 'sale'], page: '2' },
118
+ },
119
+ },
120
+ {
121
+ name: 'target query arrays are preserved',
122
+ search: undefined,
123
+ searchFromTo: '?tag=boots&tag=sale&page=2',
124
+ expected: {
125
+ searchString: '?tag=boots&tag=sale&page=2',
126
+ searchObject: { tag: ['boots', 'sale'], page: '2' },
127
+ },
128
+ },
129
+ {
130
+ name: 'empty search clears the target query',
131
+ search: '',
132
+ searchFromTo: '?tag=boots&tag=sale',
133
+ expected: {
134
+ searchString: '',
135
+ searchObject: undefined,
136
+ },
137
+ },
138
+ ];
139
+
140
+ for (const scenario of searchScenarios) {
141
+ test(`normalizes search: ${scenario.name}`, () => {
142
+ expect(normalizeSearch(scenario.search, scenario.searchFromTo)).toEqual(
143
+ scenario.expected,
144
+ );
145
+ });
146
+ }
147
+ });