@lovalingo/lovalingo 0.5.1 → 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 +61 -14
- 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 +9 -1
- package/dist/utils/api.js +14 -2
- package/dist/utils/markerEngine.d.ts +9 -0
- package/dist/utils/markerEngine.js +93 -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
|
-
import { applyActiveTranslations, restoreDom, setActiveTranslations, setMarkerEngineExclusions, startMarkerEngine } from '../utils/markerEngine';
|
|
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,8 +77,12 @@ 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("");
|
|
83
|
+
const lastPageviewFingerprintRef = useRef("");
|
|
84
|
+
const pageviewFingerprintTimeoutRef = useRef(null);
|
|
85
|
+
const pageviewFingerprintRetryTimeoutRef = useRef(null);
|
|
80
86
|
const lastNormalizedPathRef = useRef("");
|
|
81
87
|
const historyPatchedRef = useRef(false);
|
|
82
88
|
const originalHistoryRef = useRef(null);
|
|
@@ -159,6 +165,7 @@ navigateRef, // For path mode routing
|
|
|
159
165
|
}, [brandingStorageKey]);
|
|
160
166
|
useEffect(() => {
|
|
161
167
|
lastPageviewRef.current = "";
|
|
168
|
+
lastPageviewFingerprintRef.current = "";
|
|
162
169
|
}, [resolvedApiKey]);
|
|
163
170
|
const trackPageviewOnce = useCallback((path) => {
|
|
164
171
|
const next = (path || "").toString();
|
|
@@ -168,6 +175,28 @@ navigateRef, // For path mode routing
|
|
|
168
175
|
return;
|
|
169
176
|
lastPageviewRef.current = next;
|
|
170
177
|
apiRef.current.trackPageview(next);
|
|
178
|
+
const trySendFingerprint = () => {
|
|
179
|
+
if (typeof window === "undefined")
|
|
180
|
+
return;
|
|
181
|
+
const markersReady = window.__lovalingoMarkersReady === true;
|
|
182
|
+
if (!markersReady)
|
|
183
|
+
return;
|
|
184
|
+
const fp = getCriticalFingerprint();
|
|
185
|
+
if (!fp || fp.critical_count <= 0)
|
|
186
|
+
return;
|
|
187
|
+
const signature = `${next}|${fp.critical_hash}|${fp.critical_count}`;
|
|
188
|
+
if (lastPageviewFingerprintRef.current === signature)
|
|
189
|
+
return;
|
|
190
|
+
lastPageviewFingerprintRef.current = signature;
|
|
191
|
+
apiRef.current.trackPageview(next, fp);
|
|
192
|
+
};
|
|
193
|
+
if (pageviewFingerprintTimeoutRef.current != null)
|
|
194
|
+
window.clearTimeout(pageviewFingerprintTimeoutRef.current);
|
|
195
|
+
if (pageviewFingerprintRetryTimeoutRef.current != null)
|
|
196
|
+
window.clearTimeout(pageviewFingerprintRetryTimeoutRef.current);
|
|
197
|
+
// Why: wait briefly for markers/content to settle before computing a critical fingerprint for change detection.
|
|
198
|
+
pageviewFingerprintTimeoutRef.current = window.setTimeout(trySendFingerprint, 800);
|
|
199
|
+
pageviewFingerprintRetryTimeoutRef.current = window.setTimeout(trySendFingerprint, 2000);
|
|
171
200
|
}, []);
|
|
172
201
|
const enablePrehide = useCallback((bgColor) => {
|
|
173
202
|
if (typeof document === "undefined")
|
|
@@ -528,6 +557,17 @@ navigateRef, // For path mode routing
|
|
|
528
557
|
isNavigatingRef.current = false;
|
|
529
558
|
return;
|
|
530
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
|
+
}
|
|
531
571
|
const currentPath = window.location.pathname + window.location.search;
|
|
532
572
|
const normalizedPath = processPath(window.location.pathname, enhancedPathConfig);
|
|
533
573
|
const cacheKey = `${targetLocale}:${normalizedPath}`;
|
|
@@ -811,6 +851,13 @@ navigateRef, // For path mode routing
|
|
|
811
851
|
isNavigatingRef.current = true;
|
|
812
852
|
// Update URL based on routing strategy
|
|
813
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
|
+
}
|
|
814
861
|
const pathParts = window.location.pathname.split('/').filter(Boolean);
|
|
815
862
|
// Strip existing locale
|
|
816
863
|
if (allLocales.includes(pathParts[0])) {
|
|
@@ -843,7 +890,7 @@ navigateRef, // For path mode routing
|
|
|
843
890
|
})().finally(() => {
|
|
844
891
|
isInternalNavigationRef.current = false;
|
|
845
892
|
});
|
|
846
|
-
}, [allLocales, locale, routing, loadData, navigateRef]);
|
|
893
|
+
}, [allLocales, locale, routing, loadData, navigateRef, routingConfig.nonLocalizedPaths]);
|
|
847
894
|
// No string-level "misses" from the client: the pipeline (Browser Rendering) is source-of-truth for string discovery.
|
|
848
895
|
// Initialize
|
|
849
896
|
useEffect(() => {
|
|
@@ -903,13 +950,6 @@ navigateRef, // For path mode routing
|
|
|
903
950
|
if (!autoPrefixLinks)
|
|
904
951
|
return;
|
|
905
952
|
const supportedLocales = allLocales;
|
|
906
|
-
const isAssetPath = (pathname) => {
|
|
907
|
-
if (pathname === '/robots.txt' || pathname === '/sitemap.xml')
|
|
908
|
-
return true;
|
|
909
|
-
if (pathname.startsWith('/.well-known/'))
|
|
910
|
-
return true;
|
|
911
|
-
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);
|
|
912
|
-
};
|
|
913
953
|
const shouldProcessCurrentPath = () => {
|
|
914
954
|
const parts = window.location.pathname.split('/').filter(Boolean);
|
|
915
955
|
return parts.length > 0 && supportedLocales.includes(parts[0]);
|
|
@@ -937,7 +977,7 @@ navigateRef, // For path mode routing
|
|
|
937
977
|
}
|
|
938
978
|
if (url.origin !== window.location.origin)
|
|
939
979
|
return null;
|
|
940
|
-
if (
|
|
980
|
+
if (isNonLocalizedPath(url.pathname, routingConfig.nonLocalizedPaths))
|
|
941
981
|
return null;
|
|
942
982
|
const parts = url.pathname.split('/').filter(Boolean);
|
|
943
983
|
// Root ("/") should be locale-prefixed too (e.g. clicking a logo linking to "https://example.com")
|
|
@@ -1044,7 +1084,7 @@ navigateRef, // For path mode routing
|
|
|
1044
1084
|
mo.disconnect();
|
|
1045
1085
|
document.removeEventListener('click', onClickCapture, true);
|
|
1046
1086
|
};
|
|
1047
|
-
}, [routing, autoPrefixLinks, allLocales, locale, navigateRef]);
|
|
1087
|
+
}, [routing, autoPrefixLinks, allLocales, locale, navigateRef, routingConfig.nonLocalizedPaths]);
|
|
1048
1088
|
// Navigation prefetch: warm the HTTP cache for bootstrap + page bundle before the user clicks (reduces EN→FR flash on SPA route changes).
|
|
1049
1089
|
useEffect(() => {
|
|
1050
1090
|
if (!resolvedApiKey)
|
|
@@ -1156,9 +1196,16 @@ navigateRef, // For path mode routing
|
|
|
1156
1196
|
};
|
|
1157
1197
|
return (React.createElement(LovalingoContext.Provider, { value: contextValue },
|
|
1158
1198
|
children,
|
|
1159
|
-
|
|
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: {
|
|
1160
1207
|
required: Boolean(entitlements?.brandingRequired),
|
|
1161
1208
|
enabled: brandingEnabled,
|
|
1162
1209
|
href: "https://lovalingo.com",
|
|
1163
|
-
} })));
|
|
1210
|
+
} }))));
|
|
1164
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;
|
|
@@ -65,7 +70,10 @@ export declare class LovalingoAPI {
|
|
|
65
70
|
getEntitlements(): ProjectEntitlements | null;
|
|
66
71
|
fetchEntitlements(localeHint: string): Promise<ProjectEntitlements | null>;
|
|
67
72
|
fetchSeoBundle(localeHint: string): Promise<SeoBundleResponse | null>;
|
|
68
|
-
trackPageview(pathOrUrl: string
|
|
73
|
+
trackPageview(pathOrUrl: string, opts?: {
|
|
74
|
+
critical_count?: number;
|
|
75
|
+
critical_hash?: string;
|
|
76
|
+
}): Promise<void>;
|
|
69
77
|
fetchTranslations(sourceLocale: string, targetLocale: string): Promise<Translation[]>;
|
|
70
78
|
fetchBundle(localeHint: string, pathOrUrl?: string): Promise<{
|
|
71
79
|
map: Record<string, string>;
|
package/dist/utils/api.js
CHANGED
|
@@ -117,11 +117,23 @@ export class LovalingoAPI {
|
|
|
117
117
|
return null;
|
|
118
118
|
}
|
|
119
119
|
}
|
|
120
|
-
async trackPageview(pathOrUrl) {
|
|
120
|
+
async trackPageview(pathOrUrl, opts) {
|
|
121
121
|
try {
|
|
122
122
|
if (!this.hasApiKey())
|
|
123
123
|
return;
|
|
124
|
-
const
|
|
124
|
+
const params = new URLSearchParams();
|
|
125
|
+
params.set("key", this.apiKey);
|
|
126
|
+
params.set("path", pathOrUrl);
|
|
127
|
+
const count = opts?.critical_count;
|
|
128
|
+
const hash = (opts?.critical_hash || "").toString().trim().toLowerCase();
|
|
129
|
+
if (typeof count === "number" && Number.isFinite(count) && count > 0 && count <= 5000 && /^[a-z0-9]{1,40}$/.test(hash)) {
|
|
130
|
+
params.set("critical_count", String(Math.floor(count)));
|
|
131
|
+
params.set("critical_hash", hash);
|
|
132
|
+
}
|
|
133
|
+
const response = await fetch(`${this.apiBase}/functions/v1/pageview?${params.toString()}`, {
|
|
134
|
+
method: "GET",
|
|
135
|
+
keepalive: true,
|
|
136
|
+
});
|
|
125
137
|
if (response.status === 403) {
|
|
126
138
|
// Tracking should never block app behavior; keep logging consistent.
|
|
127
139
|
this.logActivationRequired("trackPageview", response);
|
|
@@ -39,6 +39,15 @@ export type DomScanResult = {
|
|
|
39
39
|
};
|
|
40
40
|
truncated: boolean;
|
|
41
41
|
};
|
|
42
|
+
export type CriticalFingerprint = {
|
|
43
|
+
critical_count: number;
|
|
44
|
+
critical_hash: string;
|
|
45
|
+
viewport: {
|
|
46
|
+
width: number;
|
|
47
|
+
height: number;
|
|
48
|
+
};
|
|
49
|
+
};
|
|
50
|
+
export declare function getCriticalFingerprint(): CriticalFingerprint;
|
|
42
51
|
export declare function startMarkerEngine(options?: MarkerEngineOptions): typeof stopMarkerEngine;
|
|
43
52
|
export declare function stopMarkerEngine(): void;
|
|
44
53
|
export declare function getMarkerStats(): MarkerStats;
|
|
@@ -50,6 +50,7 @@ function setGlobalStats(stats) {
|
|
|
50
50
|
g.__lovalingo.dom = {};
|
|
51
51
|
g.__lovalingo.dom.getStats = () => lastStats;
|
|
52
52
|
g.__lovalingo.dom.scan = () => scanDom({ maxSegments: 20000, includeCritical: true });
|
|
53
|
+
g.__lovalingo.dom.getCriticalFingerprint = () => getCriticalFingerprint();
|
|
53
54
|
g.__lovalingo.dom.apply = (bundle) => ({ applied: applyTranslationMap(bundle, document.body) });
|
|
54
55
|
g.__lovalingo.dom.restore = () => restoreDom(document.body);
|
|
55
56
|
}
|
|
@@ -314,6 +315,98 @@ function finalizeStats(stats) {
|
|
|
314
315
|
stats.coverageRatio = eligibleNodes > 0 ? stats.markedNodes / eligibleNodes : 1;
|
|
315
316
|
stats.coverageRatioChars = eligibleChars > 0 ? stats.markedChars / eligibleChars : 1;
|
|
316
317
|
}
|
|
318
|
+
function scanCriticalTexts() {
|
|
319
|
+
const root = document.body;
|
|
320
|
+
const viewportHeight = Math.max(0, Math.floor(window.innerHeight || 0));
|
|
321
|
+
const viewportWidth = Math.max(0, Math.floor(window.innerWidth || 0));
|
|
322
|
+
const viewport = { width: viewportWidth, height: viewportHeight };
|
|
323
|
+
if (!root || viewportHeight <= 0)
|
|
324
|
+
return { texts: [], viewport };
|
|
325
|
+
const seen = new Set();
|
|
326
|
+
const texts = [];
|
|
327
|
+
const walker = document.createTreeWalker(root, NodeFilter.SHOW_TEXT);
|
|
328
|
+
let node = walker.nextNode();
|
|
329
|
+
while (node && texts.length < DEFAULT_CRITICAL_MAX) {
|
|
330
|
+
if (node.nodeType !== Node.TEXT_NODE) {
|
|
331
|
+
node = walker.nextNode();
|
|
332
|
+
continue;
|
|
333
|
+
}
|
|
334
|
+
const textNode = node;
|
|
335
|
+
const raw = textNode.nodeValue || "";
|
|
336
|
+
const trimmed = raw.trim();
|
|
337
|
+
if (!trimmed || !isTranslatableText(trimmed)) {
|
|
338
|
+
node = walker.nextNode();
|
|
339
|
+
continue;
|
|
340
|
+
}
|
|
341
|
+
const parent = textNode.parentElement;
|
|
342
|
+
if (!parent || isExcludedElement(parent) || findUnsafeContainer(parent)) {
|
|
343
|
+
node = walker.nextNode();
|
|
344
|
+
continue;
|
|
345
|
+
}
|
|
346
|
+
const original = getOrInitTextOriginal(textNode, parent);
|
|
347
|
+
const originalText = normalizeWhitespace(original.trimmed);
|
|
348
|
+
if (!originalText || seen.has(originalText)) {
|
|
349
|
+
node = walker.nextNode();
|
|
350
|
+
continue;
|
|
351
|
+
}
|
|
352
|
+
const rect = getTextNodeRect(textNode);
|
|
353
|
+
if (!isInViewport(rect, viewportHeight, DEFAULT_CRITICAL_BUFFER_PX)) {
|
|
354
|
+
node = walker.nextNode();
|
|
355
|
+
continue;
|
|
356
|
+
}
|
|
357
|
+
seen.add(originalText);
|
|
358
|
+
texts.push(originalText);
|
|
359
|
+
node = walker.nextNode();
|
|
360
|
+
}
|
|
361
|
+
if (texts.length < DEFAULT_CRITICAL_MAX) {
|
|
362
|
+
const nodes = root.querySelectorAll("[title],[aria-label],[placeholder]");
|
|
363
|
+
nodes.forEach((el) => {
|
|
364
|
+
if (texts.length >= DEFAULT_CRITICAL_MAX)
|
|
365
|
+
return;
|
|
366
|
+
if (isExcludedElement(el) || findUnsafeContainer(el))
|
|
367
|
+
return;
|
|
368
|
+
let rect = null;
|
|
369
|
+
try {
|
|
370
|
+
rect = el.getBoundingClientRect();
|
|
371
|
+
}
|
|
372
|
+
catch {
|
|
373
|
+
rect = null;
|
|
374
|
+
}
|
|
375
|
+
if (!isInViewport(rect, viewportHeight, DEFAULT_CRITICAL_BUFFER_PX))
|
|
376
|
+
return;
|
|
377
|
+
for (const { attr } of ATTRIBUTE_MARKS) {
|
|
378
|
+
if (texts.length >= DEFAULT_CRITICAL_MAX)
|
|
379
|
+
break;
|
|
380
|
+
const value = el.getAttribute(attr);
|
|
381
|
+
if (!value)
|
|
382
|
+
continue;
|
|
383
|
+
const trimmed = value.trim();
|
|
384
|
+
if (!trimmed || !isTranslatableText(trimmed))
|
|
385
|
+
continue;
|
|
386
|
+
const original = normalizeWhitespace(getOrInitAttrOriginal(el, attr));
|
|
387
|
+
if (!original || seen.has(original))
|
|
388
|
+
continue;
|
|
389
|
+
seen.add(original);
|
|
390
|
+
texts.push(original);
|
|
391
|
+
}
|
|
392
|
+
});
|
|
393
|
+
}
|
|
394
|
+
return { texts, viewport };
|
|
395
|
+
}
|
|
396
|
+
export function getCriticalFingerprint() {
|
|
397
|
+
if (typeof window === "undefined" || typeof document === "undefined") {
|
|
398
|
+
return { critical_count: 0, critical_hash: "0", viewport: { width: 0, height: 0 } };
|
|
399
|
+
}
|
|
400
|
+
const { texts, viewport } = scanCriticalTexts();
|
|
401
|
+
const normalized = texts.map((t) => normalizeWhitespace(t)).filter(Boolean);
|
|
402
|
+
// Why: sort to stay stable across minor DOM reordering without affecting the set of critical strings.
|
|
403
|
+
normalized.sort((a, b) => a.localeCompare(b));
|
|
404
|
+
return {
|
|
405
|
+
critical_count: normalized.length,
|
|
406
|
+
critical_hash: hashContent(normalized.join("\n")),
|
|
407
|
+
viewport,
|
|
408
|
+
};
|
|
409
|
+
}
|
|
317
410
|
function scanDom(opts) {
|
|
318
411
|
const root = document.body;
|
|
319
412
|
if (!root) {
|
|
@@ -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