@bleedingdev/modern-js-plugin-i18n 3.2.0-ultramodern.12 → 3.2.0-ultramodern.120

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (117) hide show
  1. package/README.md +221 -11
  2. package/dist/cjs/cli/index.js +32 -5
  3. package/dist/cjs/runtime/I18nLink.js +17 -28
  4. package/dist/cjs/runtime/Link.js +252 -0
  5. package/dist/cjs/runtime/canonicalRoutes.js +18 -0
  6. package/dist/cjs/runtime/context.js +41 -10
  7. package/dist/cjs/runtime/hooks.js +17 -10
  8. package/dist/cjs/runtime/i18n/backend/config.js +9 -5
  9. package/dist/cjs/runtime/i18n/backend/defaults.js +15 -10
  10. package/dist/cjs/runtime/i18n/backend/defaults.node.js +16 -11
  11. package/dist/cjs/runtime/i18n/backend/index.js +9 -5
  12. package/dist/cjs/runtime/i18n/backend/middleware.common.js +9 -5
  13. package/dist/cjs/runtime/i18n/backend/middleware.js +9 -5
  14. package/dist/cjs/runtime/i18n/backend/middleware.node.js +13 -9
  15. package/dist/cjs/runtime/i18n/backend/sdk-backend.js +9 -5
  16. package/dist/cjs/runtime/i18n/backend/sdk-event.js +16 -11
  17. package/dist/cjs/runtime/i18n/detection/config.js +9 -5
  18. package/dist/cjs/runtime/i18n/detection/index.js +9 -5
  19. package/dist/cjs/runtime/i18n/detection/middleware.js +9 -5
  20. package/dist/cjs/runtime/i18n/detection/middleware.node.js +9 -5
  21. package/dist/cjs/runtime/i18n/index.js +9 -5
  22. package/dist/cjs/runtime/i18n/instance.js +17 -37
  23. package/dist/cjs/runtime/i18n/react-i18next.js +53 -0
  24. package/dist/cjs/runtime/i18n/utils.js +9 -17
  25. package/dist/cjs/runtime/index.js +50 -15
  26. package/dist/cjs/runtime/localizedPaths.js +105 -0
  27. package/dist/cjs/runtime/routerAdapter.js +167 -0
  28. package/dist/cjs/runtime/utils.js +87 -97
  29. package/dist/cjs/server/index.js +69 -13
  30. package/dist/cjs/shared/deepMerge.js +12 -8
  31. package/dist/cjs/shared/detection.js +9 -5
  32. package/dist/cjs/shared/localisedUrls.js +271 -0
  33. package/dist/cjs/shared/utils.js +15 -11
  34. package/dist/esm/cli/index.mjs +23 -0
  35. package/dist/esm/runtime/I18nLink.mjs +7 -22
  36. package/dist/esm/runtime/Link.mjs +209 -0
  37. package/dist/esm/runtime/canonicalRoutes.mjs +0 -0
  38. package/dist/esm/runtime/context.mjs +34 -7
  39. package/dist/esm/runtime/hooks.mjs +9 -6
  40. package/dist/esm/runtime/i18n/backend/defaults.mjs +1 -1
  41. package/dist/esm/runtime/i18n/backend/defaults.node.mjs +2 -2
  42. package/dist/esm/runtime/i18n/backend/middleware.node.mjs +3 -3
  43. package/dist/esm/runtime/i18n/instance.mjs +1 -19
  44. package/dist/esm/runtime/i18n/react-i18next.mjs +15 -0
  45. package/dist/esm/runtime/i18n/utils.mjs +0 -12
  46. package/dist/esm/runtime/index.mjs +23 -13
  47. package/dist/esm/runtime/localizedPaths.mjs +58 -0
  48. package/dist/esm/runtime/routerAdapter.mjs +129 -0
  49. package/dist/esm/runtime/utils.mjs +25 -30
  50. package/dist/esm/server/index.mjs +53 -7
  51. package/dist/esm/shared/localisedUrls.mjs +212 -0
  52. package/dist/esm-node/cli/index.mjs +23 -0
  53. package/dist/esm-node/runtime/I18nLink.mjs +7 -22
  54. package/dist/esm-node/runtime/Link.mjs +210 -0
  55. package/dist/esm-node/runtime/canonicalRoutes.mjs +1 -0
  56. package/dist/esm-node/runtime/context.mjs +34 -7
  57. package/dist/esm-node/runtime/hooks.mjs +9 -6
  58. package/dist/esm-node/runtime/i18n/backend/defaults.mjs +1 -1
  59. package/dist/esm-node/runtime/i18n/backend/defaults.node.mjs +2 -2
  60. package/dist/esm-node/runtime/i18n/backend/middleware.node.mjs +3 -3
  61. package/dist/esm-node/runtime/i18n/instance.mjs +1 -19
  62. package/dist/esm-node/runtime/i18n/react-i18next.mjs +16 -0
  63. package/dist/esm-node/runtime/i18n/utils.mjs +0 -12
  64. package/dist/esm-node/runtime/index.mjs +23 -13
  65. package/dist/esm-node/runtime/localizedPaths.mjs +59 -0
  66. package/dist/esm-node/runtime/routerAdapter.mjs +130 -0
  67. package/dist/esm-node/runtime/utils.mjs +25 -30
  68. package/dist/esm-node/server/index.mjs +53 -7
  69. package/dist/esm-node/shared/localisedUrls.mjs +213 -0
  70. package/dist/types/cli/index.d.ts +1 -0
  71. package/dist/types/runtime/I18nLink.d.ts +6 -0
  72. package/dist/types/runtime/Link.d.ts +56 -0
  73. package/dist/types/runtime/canonicalRoutes.d.ts +60 -0
  74. package/dist/types/runtime/context.d.ts +3 -0
  75. package/dist/types/runtime/hooks.d.ts +4 -2
  76. package/dist/types/runtime/i18n/backend/middleware.node.d.ts +1 -1
  77. package/dist/types/runtime/i18n/instance.d.ts +4 -6
  78. package/dist/types/runtime/i18n/react-i18next.d.ts +7 -0
  79. package/dist/types/runtime/index.d.ts +6 -1
  80. package/dist/types/runtime/localizedPaths.d.ts +39 -0
  81. package/dist/types/runtime/routerAdapter.d.ts +26 -0
  82. package/dist/types/runtime/types.d.ts +1 -1
  83. package/dist/types/runtime/utils.d.ts +13 -9
  84. package/dist/types/server/index.d.ts +6 -0
  85. package/dist/types/shared/localisedUrls.d.ts +21 -0
  86. package/dist/types/shared/type.d.ts +12 -0
  87. package/package.json +24 -28
  88. package/rstest.config.mts +39 -0
  89. package/src/cli/index.ts +44 -1
  90. package/src/runtime/I18nLink.tsx +14 -51
  91. package/src/runtime/Link.tsx +414 -0
  92. package/src/runtime/canonicalRoutes.ts +93 -0
  93. package/src/runtime/context.tsx +45 -7
  94. package/src/runtime/hooks.ts +13 -4
  95. package/src/runtime/i18n/backend/defaults.node.ts +2 -2
  96. package/src/runtime/i18n/backend/defaults.ts +3 -1
  97. package/src/runtime/i18n/backend/middleware.node.ts +1 -1
  98. package/src/runtime/i18n/instance.ts +3 -30
  99. package/src/runtime/i18n/react-i18next.ts +25 -0
  100. package/src/runtime/i18n/utils.ts +4 -26
  101. package/src/runtime/index.tsx +47 -12
  102. package/src/runtime/localizedPaths.ts +118 -0
  103. package/src/runtime/routerAdapter.tsx +333 -0
  104. package/src/runtime/types.ts +1 -1
  105. package/src/runtime/utils.ts +44 -37
  106. package/src/server/index.ts +117 -10
  107. package/src/shared/localisedUrls.ts +453 -0
  108. package/src/shared/type.ts +12 -0
  109. package/tests/i18nUtils.test.ts +52 -0
  110. package/tests/link.test.tsx +475 -0
  111. package/tests/linkTypes.test.ts +28 -0
  112. package/tests/localisedUrls.test.ts +312 -0
  113. package/tests/routerAdapter.test.tsx +452 -0
  114. package/tests/type-fixture/linkTypes.fixture.tsx +51 -0
  115. package/tests/type-fixture/tsconfig.json +15 -0
  116. package/dist/esm/rslib-runtime.mjs +0 -18
  117. package/dist/esm-node/rslib-runtime.mjs +0 -19
@@ -15,7 +15,9 @@ function convertPath(path: string | undefined): string | undefined {
15
15
  }
16
16
  // If it's an absolute path (starts with /), convert to relative path
17
17
  if (path.startsWith('/')) {
18
- return `${window.__assetPrefix__ || ''}${path}`;
18
+ return typeof window === 'undefined'
19
+ ? path
20
+ : `${window.__assetPrefix__ || ''}${path}`;
19
21
  }
20
22
  return path;
21
23
  }
@@ -1,4 +1,4 @@
1
- import Backend from 'i18next-fs-backend';
1
+ import Backend from 'i18next-fs-backend/cjs';
2
2
  import type { ExtendedBackendOptions } from '../../../shared/type';
3
3
  import type { I18nInstance } from '../instance';
4
4
  import { useI18nextBackendCommon } from './middleware.common';
@@ -1,9 +1,5 @@
1
1
  import type { BaseBackendOptions } from '../../shared/type';
2
2
 
3
- type ReactI18nextModule = typeof import('react-i18next');
4
- type InitReactI18next = ReactI18nextModule['initReactI18next'];
5
- type I18nextProviderComponent = ReactI18nextModule['I18nextProvider'];
6
-
7
3
  export interface I18nResourceStore {
8
4
  data?: {
9
5
  [language: string]: {
@@ -110,9 +106,11 @@ export interface BackendOptions extends Omit<BaseBackendOptions, 'enabled'> {
110
106
  [key: string]: any;
111
107
  }
112
108
 
109
+ export type ResourceValue = string | { [key: string]: ResourceValue };
110
+
113
111
  export interface Resources {
114
112
  [lng: string]: {
115
- [source: string]: string | Record<string, string>;
113
+ [source: string]: ResourceValue;
116
114
  };
117
115
  }
118
116
 
@@ -171,15 +169,6 @@ async function createI18nextInstance(): Promise<I18nInstance | null> {
171
169
  }
172
170
  }
173
171
 
174
- async function tryImportReactI18next(): Promise<ReactI18nextModule | null> {
175
- try {
176
- const reactI18next = await import('react-i18next');
177
- return reactI18next;
178
- } catch (error) {
179
- return null;
180
- }
181
- }
182
-
183
172
  export function getI18nextInstanceForProvider(
184
173
  instance: I18nInstance | any,
185
174
  ): any {
@@ -213,19 +202,3 @@ export async function getI18nInstance(
213
202
 
214
203
  throw new Error('No i18n instance found');
215
204
  }
216
-
217
- export async function getInitReactI18next(): Promise<InitReactI18next | null> {
218
- const reactI18nextModule = await tryImportReactI18next();
219
- if (reactI18nextModule) {
220
- return reactI18nextModule.initReactI18next;
221
- }
222
- return null;
223
- }
224
-
225
- export async function getI18nextProvider(): Promise<I18nextProviderComponent | null> {
226
- const reactI18nextModule = await tryImportReactI18next();
227
- if (reactI18nextModule) {
228
- return reactI18nextModule.I18nextProvider;
229
- }
230
- return null;
231
- }
@@ -0,0 +1,25 @@
1
+ import type React from 'react';
2
+
3
+ type ReactI18nextModule = typeof import('react-i18next');
4
+
5
+ interface ReactI18nextIntegration {
6
+ I18nextProvider: React.ComponentType<any> | null;
7
+ initReactI18next: any | null;
8
+ }
9
+
10
+ async function tryImportReactI18next(): Promise<ReactI18nextModule | null> {
11
+ try {
12
+ return (await import('react-i18next')) as ReactI18nextModule;
13
+ } catch (error) {
14
+ return null;
15
+ }
16
+ }
17
+
18
+ export async function getReactI18nextIntegration(): Promise<ReactI18nextIntegration> {
19
+ const reactI18nextModule = await tryImportReactI18next();
20
+
21
+ return {
22
+ I18nextProvider: reactI18nextModule?.I18nextProvider ?? null,
23
+ initReactI18next: reactI18nextModule?.initReactI18next ?? null,
24
+ };
25
+ }
@@ -238,32 +238,10 @@ export const initializeI18nInstance = async (
238
238
  }
239
239
  }
240
240
 
241
- if (mergedBackend && hasOptions(i18nInstance)) {
242
- // For chained backend with cacheHitMode: 'refreshAndUpdateStore',
243
- // i18next-chained-backend automatically:
244
- // 1. Loads from the first backend (HTTP/FS) and displays immediately
245
- // 2. Asynchronously loads from the second backend (SDK) and updates the store
246
- // 3. Triggers 'loaded' event when SDK resources are loaded, which causes React to re-render
247
- //
248
- // Note: i18next.init() returns a Promise that resolves when the first backend loads.
249
- // For chained backend, it does NOT wait for the second backend (SDK) to load.
250
- // The SDK backend loads asynchronously and triggers 'loaded' event automatically.
251
- const defaultNS =
252
- initOptions.defaultNS || initOptions.ns || 'translation';
253
- const ns = Array.isArray(defaultNS) ? defaultNS[0] : defaultNS;
254
-
255
- let retries = 20;
256
- while (retries > 0) {
257
- // Get the actual i18next instance to access store property
258
- const actualInstance = getActualI18nextInstance(i18nInstance);
259
- const store = (actualInstance as any).store;
260
- if (store?.data?.[finalLanguage]?.[ns]) {
261
- break;
262
- }
263
- await new Promise(resolve => setTimeout(resolve, 100));
264
- retries--;
265
- }
266
- }
241
+ // i18next.init() is the synchronization boundary for the primary backend.
242
+ // Chained SDK refreshes update the store through their own loaded events and
243
+ // must not block SSR HTML, otherwise missing/edge-only resources add fixed
244
+ // latency to every route render.
267
245
  }
268
246
  };
269
247
 
@@ -29,11 +29,7 @@ import {
29
29
  mergeDetectionOptions,
30
30
  } from './i18n/detection';
31
31
  import { useI18nextLanguageDetector } from './i18n/detection/middleware';
32
- import {
33
- getI18nextInstanceForProvider,
34
- getI18nextProvider,
35
- getInitReactI18next,
36
- } from './i18n/instance';
32
+ import { getI18nextInstanceForProvider } from './i18n/instance';
37
33
  import {
38
34
  changeI18nLanguage,
39
35
  ensureLanguageMatch,
@@ -54,6 +50,7 @@ export interface I18nPluginOptions {
54
50
  changeLanguage?: (lang: string) => void;
55
51
  initOptions?: I18nInitOptions;
56
52
  htmlLangAttr?: boolean;
53
+ reactI18next?: boolean;
57
54
  [key: string]: any;
58
55
  }
59
56
 
@@ -72,6 +69,7 @@ export const i18nPlugin = (options: I18nPluginOptions): RuntimePlugin => ({
72
69
  localeDetection,
73
70
  backend,
74
71
  htmlLangAttr = false,
72
+ reactI18next = true,
75
73
  } = options;
76
74
  const {
77
75
  localePathRedirect = false,
@@ -80,20 +78,31 @@ export const i18nPlugin = (options: I18nPluginOptions): RuntimePlugin => ({
80
78
  fallbackLanguage = 'en',
81
79
  detection,
82
80
  ignoreRedirectRoutes,
81
+ localisedUrls,
83
82
  } = localeDetection || {};
84
83
  const { enabled: backendEnabled = false } = backend || {};
85
84
  let latestI18nInstance: I18nInstance | undefined;
86
- let I18nextProvider: React.FunctionComponent<any> | null;
85
+ let I18nextProvider: React.ComponentType<any> | null;
86
+
87
+ const loadReactI18nextIntegration = async () => {
88
+ if (!reactI18next) {
89
+ return null;
90
+ }
91
+ const { getReactI18nextIntegration } = await import(
92
+ './i18n/react-i18next'
93
+ );
94
+ return getReactI18nextIntegration();
95
+ };
87
96
 
88
97
  api.onBeforeRender(async context => {
89
98
  let i18nInstance = await getI18nInstance(userI18nInstance);
90
99
  const { i18n: otherConfig } = api.getRuntimeConfig();
91
100
  const { initOptions: otherInitOptions } = otherConfig || {};
92
101
  const userInitOptions = merge(otherInitOptions || {}, initOptions || {});
93
- const initReactI18next = await getInitReactI18next();
94
- I18nextProvider = await getI18nextProvider();
95
- if (initReactI18next) {
96
- i18nInstance.use(initReactI18next);
102
+ const reactI18nextIntegration = await loadReactI18nextIntegration();
103
+ I18nextProvider = reactI18nextIntegration?.I18nextProvider ?? null;
104
+ if (reactI18nextIntegration?.initReactI18next) {
105
+ i18nInstance.use(reactI18nextIntegration.initReactI18next);
97
106
  }
98
107
 
99
108
  const pathname = getPathname(context);
@@ -227,6 +236,7 @@ export const i18nPlugin = (options: I18nPluginOptions): RuntimePlugin => ({
227
236
  languages,
228
237
  fallbackLanguage,
229
238
  ignoreRedirectRoutes,
239
+ localisedUrls,
230
240
  );
231
241
 
232
242
  const contextValue = useMemo(
@@ -238,6 +248,7 @@ export const i18nPlugin = (options: I18nPluginOptions): RuntimePlugin => ({
238
248
  languages,
239
249
  localePathRedirect,
240
250
  ignoreRedirectRoutes,
251
+ localisedUrls,
241
252
  setLang,
242
253
  ),
243
254
  [
@@ -247,15 +258,17 @@ export const i18nPlugin = (options: I18nPluginOptions): RuntimePlugin => ({
247
258
  languages,
248
259
  localePathRedirect,
249
260
  ignoreRedirectRoutes,
261
+ localisedUrls,
250
262
  forceUpdate,
251
263
  ],
252
264
  );
253
265
 
266
+ const children = (props as React.PropsWithChildren).children;
254
267
  const appContent = (
255
268
  <>
256
269
  {Boolean(htmlLangAttr) && <Helmet htmlAttributes={{ lang }} />}
257
270
  <ModernI18nProvider value={contextValue}>
258
- <App {...props} />
271
+ {App ? <App {...props}>{children}</App> : children}
259
272
  </ModernI18nProvider>
260
273
  </>
261
274
  );
@@ -280,6 +293,28 @@ export const i18nPlugin = (options: I18nPluginOptions): RuntimePlugin => ({
280
293
  },
281
294
  });
282
295
 
296
+ export type {
297
+ AllowedLinkTarget,
298
+ CanonicalRoutePath,
299
+ UltramodernCanonicalRoutes,
300
+ } from './canonicalRoutes';
283
301
  export { useModernI18n } from './context';
284
- export { I18nLink } from './I18nLink';
302
+ export { I18nLink, type I18nLinkProps } from './I18nLink';
303
+ export {
304
+ Link,
305
+ type LinkActiveOptions,
306
+ type LinkBaseProps,
307
+ type LinkParams,
308
+ type LinkProps,
309
+ } from './Link';
310
+ export {
311
+ canonicalPath,
312
+ type LocalizedPathsConfig,
313
+ localizePath,
314
+ type UseLocalizedLocationReturn,
315
+ type UseLocalizedPathsReturn,
316
+ useLocalizedLocation,
317
+ useLocalizedPaths,
318
+ } from './localizedPaths';
319
+ export { buildLocalizedUrl, splitUrlTarget } from './utils';
285
320
  export default i18nPlugin;
@@ -0,0 +1,118 @@
1
+ import { useMemo } from 'react';
2
+ import type { LocalisedUrlsOption } from '../shared/localisedUrls';
3
+ import {
4
+ resolveCanonicalLocalisedPath,
5
+ resolveLocalisedUrlsConfig,
6
+ } from '../shared/localisedUrls';
7
+ import { useModernI18n } from './context';
8
+ import { useI18nRouterAdapter } from './routerAdapter';
9
+ import { buildLocalizedUrl, splitUrlTarget } from './utils';
10
+
11
+ export interface LocalizedPathsConfig {
12
+ languages: string[];
13
+ localisedUrls?: LocalisedUrlsOption;
14
+ }
15
+
16
+ /**
17
+ * Localize a canonical, language-agnostic target for the given language:
18
+ * adds the language prefix and applies `localisedUrls` pattern mapping.
19
+ * `?search`/`#hash` suffixes are preserved verbatim.
20
+ */
21
+ export const localizePath = (
22
+ pathname: string,
23
+ language: string,
24
+ config: LocalizedPathsConfig,
25
+ ): string =>
26
+ buildLocalizedUrl(pathname, language, config.languages, config.localisedUrls);
27
+
28
+ /**
29
+ * Reverse of {@link localizePath}: strip the language prefix and map localized
30
+ * slugs back to the canonical pattern's path. `?search`/`#hash` suffixes are
31
+ * preserved verbatim.
32
+ */
33
+ export const canonicalPath = (
34
+ target: string,
35
+ config: LocalizedPathsConfig,
36
+ ): string => {
37
+ const { pathname, search, hash } = splitUrlTarget(target);
38
+ const segments = pathname.split('/').filter(Boolean);
39
+ const pathWithoutLanguage =
40
+ segments.length > 0 && config.languages.includes(segments[0])
41
+ ? `/${segments.slice(1).join('/')}`
42
+ : pathname || '/';
43
+ const localisedUrlsConfig = resolveLocalisedUrlsConfig(config.localisedUrls);
44
+ const resolvedPath = localisedUrlsConfig.enabled
45
+ ? resolveCanonicalLocalisedPath(
46
+ pathWithoutLanguage,
47
+ config.languages,
48
+ localisedUrlsConfig.map,
49
+ )
50
+ : pathWithoutLanguage;
51
+
52
+ return `${resolvedPath}${search}${hash}`;
53
+ };
54
+
55
+ export interface UseLocalizedPathsReturn {
56
+ localizePath: (pathname: string, language: string) => string;
57
+ canonicalPath: (pathname: string) => string;
58
+ }
59
+
60
+ /**
61
+ * Context-bound versions of {@link localizePath} and {@link canonicalPath} —
62
+ * the plugin configuration (languages, localisedUrls) is read from the i18n
63
+ * provider, so apps never copy pattern-matching helpers again.
64
+ */
65
+ export const useLocalizedPaths = (): UseLocalizedPathsReturn => {
66
+ const { supportedLanguages, localisedUrls } = useModernI18n();
67
+
68
+ return useMemo(() => {
69
+ const config: LocalizedPathsConfig = {
70
+ languages: supportedLanguages,
71
+ localisedUrls,
72
+ };
73
+
74
+ return {
75
+ localizePath: (pathname: string, language: string) =>
76
+ localizePath(pathname, language, config),
77
+ canonicalPath: (pathname: string) => canonicalPath(pathname, config),
78
+ };
79
+ }, [supportedLanguages, localisedUrls]);
80
+ };
81
+
82
+ export interface UseLocalizedLocationReturn {
83
+ language: string;
84
+ /** Canonical (language-agnostic) path of the current location. */
85
+ canonical: string;
86
+ /** Per-language hrefs for the current location, search+hash preserved. */
87
+ alternates: Record<string, string>;
88
+ }
89
+
90
+ /**
91
+ * Per-language hrefs for the current location — for hreflang `<link>` tags and
92
+ * language switchers. SSR-safe: the location comes from the router adapter.
93
+ */
94
+ export const useLocalizedLocation = (): UseLocalizedLocationReturn => {
95
+ const { language, supportedLanguages, localisedUrls } = useModernI18n();
96
+ const { location } = useI18nRouterAdapter();
97
+ const pathname = location?.pathname ?? '/';
98
+ const search = location?.search ?? '';
99
+ const hash = location?.hash ?? '';
100
+
101
+ return useMemo(() => {
102
+ const config: LocalizedPathsConfig = {
103
+ languages: supportedLanguages,
104
+ localisedUrls,
105
+ };
106
+ const alternates: Record<string, string> = {};
107
+ for (const supportedLanguage of supportedLanguages) {
108
+ alternates[supportedLanguage] =
109
+ `${localizePath(pathname, supportedLanguage, config)}${search}${hash}`;
110
+ }
111
+
112
+ return {
113
+ language,
114
+ canonical: canonicalPath(pathname, config),
115
+ alternates,
116
+ };
117
+ }, [language, supportedLanguages, localisedUrls, pathname, search, hash]);
118
+ };