@lovalingo/lovalingo 0.5.2 → 0.5.3
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +1 -1
- package/dist/components/AixsterProvider.js +34 -13
- package/dist/components/LangLink.js +7 -7
- package/dist/components/LangRouter.d.ts +4 -1
- package/dist/components/LangRouter.js +116 -31
- package/dist/context/LangRoutingContext.d.ts +6 -0
- package/dist/context/LangRoutingContext.js +5 -0
- package/dist/hooks/useLangNavigate.js +10 -12
- package/dist/utils/api.d.ts +5 -0
- package/dist/utils/nonLocalizedPaths.d.ts +9 -0
- package/dist/utils/nonLocalizedPaths.js +78 -0
- package/dist/version.d.ts +1 -1
- package/dist/version.js +1 -1
- package/package.json +1 -1
package/README.md
CHANGED
|
@@ -55,7 +55,7 @@ export function App() {
|
|
|
55
55
|
const navigateRef = useRef();
|
|
56
56
|
|
|
57
57
|
return (
|
|
58
|
-
<LangRouter defaultLang="en" langs={["en", "de", "fr"]} navigateRef={navigateRef}>
|
|
58
|
+
<LangRouter publicAnonKey="aix_your_public_anon_key" defaultLang="en" langs={["en", "de", "fr"]} navigateRef={navigateRef}>
|
|
59
59
|
<LovalingoProvider
|
|
60
60
|
publicAnonKey="aix_your_public_anon_key"
|
|
61
61
|
defaultLocale="en"
|
|
@@ -1,10 +1,12 @@
|
|
|
1
|
-
import React, { useMemo, useState, useEffect, useCallback, useRef } from 'react';
|
|
1
|
+
import React, { useMemo, useState, useEffect, useCallback, useRef, useContext } from 'react';
|
|
2
2
|
import { LovalingoContext } from '../context/LovalingoContext';
|
|
3
|
+
import { LangRoutingContext } from '../context/LangRoutingContext';
|
|
3
4
|
import { LovalingoAPI } from '../utils/api';
|
|
4
5
|
import { applyDomRules } from '../utils/domRules';
|
|
5
6
|
import { hashContent } from '../utils/hash';
|
|
6
7
|
import { applyActiveTranslations, getCriticalFingerprint, restoreDom, setActiveTranslations, setMarkerEngineExclusions, startMarkerEngine } from '../utils/markerEngine';
|
|
7
8
|
import { logDebug, warnDebug, errorDebug } from '../utils/logger';
|
|
9
|
+
import { isNonLocalizedPath, stripLocalePrefix } from '../utils/nonLocalizedPaths';
|
|
8
10
|
import { processPath } from '../utils/pathNormalizer';
|
|
9
11
|
import { LanguageSwitcher } from './LanguageSwitcher';
|
|
10
12
|
const LOCALE_STORAGE_KEY = 'Lovalingo_locale';
|
|
@@ -75,6 +77,7 @@ navigateRef, // For path mode routing
|
|
|
75
77
|
useEffect(() => {
|
|
76
78
|
apiRef.current = new LovalingoAPI(resolvedApiKey, apiBase, enhancedPathConfig);
|
|
77
79
|
}, [apiBase, enhancedPathConfig, resolvedApiKey]);
|
|
80
|
+
const routingConfig = useContext(LangRoutingContext);
|
|
78
81
|
const [entitlements, setEntitlements] = useState(() => apiRef.current.getEntitlements());
|
|
79
82
|
const lastPageviewRef = useRef("");
|
|
80
83
|
const lastPageviewFingerprintRef = useRef("");
|
|
@@ -554,6 +557,17 @@ navigateRef, // For path mode routing
|
|
|
554
557
|
isNavigatingRef.current = false;
|
|
555
558
|
return;
|
|
556
559
|
}
|
|
560
|
+
if (routing === "path") {
|
|
561
|
+
const stripped = stripLocalePrefix(window.location.pathname, allLocales);
|
|
562
|
+
if (isNonLocalizedPath(stripped, routingConfig.nonLocalizedPaths)) {
|
|
563
|
+
// Why: auth/admin (non-localized) routes must never be blocked or mutated by the translation runtime.
|
|
564
|
+
disablePrehide();
|
|
565
|
+
setActiveTranslations(null);
|
|
566
|
+
restoreDom(document.body);
|
|
567
|
+
isNavigatingRef.current = false;
|
|
568
|
+
return;
|
|
569
|
+
}
|
|
570
|
+
}
|
|
557
571
|
const currentPath = window.location.pathname + window.location.search;
|
|
558
572
|
const normalizedPath = processPath(window.location.pathname, enhancedPathConfig);
|
|
559
573
|
const cacheKey = `${targetLocale}:${normalizedPath}`;
|
|
@@ -837,6 +851,13 @@ navigateRef, // For path mode routing
|
|
|
837
851
|
isNavigatingRef.current = true;
|
|
838
852
|
// Update URL based on routing strategy
|
|
839
853
|
if (routing === 'path') {
|
|
854
|
+
const stripped = stripLocalePrefix(window.location.pathname, allLocales);
|
|
855
|
+
if (isNonLocalizedPath(stripped, routingConfig.nonLocalizedPaths)) {
|
|
856
|
+
// Why: switching languages must not rewrite non-localized routes like "/auth" to "/de/auth".
|
|
857
|
+
setLocaleState(newLocale);
|
|
858
|
+
isNavigatingRef.current = false;
|
|
859
|
+
return;
|
|
860
|
+
}
|
|
840
861
|
const pathParts = window.location.pathname.split('/').filter(Boolean);
|
|
841
862
|
// Strip existing locale
|
|
842
863
|
if (allLocales.includes(pathParts[0])) {
|
|
@@ -869,7 +890,7 @@ navigateRef, // For path mode routing
|
|
|
869
890
|
})().finally(() => {
|
|
870
891
|
isInternalNavigationRef.current = false;
|
|
871
892
|
});
|
|
872
|
-
}, [allLocales, locale, routing, loadData, navigateRef]);
|
|
893
|
+
}, [allLocales, locale, routing, loadData, navigateRef, routingConfig.nonLocalizedPaths]);
|
|
873
894
|
// No string-level "misses" from the client: the pipeline (Browser Rendering) is source-of-truth for string discovery.
|
|
874
895
|
// Initialize
|
|
875
896
|
useEffect(() => {
|
|
@@ -929,13 +950,6 @@ navigateRef, // For path mode routing
|
|
|
929
950
|
if (!autoPrefixLinks)
|
|
930
951
|
return;
|
|
931
952
|
const supportedLocales = allLocales;
|
|
932
|
-
const isAssetPath = (pathname) => {
|
|
933
|
-
if (pathname === '/robots.txt' || pathname === '/sitemap.xml')
|
|
934
|
-
return true;
|
|
935
|
-
if (pathname.startsWith('/.well-known/'))
|
|
936
|
-
return true;
|
|
937
|
-
return /\.(?:png|jpg|jpeg|gif|svg|webp|avif|ico|css|js|map|json|xml|txt|pdf|zip|gz|br|woff2?|ttf|eot)$/i.test(pathname);
|
|
938
|
-
};
|
|
939
953
|
const shouldProcessCurrentPath = () => {
|
|
940
954
|
const parts = window.location.pathname.split('/').filter(Boolean);
|
|
941
955
|
return parts.length > 0 && supportedLocales.includes(parts[0]);
|
|
@@ -963,7 +977,7 @@ navigateRef, // For path mode routing
|
|
|
963
977
|
}
|
|
964
978
|
if (url.origin !== window.location.origin)
|
|
965
979
|
return null;
|
|
966
|
-
if (
|
|
980
|
+
if (isNonLocalizedPath(url.pathname, routingConfig.nonLocalizedPaths))
|
|
967
981
|
return null;
|
|
968
982
|
const parts = url.pathname.split('/').filter(Boolean);
|
|
969
983
|
// Root ("/") should be locale-prefixed too (e.g. clicking a logo linking to "https://example.com")
|
|
@@ -1070,7 +1084,7 @@ navigateRef, // For path mode routing
|
|
|
1070
1084
|
mo.disconnect();
|
|
1071
1085
|
document.removeEventListener('click', onClickCapture, true);
|
|
1072
1086
|
};
|
|
1073
|
-
}, [routing, autoPrefixLinks, allLocales, locale, navigateRef]);
|
|
1087
|
+
}, [routing, autoPrefixLinks, allLocales, locale, navigateRef, routingConfig.nonLocalizedPaths]);
|
|
1074
1088
|
// Navigation prefetch: warm the HTTP cache for bootstrap + page bundle before the user clicks (reduces EN→FR flash on SPA route changes).
|
|
1075
1089
|
useEffect(() => {
|
|
1076
1090
|
if (!resolvedApiKey)
|
|
@@ -1182,9 +1196,16 @@ navigateRef, // For path mode routing
|
|
|
1182
1196
|
};
|
|
1183
1197
|
return (React.createElement(LovalingoContext.Provider, { value: contextValue },
|
|
1184
1198
|
children,
|
|
1185
|
-
|
|
1199
|
+
(() => {
|
|
1200
|
+
if (routing !== "path")
|
|
1201
|
+
return true;
|
|
1202
|
+
if (typeof window === "undefined")
|
|
1203
|
+
return true;
|
|
1204
|
+
const stripped = stripLocalePrefix(window.location.pathname, allLocales);
|
|
1205
|
+
return !isNonLocalizedPath(stripped, routingConfig.nonLocalizedPaths);
|
|
1206
|
+
})() && (React.createElement(LanguageSwitcher, { locales: allLocales, currentLocale: locale, onLocaleChange: setLocale, position: switcherPosition, offsetY: switcherOffsetY, theme: switcherTheme, branding: {
|
|
1186
1207
|
required: Boolean(entitlements?.brandingRequired),
|
|
1187
1208
|
enabled: brandingEnabled,
|
|
1188
1209
|
href: "https://lovalingo.com",
|
|
1189
|
-
} })));
|
|
1210
|
+
} }))));
|
|
1190
1211
|
};
|
|
@@ -1,10 +1,9 @@
|
|
|
1
1
|
import React from 'react';
|
|
2
2
|
import { Link } from 'react-router-dom';
|
|
3
3
|
import { useLang } from '../hooks/useLang';
|
|
4
|
-
|
|
5
|
-
|
|
6
|
-
|
|
7
|
-
]);
|
|
4
|
+
import { useContext } from 'react';
|
|
5
|
+
import { LangRoutingContext } from '../context/LangRoutingContext';
|
|
6
|
+
import { isNonLocalizedPath } from '../utils/nonLocalizedPaths';
|
|
8
7
|
/**
|
|
9
8
|
* LangLink - Language-aware Link component
|
|
10
9
|
*
|
|
@@ -24,12 +23,13 @@ const NON_LOCALIZED_APP_PATHS = new Set([
|
|
|
24
23
|
*/
|
|
25
24
|
export function LangLink({ to, ...props }) {
|
|
26
25
|
const lang = useLang();
|
|
26
|
+
const routing = useContext(LangRoutingContext);
|
|
27
27
|
// If 'to' is a string, prepend language
|
|
28
28
|
const langTo = typeof to === 'string'
|
|
29
29
|
? (() => {
|
|
30
|
-
const trimmed = to
|
|
31
|
-
const
|
|
32
|
-
return
|
|
30
|
+
const trimmed = (to || '').toString().trim();
|
|
31
|
+
const normalized = trimmed.startsWith('/') ? trimmed : `/${trimmed}`;
|
|
32
|
+
return isNonLocalizedPath(normalized, routing.nonLocalizedPaths) ? normalized : `/${lang}${normalized}`;
|
|
33
33
|
})()
|
|
34
34
|
: to;
|
|
35
35
|
return React.createElement(Link, { ...props, to: langTo });
|
|
@@ -4,6 +4,9 @@ interface LangRouterProps {
|
|
|
4
4
|
defaultLang: string;
|
|
5
5
|
langs: string[];
|
|
6
6
|
navigateRef?: React.MutableRefObject<((path: string) => void) | undefined>;
|
|
7
|
+
apiBase?: string;
|
|
8
|
+
apiKey?: string;
|
|
9
|
+
publicAnonKey?: string;
|
|
7
10
|
}
|
|
8
11
|
/**
|
|
9
12
|
* LangRouter - Drop-in replacement for BrowserRouter that automatically handles language routing
|
|
@@ -30,5 +33,5 @@ interface LangRouterProps {
|
|
|
30
33
|
* - /fr/pricing
|
|
31
34
|
* - etc.
|
|
32
35
|
*/
|
|
33
|
-
export declare function LangRouter({ children, defaultLang, langs, navigateRef }: LangRouterProps): React.JSX.Element;
|
|
36
|
+
export declare function LangRouter({ children, defaultLang, langs, navigateRef, apiBase, apiKey, publicAnonKey }: LangRouterProps): React.JSX.Element;
|
|
34
37
|
export {};
|
|
@@ -1,17 +1,9 @@
|
|
|
1
|
-
import React, { useEffect } from 'react';
|
|
2
|
-
import { BrowserRouter, Routes, Route,
|
|
1
|
+
import React, { useCallback, useEffect, useMemo, useState } from 'react';
|
|
2
|
+
import { BrowserRouter, Routes, Route, Outlet, useLocation, useNavigate } from 'react-router-dom';
|
|
3
3
|
import { LangContext } from '../context/LangContext';
|
|
4
|
-
|
|
5
|
-
|
|
6
|
-
|
|
7
|
-
]);
|
|
8
|
-
function isNonLocalizedPath(pathname) {
|
|
9
|
-
if (NON_LOCALIZED_APP_PATHS.has(pathname))
|
|
10
|
-
return true;
|
|
11
|
-
if (pathname.startsWith('/.well-known/'))
|
|
12
|
-
return true;
|
|
13
|
-
return /\.(?:png|jpg|jpeg|gif|svg|webp|avif|ico|css|js|map|json|xml|txt|pdf|zip|gz|br|woff2?|ttf|eot)$/i.test(pathname);
|
|
14
|
-
}
|
|
4
|
+
import { LangRoutingContext } from '../context/LangRoutingContext';
|
|
5
|
+
import { isNonLocalizedPath, parseBootstrapNonLocalizedPaths } from '../utils/nonLocalizedPaths';
|
|
6
|
+
import { logDebug } from '../utils/logger';
|
|
15
7
|
/**
|
|
16
8
|
* NavigateExporter - Internal component that exports navigate function via ref
|
|
17
9
|
*/
|
|
@@ -27,29 +19,42 @@ function NavigateExporter({ navigateRef }) {
|
|
|
27
19
|
/**
|
|
28
20
|
* LangGuard - Internal component that validates language and provides it to children
|
|
29
21
|
*/
|
|
30
|
-
function LangGuard({
|
|
22
|
+
function LangGuard({ lang, nonLocalizedPaths, }) {
|
|
31
23
|
const location = useLocation();
|
|
24
|
+
const navigate = useNavigate();
|
|
32
25
|
// If the URL is language-prefixed but the underlying route is non-localized (e.g. robots/sitemap),
|
|
33
26
|
// redirect to the canonical non-localized path.
|
|
34
27
|
const prefix = `/${lang}`;
|
|
35
28
|
const restPath = location.pathname.startsWith(prefix) ? location.pathname.slice(prefix.length) || '/' : location.pathname;
|
|
36
|
-
|
|
29
|
+
const shouldDePrefix = isNonLocalizedPath(restPath, nonLocalizedPaths);
|
|
30
|
+
useEffect(() => {
|
|
31
|
+
if (!shouldDePrefix)
|
|
32
|
+
return;
|
|
37
33
|
const nextPath = `${restPath}${location.search}${location.hash}`;
|
|
38
|
-
|
|
39
|
-
}
|
|
34
|
+
navigate(nextPath, { replace: true });
|
|
35
|
+
}, [location.hash, location.search, navigate, restPath, shouldDePrefix]);
|
|
40
36
|
// Valid language - render children (user's routes)
|
|
41
37
|
return (React.createElement(LangContext.Provider, { value: lang },
|
|
42
38
|
React.createElement(Outlet, { context: { lang } })));
|
|
43
39
|
}
|
|
44
|
-
function RedirectToDefaultLang({ defaultLang, children }) {
|
|
40
|
+
function RedirectToDefaultLang({ defaultLang, children, nonLocalizedPaths, routingStatus, }) {
|
|
45
41
|
const location = useLocation();
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
42
|
+
const navigate = useNavigate();
|
|
43
|
+
const shouldSkip = isNonLocalizedPath(location.pathname, nonLocalizedPaths);
|
|
44
|
+
useEffect(() => {
|
|
45
|
+
if (shouldSkip)
|
|
46
|
+
return;
|
|
47
|
+
if (routingStatus === "loading")
|
|
48
|
+
return;
|
|
49
|
+
const nextPath = location.pathname === "/" || location.pathname === ""
|
|
50
|
+
? `/${defaultLang}${location.search}${location.hash}`
|
|
51
|
+
: `/${defaultLang}${location.pathname}${location.search}${location.hash}`;
|
|
52
|
+
const current = `${location.pathname}${location.search}${location.hash}`;
|
|
53
|
+
if (nextPath === current)
|
|
54
|
+
return;
|
|
55
|
+
navigate(nextPath, { replace: true });
|
|
56
|
+
}, [defaultLang, location.hash, location.pathname, location.search, navigate, routingStatus, shouldSkip]);
|
|
57
|
+
return React.createElement(React.Fragment, null, children);
|
|
53
58
|
}
|
|
54
59
|
/**
|
|
55
60
|
* LangRouter - Drop-in replacement for BrowserRouter that automatically handles language routing
|
|
@@ -76,12 +81,92 @@ function RedirectToDefaultLang({ defaultLang, children }) {
|
|
|
76
81
|
* - /fr/pricing
|
|
77
82
|
* - etc.
|
|
78
83
|
*/
|
|
79
|
-
export function LangRouter({ children, defaultLang, langs, navigateRef }) {
|
|
84
|
+
export function LangRouter({ children, defaultLang, langs, navigateRef, apiBase, apiKey, publicAnonKey }) {
|
|
85
|
+
const metaKey = typeof document !== "undefined"
|
|
86
|
+
? document.querySelector('meta[name="lovalingo-public-anon-key"]')?.content?.trim() || ""
|
|
87
|
+
: "";
|
|
88
|
+
const globals = globalThis;
|
|
89
|
+
const resolvedApiKey = (typeof apiKey === "string" && apiKey.trim().length > 0
|
|
90
|
+
? apiKey
|
|
91
|
+
: typeof publicAnonKey === "string" && publicAnonKey.trim().length > 0
|
|
92
|
+
? publicAnonKey
|
|
93
|
+
: globals.__LOVALINGO_PUBLIC_ANON_KEY__ || globals.__LOVALINGO_API_KEY__ || metaKey || "").trim();
|
|
94
|
+
const resolvedApiBase = (typeof apiBase === "string" && apiBase.trim().length > 0
|
|
95
|
+
? apiBase.trim()
|
|
96
|
+
: "https://cdn.lovalingo.com");
|
|
97
|
+
const nonLocalizedStorageKey = useMemo(() => `Lovalingo_non_localized_paths:${resolvedApiKey || "anonymous"}`, [resolvedApiKey]);
|
|
98
|
+
const [nonLocalizedPaths, setNonLocalizedPaths] = useState(() => {
|
|
99
|
+
if (typeof window === "undefined")
|
|
100
|
+
return [];
|
|
101
|
+
if (!resolvedApiKey)
|
|
102
|
+
return [];
|
|
103
|
+
try {
|
|
104
|
+
const raw = localStorage.getItem(nonLocalizedStorageKey);
|
|
105
|
+
if (!raw)
|
|
106
|
+
return [];
|
|
107
|
+
const parsed = JSON.parse(raw);
|
|
108
|
+
return parseBootstrapNonLocalizedPaths(parsed);
|
|
109
|
+
}
|
|
110
|
+
catch {
|
|
111
|
+
return [];
|
|
112
|
+
}
|
|
113
|
+
});
|
|
114
|
+
const [routingStatus, setRoutingStatus] = useState(() => {
|
|
115
|
+
if (!resolvedApiKey)
|
|
116
|
+
return "unknown";
|
|
117
|
+
return nonLocalizedPaths.length > 0 ? "ready" : "loading";
|
|
118
|
+
});
|
|
119
|
+
const fetchNonLocalizedPaths = useCallback(async () => {
|
|
120
|
+
if (typeof window === "undefined")
|
|
121
|
+
return;
|
|
122
|
+
if (!resolvedApiKey)
|
|
123
|
+
return;
|
|
124
|
+
const pathParam = window.location.pathname + window.location.search;
|
|
125
|
+
const requestUrl = `${resolvedApiBase}/functions/v1/bootstrap?key=${encodeURIComponent(resolvedApiKey)}&locale=${encodeURIComponent(defaultLang)}&path=${encodeURIComponent(pathParam)}`;
|
|
126
|
+
const response = await fetch(requestUrl);
|
|
127
|
+
const resolvedResponse = response.status === 304 ? await fetch(requestUrl, { cache: "force-cache" }) : response;
|
|
128
|
+
if (!resolvedResponse.ok)
|
|
129
|
+
throw new Error(`bootstrap HTTP ${resolvedResponse.status}`);
|
|
130
|
+
const data = (await resolvedResponse.json());
|
|
131
|
+
const record = (data || {});
|
|
132
|
+
return parseBootstrapNonLocalizedPaths(record["non_localized_paths"]);
|
|
133
|
+
}, [defaultLang, resolvedApiBase, resolvedApiKey]);
|
|
134
|
+
useEffect(() => {
|
|
135
|
+
let cancelled = false;
|
|
136
|
+
void (async () => {
|
|
137
|
+
if (!resolvedApiKey)
|
|
138
|
+
return;
|
|
139
|
+
setRoutingStatus((prev) => (prev === "ready" ? prev : "loading"));
|
|
140
|
+
try {
|
|
141
|
+
const next = await fetchNonLocalizedPaths();
|
|
142
|
+
if (cancelled || !next)
|
|
143
|
+
return;
|
|
144
|
+
setNonLocalizedPaths(next);
|
|
145
|
+
setRoutingStatus("ready");
|
|
146
|
+
try {
|
|
147
|
+
localStorage.setItem(nonLocalizedStorageKey, JSON.stringify(next));
|
|
148
|
+
}
|
|
149
|
+
catch {
|
|
150
|
+
// ignore
|
|
151
|
+
}
|
|
152
|
+
}
|
|
153
|
+
catch (err) {
|
|
154
|
+
if (cancelled)
|
|
155
|
+
return;
|
|
156
|
+
setRoutingStatus("error");
|
|
157
|
+
logDebug("[Lovalingo] Failed to fetch non-localized paths:", err);
|
|
158
|
+
}
|
|
159
|
+
})();
|
|
160
|
+
return () => {
|
|
161
|
+
cancelled = true;
|
|
162
|
+
};
|
|
163
|
+
}, [fetchNonLocalizedPaths, nonLocalizedStorageKey, resolvedApiKey]);
|
|
80
164
|
return (React.createElement(BrowserRouter, null,
|
|
81
165
|
React.createElement(NavigateExporter, { navigateRef: navigateRef }),
|
|
82
|
-
React.createElement(
|
|
83
|
-
|
|
84
|
-
React.createElement(Route, {
|
|
85
|
-
|
|
86
|
-
|
|
166
|
+
React.createElement(LangRoutingContext.Provider, { value: { nonLocalizedPaths, status: routingStatus } },
|
|
167
|
+
React.createElement(Routes, null,
|
|
168
|
+
langs.map((lang) => (React.createElement(Route, { key: lang, path: `${lang}/*`, element: React.createElement(LangGuard, { lang: lang, nonLocalizedPaths: nonLocalizedPaths }) },
|
|
169
|
+
React.createElement(Route, { index: true, element: React.createElement(React.Fragment, null, children) }),
|
|
170
|
+
React.createElement(Route, { path: "*", element: React.createElement(React.Fragment, null, children) })))),
|
|
171
|
+
React.createElement(Route, { path: "*", element: React.createElement(RedirectToDefaultLang, { defaultLang: defaultLang, nonLocalizedPaths: nonLocalizedPaths, routingStatus: routingStatus }, children) })))));
|
|
87
172
|
}
|
|
@@ -0,0 +1,6 @@
|
|
|
1
|
+
import type { NonLocalizedPathRule } from "../utils/nonLocalizedPaths";
|
|
2
|
+
export type LangRoutingContextValue = {
|
|
3
|
+
nonLocalizedPaths: NonLocalizedPathRule[];
|
|
4
|
+
status: "unknown" | "loading" | "ready" | "error";
|
|
5
|
+
};
|
|
6
|
+
export declare const LangRoutingContext: import("react").Context<LangRoutingContextValue>;
|
|
@@ -1,11 +1,8 @@
|
|
|
1
1
|
import { useNavigate } from 'react-router-dom';
|
|
2
|
-
import { useCallback } from 'react';
|
|
2
|
+
import { useCallback, useContext } from 'react';
|
|
3
3
|
import { useLang } from './useLang';
|
|
4
|
-
|
|
5
|
-
|
|
6
|
-
'robots.txt',
|
|
7
|
-
'sitemap.xml',
|
|
8
|
-
]);
|
|
4
|
+
import { LangRoutingContext } from '../context/LangRoutingContext';
|
|
5
|
+
import { isNonLocalizedPath } from '../utils/nonLocalizedPaths';
|
|
9
6
|
/**
|
|
10
7
|
* useLangNavigate - Get a language-aware navigate function
|
|
11
8
|
*
|
|
@@ -29,12 +26,13 @@ const NON_LOCALIZED_APP_PATHS = new Set([
|
|
|
29
26
|
export function useLangNavigate() {
|
|
30
27
|
const navigate = useNavigate();
|
|
31
28
|
const lang = useLang();
|
|
29
|
+
const routing = useContext(LangRoutingContext);
|
|
32
30
|
return useCallback((path, options) => {
|
|
33
|
-
const trimmed = path.
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
31
|
+
const trimmed = (path || "").toString().trim();
|
|
32
|
+
if (!trimmed)
|
|
33
|
+
return;
|
|
34
|
+
const normalized = trimmed.startsWith("/") ? trimmed : `/${trimmed}`;
|
|
35
|
+
const fullPath = isNonLocalizedPath(normalized, routing.nonLocalizedPaths) ? normalized : `/${lang}${normalized}`;
|
|
38
36
|
navigate(fullPath, options);
|
|
39
|
-
}, [lang, navigate]);
|
|
37
|
+
}, [lang, navigate, routing.nonLocalizedPaths]);
|
|
40
38
|
}
|
package/dist/utils/api.d.ts
CHANGED
|
@@ -25,6 +25,11 @@ export type BootstrapResponse = {
|
|
|
25
25
|
locale?: string;
|
|
26
26
|
normalized_path?: string;
|
|
27
27
|
routing_strategy?: string;
|
|
28
|
+
non_localized_paths?: Array<{
|
|
29
|
+
pattern?: string;
|
|
30
|
+
match_type?: string;
|
|
31
|
+
updated_at?: string | null;
|
|
32
|
+
}>;
|
|
28
33
|
loading_bg_color?: string | null;
|
|
29
34
|
branding_enabled?: boolean;
|
|
30
35
|
seoEnabled?: boolean;
|
|
@@ -0,0 +1,9 @@
|
|
|
1
|
+
export type NonLocalizedPathRule = {
|
|
2
|
+
pattern: string;
|
|
3
|
+
match_type: "exact" | "prefix" | "regex";
|
|
4
|
+
};
|
|
5
|
+
export declare function isGlobalNonLocalizedPath(pathname: string): boolean;
|
|
6
|
+
export declare function matchesNonLocalizedRules(pathname: string, rules: NonLocalizedPathRule[]): boolean;
|
|
7
|
+
export declare function isNonLocalizedPath(pathname: string, rules: NonLocalizedPathRule[]): boolean;
|
|
8
|
+
export declare function stripLocalePrefix(pathname: string, locales: string[]): string;
|
|
9
|
+
export declare function parseBootstrapNonLocalizedPaths(value: unknown): NonLocalizedPathRule[];
|
|
@@ -0,0 +1,78 @@
|
|
|
1
|
+
const GLOBAL_NON_LOCALIZED_APP_PATHS = new Set(["/robots.txt", "/sitemap.xml"]);
|
|
2
|
+
export function isGlobalNonLocalizedPath(pathname) {
|
|
3
|
+
const input = (pathname || "").toString();
|
|
4
|
+
if (!input.startsWith("/"))
|
|
5
|
+
return false;
|
|
6
|
+
if (GLOBAL_NON_LOCALIZED_APP_PATHS.has(input))
|
|
7
|
+
return true;
|
|
8
|
+
if (input.startsWith("/.well-known/"))
|
|
9
|
+
return true;
|
|
10
|
+
return /\.(?:png|jpg|jpeg|gif|svg|webp|avif|ico|css|js|map|json|xml|txt|pdf|zip|gz|br|woff2?|ttf|eot)$/i.test(input);
|
|
11
|
+
}
|
|
12
|
+
export function matchesNonLocalizedRules(pathname, rules) {
|
|
13
|
+
const input = (pathname || "").toString();
|
|
14
|
+
if (!input.startsWith("/"))
|
|
15
|
+
return false;
|
|
16
|
+
if (!Array.isArray(rules) || rules.length === 0)
|
|
17
|
+
return false;
|
|
18
|
+
for (const rule of rules) {
|
|
19
|
+
const pattern = typeof rule?.pattern === "string" ? rule.pattern : "";
|
|
20
|
+
const matchType = rule?.match_type;
|
|
21
|
+
if (!pattern)
|
|
22
|
+
continue;
|
|
23
|
+
if (matchType === "exact") {
|
|
24
|
+
if (input === pattern)
|
|
25
|
+
return true;
|
|
26
|
+
continue;
|
|
27
|
+
}
|
|
28
|
+
if (matchType === "prefix") {
|
|
29
|
+
if (input.startsWith(pattern))
|
|
30
|
+
return true;
|
|
31
|
+
continue;
|
|
32
|
+
}
|
|
33
|
+
if (matchType === "regex") {
|
|
34
|
+
try {
|
|
35
|
+
if (new RegExp(pattern).test(input))
|
|
36
|
+
return true;
|
|
37
|
+
}
|
|
38
|
+
catch {
|
|
39
|
+
// ignore invalid regex rules
|
|
40
|
+
}
|
|
41
|
+
}
|
|
42
|
+
}
|
|
43
|
+
return false;
|
|
44
|
+
}
|
|
45
|
+
export function isNonLocalizedPath(pathname, rules) {
|
|
46
|
+
return isGlobalNonLocalizedPath(pathname) || matchesNonLocalizedRules(pathname, rules);
|
|
47
|
+
}
|
|
48
|
+
export function stripLocalePrefix(pathname, locales) {
|
|
49
|
+
const input = (pathname || "").toString();
|
|
50
|
+
if (!input.startsWith("/"))
|
|
51
|
+
return input;
|
|
52
|
+
const parts = input.split("/").filter(Boolean);
|
|
53
|
+
if (parts.length === 0)
|
|
54
|
+
return "/";
|
|
55
|
+
const first = parts[0] || "";
|
|
56
|
+
if (!first || !Array.isArray(locales) || !locales.includes(first))
|
|
57
|
+
return input;
|
|
58
|
+
const rest = `/${parts.slice(1).join("/")}`;
|
|
59
|
+
return rest === "" ? "/" : rest;
|
|
60
|
+
}
|
|
61
|
+
export function parseBootstrapNonLocalizedPaths(value) {
|
|
62
|
+
if (!Array.isArray(value))
|
|
63
|
+
return [];
|
|
64
|
+
const out = [];
|
|
65
|
+
for (const row of value) {
|
|
66
|
+
if (!row || typeof row !== "object")
|
|
67
|
+
continue;
|
|
68
|
+
const record = row;
|
|
69
|
+
const pattern = typeof record.pattern === "string" ? record.pattern.trim() : "";
|
|
70
|
+
const match_type = typeof record.match_type === "string" ? record.match_type.trim() : "";
|
|
71
|
+
if (!pattern)
|
|
72
|
+
continue;
|
|
73
|
+
if (match_type !== "exact" && match_type !== "prefix" && match_type !== "regex")
|
|
74
|
+
continue;
|
|
75
|
+
out.push({ pattern, match_type: match_type });
|
|
76
|
+
}
|
|
77
|
+
return out;
|
|
78
|
+
}
|
package/dist/version.d.ts
CHANGED
|
@@ -1 +1 @@
|
|
|
1
|
-
export declare const VERSION = "0.3
|
|
1
|
+
export declare const VERSION = "0.5.3";
|
package/dist/version.js
CHANGED
|
@@ -1 +1 @@
|
|
|
1
|
-
export const VERSION = "0.3
|
|
1
|
+
export const VERSION = "0.5.3";
|
package/package.json
CHANGED