@lovalingo/lovalingo 0.5.0 → 0.5.2
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 +11 -0
- package/dist/components/AixsterProvider.js +85 -11
- package/dist/components/LangLink.js +0 -6
- package/dist/components/LangRouter.js +1 -7
- package/dist/hooks/useLangNavigate.js +0 -6
- package/dist/types.d.ts +1 -0
- package/dist/utils/api.d.ts +4 -1
- package/dist/utils/api.js +14 -2
- package/dist/utils/markerEngine.d.ts +9 -0
- package/dist/utils/markerEngine.js +93 -0
- package/package.json +1 -1
package/README.md
CHANGED
|
@@ -205,6 +205,7 @@ Lovalingo includes a floating language switcher.
|
|
|
205
205
|
publicAnonKey="aix_xxx"
|
|
206
206
|
defaultLocale="en"
|
|
207
207
|
locales={["en", "de", "fr"]}
|
|
208
|
+
overlayBgColor="#ffffff" // optional: background during the no-flash prehide phase (default: #ffffff)
|
|
208
209
|
switcherPosition="bottom-right"
|
|
209
210
|
switcherOffsetY={20}
|
|
210
211
|
switcherTheme="light" // "dark" | "light" (default: "dark")
|
|
@@ -213,6 +214,16 @@ Lovalingo includes a floating language switcher.
|
|
|
213
214
|
</LovalingoProvider>
|
|
214
215
|
```
|
|
215
216
|
|
|
217
|
+
## Overlay Background (No-Flash UX)
|
|
218
|
+
|
|
219
|
+
Lovalingo may briefly hide the page while it loads translations to avoid a visible EN→DE flash.
|
|
220
|
+
|
|
221
|
+
Set `overlayBgColor` to match your app background so the fallback looks seamless:
|
|
222
|
+
|
|
223
|
+
```tsx
|
|
224
|
+
<LovalingoProvider overlayBgColor="#0b0b0b" ... />
|
|
225
|
+
```
|
|
226
|
+
|
|
216
227
|
## SEO (Canonical + hreflang)
|
|
217
228
|
|
|
218
229
|
Lovalingo can keep `<head>` SEO signals in sync with the active locale:
|
|
@@ -3,7 +3,7 @@ import { LovalingoContext } from '../context/LovalingoContext';
|
|
|
3
3
|
import { LovalingoAPI } from '../utils/api';
|
|
4
4
|
import { applyDomRules } from '../utils/domRules';
|
|
5
5
|
import { hashContent } from '../utils/hash';
|
|
6
|
-
import { applyActiveTranslations, restoreDom, setActiveTranslations, setMarkerEngineExclusions, startMarkerEngine } from '../utils/markerEngine';
|
|
6
|
+
import { applyActiveTranslations, getCriticalFingerprint, restoreDom, setActiveTranslations, setMarkerEngineExclusions, startMarkerEngine } from '../utils/markerEngine';
|
|
7
7
|
import { logDebug, warnDebug, errorDebug } from '../utils/logger';
|
|
8
8
|
import { processPath } from '../utils/pathNormalizer';
|
|
9
9
|
import { LanguageSwitcher } from './LanguageSwitcher';
|
|
@@ -11,8 +11,10 @@ const LOCALE_STORAGE_KEY = 'Lovalingo_locale';
|
|
|
11
11
|
const LOADING_BG_STORAGE_PREFIX = "Lovalingo_loading_bg_color";
|
|
12
12
|
const BRANDING_STORAGE_PREFIX = "Lovalingo_branding_enabled";
|
|
13
13
|
const CRITICAL_CACHE_PREFIX = "Lovalingo_critical_v0_3";
|
|
14
|
+
// Why: avoid long blank screens on blocked/untranslated routes by always revealing the original page quickly.
|
|
15
|
+
const PREHIDE_FAILSAFE_MS = 1700;
|
|
14
16
|
export const LovalingoProvider = ({ children, apiKey: apiKeyProp, publicAnonKey, defaultLocale, locales, apiBase = 'https://cdn.lovalingo.com', routing = 'path', // Default to path mode (SEO-friendly, recommended)
|
|
15
|
-
autoPrefixLinks = true, autoApplyRules = true, switcherPosition = 'bottom-right', switcherOffsetY = 20, switcherTheme = 'dark', editMode: initialEditMode = false, editKey = 'KeyE', pathNormalization = { enabled: true }, // Enable by default
|
|
17
|
+
autoPrefixLinks = true, overlayBgColor, autoApplyRules = true, switcherPosition = 'bottom-right', switcherOffsetY = 20, switcherTheme = 'dark', editMode: initialEditMode = false, editKey = 'KeyE', pathNormalization = { enabled: true }, // Enable by default
|
|
16
18
|
mode = 'dom', // Default to legacy DOM mode for backward compatibility
|
|
17
19
|
sitemap = true, // Default: true - Auto-inject sitemap link tag
|
|
18
20
|
seo = true, // Default: true - Can be disabled per project entitlements
|
|
@@ -75,12 +77,18 @@ navigateRef, // For path mode routing
|
|
|
75
77
|
}, [apiBase, enhancedPathConfig, resolvedApiKey]);
|
|
76
78
|
const [entitlements, setEntitlements] = useState(() => apiRef.current.getEntitlements());
|
|
77
79
|
const lastPageviewRef = useRef("");
|
|
80
|
+
const lastPageviewFingerprintRef = useRef("");
|
|
81
|
+
const pageviewFingerprintTimeoutRef = useRef(null);
|
|
82
|
+
const pageviewFingerprintRetryTimeoutRef = useRef(null);
|
|
83
|
+
const lastNormalizedPathRef = useRef("");
|
|
78
84
|
const historyPatchedRef = useRef(false);
|
|
79
85
|
const originalHistoryRef = useRef(null);
|
|
80
86
|
const onNavigateRef = useRef(() => undefined);
|
|
81
87
|
const retryTimeoutRef = useRef(null);
|
|
88
|
+
const loadingFailsafeTimeoutRef = useRef(null);
|
|
82
89
|
const isNavigatingRef = useRef(false);
|
|
83
90
|
const isInternalNavigationRef = useRef(false);
|
|
91
|
+
const inFlightLoadKeyRef = useRef(null);
|
|
84
92
|
const translationCacheRef = useRef(new Map());
|
|
85
93
|
const exclusionsCacheRef = useRef(null);
|
|
86
94
|
const domRulesCacheRef = useRef(new Map());
|
|
@@ -109,6 +117,9 @@ navigateRef, // For path mode routing
|
|
|
109
117
|
prevBodyBg: "",
|
|
110
118
|
});
|
|
111
119
|
const getCachedLoadingBgColor = useCallback(() => {
|
|
120
|
+
const configured = (overlayBgColor || "").toString().trim();
|
|
121
|
+
if (/^#[0-9a-fA-F]{6}$/.test(configured))
|
|
122
|
+
return configured;
|
|
112
123
|
try {
|
|
113
124
|
const cached = localStorage.getItem(loadingBgStorageKey) || "";
|
|
114
125
|
if (/^#[0-9a-fA-F]{6}$/.test(cached.trim()))
|
|
@@ -118,7 +129,7 @@ navigateRef, // For path mode routing
|
|
|
118
129
|
// ignore
|
|
119
130
|
}
|
|
120
131
|
return "#ffffff";
|
|
121
|
-
}, [loadingBgStorageKey]);
|
|
132
|
+
}, [loadingBgStorageKey, overlayBgColor]);
|
|
122
133
|
const setCachedLoadingBgColor = useCallback((color) => {
|
|
123
134
|
const next = (color || "").toString().trim();
|
|
124
135
|
if (!/^#[0-9a-fA-F]{6}$/.test(next))
|
|
@@ -130,6 +141,13 @@ navigateRef, // For path mode routing
|
|
|
130
141
|
// ignore
|
|
131
142
|
}
|
|
132
143
|
}, [loadingBgStorageKey]);
|
|
144
|
+
useEffect(() => {
|
|
145
|
+
// Why: make `overlayBgColor` the source of truth while keeping the existing cache key for backwards compatibility.
|
|
146
|
+
const configured = (overlayBgColor || "").toString().trim();
|
|
147
|
+
if (!/^#[0-9a-fA-F]{6}$/.test(configured))
|
|
148
|
+
return;
|
|
149
|
+
setCachedLoadingBgColor(configured);
|
|
150
|
+
}, [overlayBgColor, setCachedLoadingBgColor]);
|
|
133
151
|
const setCachedBrandingEnabled = useCallback((enabled) => {
|
|
134
152
|
try {
|
|
135
153
|
localStorage.setItem(brandingStorageKey, enabled === false ? "0" : "1");
|
|
@@ -144,6 +162,7 @@ navigateRef, // For path mode routing
|
|
|
144
162
|
}, [brandingStorageKey]);
|
|
145
163
|
useEffect(() => {
|
|
146
164
|
lastPageviewRef.current = "";
|
|
165
|
+
lastPageviewFingerprintRef.current = "";
|
|
147
166
|
}, [resolvedApiKey]);
|
|
148
167
|
const trackPageviewOnce = useCallback((path) => {
|
|
149
168
|
const next = (path || "").toString();
|
|
@@ -153,6 +172,28 @@ navigateRef, // For path mode routing
|
|
|
153
172
|
return;
|
|
154
173
|
lastPageviewRef.current = next;
|
|
155
174
|
apiRef.current.trackPageview(next);
|
|
175
|
+
const trySendFingerprint = () => {
|
|
176
|
+
if (typeof window === "undefined")
|
|
177
|
+
return;
|
|
178
|
+
const markersReady = window.__lovalingoMarkersReady === true;
|
|
179
|
+
if (!markersReady)
|
|
180
|
+
return;
|
|
181
|
+
const fp = getCriticalFingerprint();
|
|
182
|
+
if (!fp || fp.critical_count <= 0)
|
|
183
|
+
return;
|
|
184
|
+
const signature = `${next}|${fp.critical_hash}|${fp.critical_count}`;
|
|
185
|
+
if (lastPageviewFingerprintRef.current === signature)
|
|
186
|
+
return;
|
|
187
|
+
lastPageviewFingerprintRef.current = signature;
|
|
188
|
+
apiRef.current.trackPageview(next, fp);
|
|
189
|
+
};
|
|
190
|
+
if (pageviewFingerprintTimeoutRef.current != null)
|
|
191
|
+
window.clearTimeout(pageviewFingerprintTimeoutRef.current);
|
|
192
|
+
if (pageviewFingerprintRetryTimeoutRef.current != null)
|
|
193
|
+
window.clearTimeout(pageviewFingerprintRetryTimeoutRef.current);
|
|
194
|
+
// Why: wait briefly for markers/content to settle before computing a critical fingerprint for change detection.
|
|
195
|
+
pageviewFingerprintTimeoutRef.current = window.setTimeout(trySendFingerprint, 800);
|
|
196
|
+
pageviewFingerprintRetryTimeoutRef.current = window.setTimeout(trySendFingerprint, 2000);
|
|
156
197
|
}, []);
|
|
157
198
|
const enablePrehide = useCallback((bgColor) => {
|
|
158
199
|
if (typeof document === "undefined")
|
|
@@ -176,12 +217,10 @@ navigateRef, // For path mode routing
|
|
|
176
217
|
body.style.backgroundColor = bgColor;
|
|
177
218
|
}
|
|
178
219
|
if (state.timeoutId != null) {
|
|
179
|
-
|
|
220
|
+
return;
|
|
180
221
|
}
|
|
181
|
-
// Why: avoid
|
|
182
|
-
state.timeoutId = window.setTimeout(() =>
|
|
183
|
-
disablePrehide();
|
|
184
|
-
}, 2500);
|
|
222
|
+
// Why: avoid a "perma-hide" when navigation events repeatedly re-trigger prehide and keep extending the timeout.
|
|
223
|
+
state.timeoutId = window.setTimeout(() => disablePrehide(), PREHIDE_FAILSAFE_MS);
|
|
185
224
|
}, []);
|
|
186
225
|
const disablePrehide = useCallback(() => {
|
|
187
226
|
if (typeof document === "undefined")
|
|
@@ -266,6 +305,7 @@ navigateRef, // For path mode routing
|
|
|
266
305
|
apiBase,
|
|
267
306
|
routing,
|
|
268
307
|
autoPrefixLinks,
|
|
308
|
+
overlayBgColor,
|
|
269
309
|
switcherPosition,
|
|
270
310
|
switcherOffsetY,
|
|
271
311
|
switcherTheme,
|
|
@@ -501,6 +541,10 @@ navigateRef, // For path mode routing
|
|
|
501
541
|
clearTimeout(retryTimeoutRef.current);
|
|
502
542
|
retryTimeoutRef.current = null;
|
|
503
543
|
}
|
|
544
|
+
if (loadingFailsafeTimeoutRef.current != null) {
|
|
545
|
+
window.clearTimeout(loadingFailsafeTimeoutRef.current);
|
|
546
|
+
loadingFailsafeTimeoutRef.current = null;
|
|
547
|
+
}
|
|
504
548
|
// If switching to default locale, clear translations and translate with empty map
|
|
505
549
|
// This will show original text using stored data-Lovalingo-original-html
|
|
506
550
|
if (targetLocale === defaultLocale) {
|
|
@@ -513,6 +557,10 @@ navigateRef, // For path mode routing
|
|
|
513
557
|
const currentPath = window.location.pathname + window.location.search;
|
|
514
558
|
const normalizedPath = processPath(window.location.pathname, enhancedPathConfig);
|
|
515
559
|
const cacheKey = `${targetLocale}:${normalizedPath}`;
|
|
560
|
+
if (inFlightLoadKeyRef.current === cacheKey) {
|
|
561
|
+
return;
|
|
562
|
+
}
|
|
563
|
+
inFlightLoadKeyRef.current = cacheKey;
|
|
516
564
|
// Check if we have cached translations for this locale + path
|
|
517
565
|
const cachedEntry = translationCacheRef.current.get(cacheKey);
|
|
518
566
|
const cachedExclusions = exclusionsCacheRef.current;
|
|
@@ -553,12 +601,20 @@ navigateRef, // For path mode routing
|
|
|
553
601
|
}, 500);
|
|
554
602
|
disablePrehide();
|
|
555
603
|
isNavigatingRef.current = false;
|
|
604
|
+
if (inFlightLoadKeyRef.current === cacheKey) {
|
|
605
|
+
inFlightLoadKeyRef.current = null;
|
|
606
|
+
}
|
|
556
607
|
return;
|
|
557
608
|
}
|
|
558
609
|
// CACHE MISS - Fetch from API
|
|
559
610
|
logDebug(`[Lovalingo] Fetching translations for ${targetLocale} on ${normalizedPath}`);
|
|
560
611
|
setIsLoading(true);
|
|
561
612
|
enablePrehide(getCachedLoadingBgColor());
|
|
613
|
+
// Why: never keep the app hidden/blocked for longer than the UX budget; show the original content if translations aren't ready fast.
|
|
614
|
+
loadingFailsafeTimeoutRef.current = window.setTimeout(() => {
|
|
615
|
+
disablePrehide();
|
|
616
|
+
setIsLoading(false);
|
|
617
|
+
}, PREHIDE_FAILSAFE_MS);
|
|
562
618
|
try {
|
|
563
619
|
if (previousLocale && previousLocale !== defaultLocale) {
|
|
564
620
|
logDebug(`[Lovalingo] Switching from ${previousLocale} to ${targetLocale}`);
|
|
@@ -673,7 +729,14 @@ navigateRef, // For path mode routing
|
|
|
673
729
|
}
|
|
674
730
|
finally {
|
|
675
731
|
setIsLoading(false);
|
|
732
|
+
if (loadingFailsafeTimeoutRef.current != null) {
|
|
733
|
+
window.clearTimeout(loadingFailsafeTimeoutRef.current);
|
|
734
|
+
loadingFailsafeTimeoutRef.current = null;
|
|
735
|
+
}
|
|
676
736
|
isNavigatingRef.current = false;
|
|
737
|
+
if (inFlightLoadKeyRef.current === cacheKey) {
|
|
738
|
+
inFlightLoadKeyRef.current = null;
|
|
739
|
+
}
|
|
677
740
|
}
|
|
678
741
|
}, [
|
|
679
742
|
applySeoBundle,
|
|
@@ -694,15 +757,25 @@ navigateRef, // For path mode routing
|
|
|
694
757
|
onNavigateRef.current = () => {
|
|
695
758
|
trackPageviewOnce(window.location.pathname + window.location.search);
|
|
696
759
|
const nextLocale = detectLocale();
|
|
760
|
+
const normalizedPath = processPath(window.location.pathname, enhancedPathConfig);
|
|
761
|
+
const normalizedPathChanged = normalizedPath !== lastNormalizedPathRef.current;
|
|
762
|
+
lastNormalizedPathRef.current = normalizedPath;
|
|
763
|
+
// Why: bundles are path-scoped, so SPA navigations within the same locale must trigger a reload for the new route.
|
|
764
|
+
if (normalizedPathChanged && nextLocale !== defaultLocale && !isInternalNavigationRef.current) {
|
|
765
|
+
void loadData(nextLocale, locale);
|
|
766
|
+
return;
|
|
767
|
+
}
|
|
697
768
|
if (nextLocale !== locale) {
|
|
698
769
|
setLocaleState(nextLocale);
|
|
699
|
-
|
|
770
|
+
if (!isInternalNavigationRef.current) {
|
|
771
|
+
void loadData(nextLocale, locale);
|
|
772
|
+
}
|
|
700
773
|
}
|
|
701
774
|
else if (mode === "dom" && nextLocale !== defaultLocale) {
|
|
702
775
|
applyActiveTranslations(document.body);
|
|
703
776
|
}
|
|
704
777
|
};
|
|
705
|
-
}, [defaultLocale, detectLocale, loadData, locale, mode, trackPageviewOnce]);
|
|
778
|
+
}, [defaultLocale, detectLocale, enhancedPathConfig, loadData, locale, mode, trackPageviewOnce]);
|
|
706
779
|
// SPA router hook-in: patch History API once (prevents stacked wrappers → request storms).
|
|
707
780
|
useEffect(() => {
|
|
708
781
|
if (typeof window === "undefined")
|
|
@@ -801,6 +874,7 @@ navigateRef, // For path mode routing
|
|
|
801
874
|
// Initialize
|
|
802
875
|
useEffect(() => {
|
|
803
876
|
const initialLocale = detectLocale();
|
|
877
|
+
lastNormalizedPathRef.current = processPath(window.location.pathname, enhancedPathConfig);
|
|
804
878
|
// Track initial page (fallback discovery for pages not present in the routes feed).
|
|
805
879
|
trackPageviewOnce(window.location.pathname + window.location.search);
|
|
806
880
|
// Always prefetch artifacts for the initial locale (pipeline-produced translations + rules).
|
|
@@ -820,7 +894,7 @@ navigateRef, // For path mode routing
|
|
|
820
894
|
clearTimeout(retryTimeoutRef.current);
|
|
821
895
|
}
|
|
822
896
|
};
|
|
823
|
-
}, [detectLocale, loadData, editKey, trackPageviewOnce]);
|
|
897
|
+
}, [detectLocale, enhancedPathConfig, loadData, editKey, trackPageviewOnce]);
|
|
824
898
|
// Auto-inject sitemap link tag
|
|
825
899
|
useEffect(() => {
|
|
826
900
|
if (sitemap && resolvedApiKey && isSeoActive()) {
|
|
@@ -2,12 +2,6 @@ import React from 'react';
|
|
|
2
2
|
import { Link } from 'react-router-dom';
|
|
3
3
|
import { useLang } from '../hooks/useLang';
|
|
4
4
|
const NON_LOCALIZED_APP_PATHS = new Set([
|
|
5
|
-
'auth',
|
|
6
|
-
'login',
|
|
7
|
-
'signup',
|
|
8
|
-
'sign-in',
|
|
9
|
-
'sign-up',
|
|
10
|
-
'register',
|
|
11
5
|
'robots.txt',
|
|
12
6
|
'sitemap.xml',
|
|
13
7
|
]);
|
|
@@ -2,12 +2,6 @@ import React, { useEffect } from 'react';
|
|
|
2
2
|
import { BrowserRouter, Routes, Route, Navigate, Outlet, useLocation, useNavigate } from 'react-router-dom';
|
|
3
3
|
import { LangContext } from '../context/LangContext';
|
|
4
4
|
const NON_LOCALIZED_APP_PATHS = new Set([
|
|
5
|
-
'/auth',
|
|
6
|
-
'/login',
|
|
7
|
-
'/signup',
|
|
8
|
-
'/sign-in',
|
|
9
|
-
'/sign-up',
|
|
10
|
-
'/register',
|
|
11
5
|
'/robots.txt',
|
|
12
6
|
'/sitemap.xml',
|
|
13
7
|
]);
|
|
@@ -35,7 +29,7 @@ function NavigateExporter({ navigateRef }) {
|
|
|
35
29
|
*/
|
|
36
30
|
function LangGuard({ defaultLang, lang }) {
|
|
37
31
|
const location = useLocation();
|
|
38
|
-
// If the URL is language-prefixed but the underlying route is non-localized (
|
|
32
|
+
// If the URL is language-prefixed but the underlying route is non-localized (e.g. robots/sitemap),
|
|
39
33
|
// redirect to the canonical non-localized path.
|
|
40
34
|
const prefix = `/${lang}`;
|
|
41
35
|
const restPath = location.pathname.startsWith(prefix) ? location.pathname.slice(prefix.length) || '/' : location.pathname;
|
|
@@ -3,12 +3,6 @@ import { useCallback } from 'react';
|
|
|
3
3
|
import { useLang } from './useLang';
|
|
4
4
|
//Globally excluded paths.
|
|
5
5
|
const NON_LOCALIZED_APP_PATHS = new Set([
|
|
6
|
-
'auth',
|
|
7
|
-
'login',
|
|
8
|
-
'signup',
|
|
9
|
-
'sign-in',
|
|
10
|
-
'sign-up',
|
|
11
|
-
'register',
|
|
12
6
|
'robots.txt',
|
|
13
7
|
'sitemap.xml',
|
|
14
8
|
]);
|
package/dist/types.d.ts
CHANGED
|
@@ -10,6 +10,7 @@ export interface LovalingoConfig {
|
|
|
10
10
|
apiBase?: string;
|
|
11
11
|
routing?: 'query' | 'path';
|
|
12
12
|
autoPrefixLinks?: boolean;
|
|
13
|
+
overlayBgColor?: string;
|
|
13
14
|
switcherPosition?: 'bottom-right' | 'bottom-left' | 'top-right' | 'top-left';
|
|
14
15
|
switcherOffsetY?: number;
|
|
15
16
|
switcherTheme?: 'dark' | 'light';
|
package/dist/utils/api.d.ts
CHANGED
|
@@ -65,7 +65,10 @@ export declare class LovalingoAPI {
|
|
|
65
65
|
getEntitlements(): ProjectEntitlements | null;
|
|
66
66
|
fetchEntitlements(localeHint: string): Promise<ProjectEntitlements | null>;
|
|
67
67
|
fetchSeoBundle(localeHint: string): Promise<SeoBundleResponse | null>;
|
|
68
|
-
trackPageview(pathOrUrl: string
|
|
68
|
+
trackPageview(pathOrUrl: string, opts?: {
|
|
69
|
+
critical_count?: number;
|
|
70
|
+
critical_hash?: string;
|
|
71
|
+
}): Promise<void>;
|
|
69
72
|
fetchTranslations(sourceLocale: string, targetLocale: string): Promise<Translation[]>;
|
|
70
73
|
fetchBundle(localeHint: string, pathOrUrl?: string): Promise<{
|
|
71
74
|
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) {
|
package/package.json
CHANGED