@lovalingo/lovalingo 0.2.1 → 0.3.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 +5 -4
- package/dist/components/AixsterProvider.js +429 -198
- package/dist/utils/api.d.ts +31 -1
- package/dist/utils/api.js +100 -25
- package/dist/utils/markerEngine.d.ts +5 -0
- package/dist/utils/markerEngine.js +80 -5
- package/dist/version.d.ts +1 -1
- package/dist/version.js +1 -1
- package/package.json +1 -1
package/README.md
CHANGED
|
@@ -7,10 +7,9 @@ Built for React and Next.js apps, Lovalingo does **not** generate translations i
|
|
|
7
7
|
## i18n alternative for lovable and vibecoding tools
|
|
8
8
|
|
|
9
9
|
1. Your app renders normally (source language).
|
|
10
|
-
2. Lovalingo
|
|
11
|
-
3. The runtime
|
|
12
|
-
4.
|
|
13
|
-
5. Optional SEO: Lovalingo updates `<head>` (canonical + hreflang + basic meta) using `seo-bundle`.
|
|
10
|
+
2. Lovalingo fetches a single `bootstrap` payload (critical above-the-fold translations + SEO + rules + exclusions).
|
|
11
|
+
3. The runtime hides the page (`visibility:hidden`) until the critical slice is applied (prevents the EN → FR flash).
|
|
12
|
+
4. The full page bundle is lazy-loaded after first paint (keeps route changes + dynamic content translated).
|
|
14
13
|
|
|
15
14
|
All artifacts are produced server-side by the pipeline (render → audit → deterministic translate → optional fix loop).
|
|
16
15
|
|
|
@@ -117,6 +116,8 @@ Enabled by default. Disable if you already manage `<head>` yourself:
|
|
|
117
116
|
<LovalingoProvider seo={false} ... />
|
|
118
117
|
```
|
|
119
118
|
|
|
119
|
+
Tip: Set the "No-flash loading background" color in the Lovalingo dashboard (Project → Setup) to match your site’s background during the brief prehide window.
|
|
120
|
+
|
|
120
121
|
## Sitemap link tag
|
|
121
122
|
|
|
122
123
|
By default Lovalingo injects `<link rel="sitemap" href="/sitemap.xml">` for discovery. Disable it:
|
|
@@ -2,11 +2,14 @@ import React, { useMemo, useState, useEffect, useCallback, useRef } from 'react'
|
|
|
2
2
|
import { LovalingoContext } from '../context/LovalingoContext';
|
|
3
3
|
import { LovalingoAPI } from '../utils/api';
|
|
4
4
|
import { applyDomRules } from '../utils/domRules';
|
|
5
|
+
import { hashContent } from '../utils/hash';
|
|
5
6
|
import { applyActiveTranslations, restoreDom, setActiveTranslations, setMarkerEngineExclusions, startMarkerEngine } from '../utils/markerEngine';
|
|
6
7
|
import { logDebug, warnDebug, errorDebug } from '../utils/logger';
|
|
8
|
+
import { processPath } from '../utils/pathNormalizer';
|
|
7
9
|
import { LanguageSwitcher } from './LanguageSwitcher';
|
|
8
|
-
import { NavigationOverlay } from './NavigationOverlay';
|
|
9
10
|
const LOCALE_STORAGE_KEY = 'Lovalingo_locale';
|
|
11
|
+
const LOADING_BG_STORAGE_PREFIX = "Lovalingo_loading_bg_color";
|
|
12
|
+
const CRITICAL_CACHE_PREFIX = "Lovalingo_critical_v0_3";
|
|
10
13
|
export const LovalingoProvider = ({ children, apiKey: apiKeyProp, publicAnonKey, defaultLocale, locales, apiBase = 'https://leuskvkajliuzalrlwhw.supabase.co', routing = 'query', // Default to query mode (backward compatible)
|
|
11
14
|
autoPrefixLinks = true, autoApplyRules = true, switcherPosition = 'bottom-right', switcherOffsetY = 20, switcherTheme = 'dark', editMode: initialEditMode = false, editKey = 'KeyE', pathNormalization = { enabled: true }, // Enable by default
|
|
12
15
|
mode = 'dom', // Default to legacy DOM mode for backward compatibility
|
|
@@ -34,27 +37,41 @@ navigateRef, // For path mode routing
|
|
|
34
37
|
const base = rawLocales.includes(defaultLocale) ? rawLocales : [defaultLocale, ...rawLocales];
|
|
35
38
|
return Array.from(new Set(base));
|
|
36
39
|
}, [defaultLocale, localesKey, rawLocales]);
|
|
37
|
-
//
|
|
40
|
+
// Why: read locale synchronously from the URL to avoid an initial default-locale render (EN → FR flash) before effects run.
|
|
38
41
|
const [locale, setLocaleState] = useState(() => {
|
|
42
|
+
if (typeof window === "undefined")
|
|
43
|
+
return defaultLocale;
|
|
44
|
+
if (routing === "path") {
|
|
45
|
+
const pathLocale = window.location.pathname.split("/")[1];
|
|
46
|
+
if (pathLocale && allLocales.includes(pathLocale)) {
|
|
47
|
+
return pathLocale;
|
|
48
|
+
}
|
|
49
|
+
}
|
|
50
|
+
else if (routing === "query") {
|
|
51
|
+
const params = new URLSearchParams(window.location.search);
|
|
52
|
+
const queryLocale = params.get("t") || params.get("locale");
|
|
53
|
+
if (queryLocale && allLocales.includes(queryLocale)) {
|
|
54
|
+
return queryLocale;
|
|
55
|
+
}
|
|
56
|
+
}
|
|
39
57
|
try {
|
|
40
58
|
const stored = localStorage.getItem(LOCALE_STORAGE_KEY);
|
|
41
|
-
if (stored &&
|
|
59
|
+
if (stored && allLocales.includes(stored)) {
|
|
42
60
|
return stored;
|
|
43
61
|
}
|
|
44
62
|
}
|
|
45
|
-
catch
|
|
46
|
-
//
|
|
63
|
+
catch {
|
|
64
|
+
// ignore
|
|
47
65
|
}
|
|
48
66
|
return defaultLocale;
|
|
49
67
|
});
|
|
50
68
|
const [isLoading, setIsLoading] = useState(false);
|
|
51
|
-
const [isNavigationLoading, setIsNavigationLoading] = useState(false);
|
|
52
69
|
const [editMode, setEditMode] = useState(initialEditMode);
|
|
53
|
-
|
|
54
|
-
const enhancedPathConfig = routing === 'path'
|
|
55
|
-
? { ...pathNormalization, supportedLocales: allLocales }
|
|
56
|
-
: pathNormalization;
|
|
70
|
+
const enhancedPathConfig = useMemo(() => (routing === "path" ? { ...pathNormalization, supportedLocales: allLocales } : pathNormalization), [allLocales, pathNormalization, routing]);
|
|
57
71
|
const apiRef = useRef(new LovalingoAPI(resolvedApiKey, apiBase, enhancedPathConfig));
|
|
72
|
+
useEffect(() => {
|
|
73
|
+
apiRef.current = new LovalingoAPI(resolvedApiKey, apiBase, enhancedPathConfig);
|
|
74
|
+
}, [apiBase, enhancedPathConfig, resolvedApiKey]);
|
|
58
75
|
const [entitlements, setEntitlements] = useState(() => apiRef.current.getEntitlements());
|
|
59
76
|
const retryTimeoutRef = useRef(null);
|
|
60
77
|
const isNavigatingRef = useRef(false);
|
|
@@ -62,6 +79,141 @@ navigateRef, // For path mode routing
|
|
|
62
79
|
const translationCacheRef = useRef(new Map());
|
|
63
80
|
const exclusionsCacheRef = useRef(null);
|
|
64
81
|
const domRulesCacheRef = useRef(new Map());
|
|
82
|
+
const loadingBgStorageKey = `${LOADING_BG_STORAGE_PREFIX}:${resolvedApiKey || "anonymous"}`;
|
|
83
|
+
const prehideStateRef = useRef({
|
|
84
|
+
active: false,
|
|
85
|
+
timeoutId: null,
|
|
86
|
+
prevHtmlVisibility: "",
|
|
87
|
+
prevBodyVisibility: "",
|
|
88
|
+
prevHtmlBg: "",
|
|
89
|
+
prevBodyBg: "",
|
|
90
|
+
});
|
|
91
|
+
const getCachedLoadingBgColor = useCallback(() => {
|
|
92
|
+
try {
|
|
93
|
+
const cached = localStorage.getItem(loadingBgStorageKey) || "";
|
|
94
|
+
if (/^#[0-9a-fA-F]{6}$/.test(cached.trim()))
|
|
95
|
+
return cached.trim();
|
|
96
|
+
}
|
|
97
|
+
catch {
|
|
98
|
+
// ignore
|
|
99
|
+
}
|
|
100
|
+
return "#ffffff";
|
|
101
|
+
}, [loadingBgStorageKey]);
|
|
102
|
+
const setCachedLoadingBgColor = useCallback((color) => {
|
|
103
|
+
const next = (color || "").toString().trim();
|
|
104
|
+
if (!/^#[0-9a-fA-F]{6}$/.test(next))
|
|
105
|
+
return;
|
|
106
|
+
try {
|
|
107
|
+
localStorage.setItem(loadingBgStorageKey, next);
|
|
108
|
+
}
|
|
109
|
+
catch {
|
|
110
|
+
// ignore
|
|
111
|
+
}
|
|
112
|
+
}, [loadingBgStorageKey]);
|
|
113
|
+
const enablePrehide = useCallback((bgColor) => {
|
|
114
|
+
if (typeof document === "undefined")
|
|
115
|
+
return;
|
|
116
|
+
const html = document.documentElement;
|
|
117
|
+
const body = document.body;
|
|
118
|
+
if (!html || !body)
|
|
119
|
+
return;
|
|
120
|
+
const state = prehideStateRef.current;
|
|
121
|
+
if (!state.active) {
|
|
122
|
+
state.active = true;
|
|
123
|
+
state.prevHtmlVisibility = html.style.visibility || "";
|
|
124
|
+
state.prevBodyVisibility = body.style.visibility || "";
|
|
125
|
+
state.prevHtmlBg = html.style.backgroundColor || "";
|
|
126
|
+
state.prevBodyBg = body.style.backgroundColor || "";
|
|
127
|
+
}
|
|
128
|
+
html.style.visibility = "hidden";
|
|
129
|
+
body.style.visibility = "hidden";
|
|
130
|
+
if (bgColor) {
|
|
131
|
+
html.style.backgroundColor = bgColor;
|
|
132
|
+
body.style.backgroundColor = bgColor;
|
|
133
|
+
}
|
|
134
|
+
if (state.timeoutId != null) {
|
|
135
|
+
window.clearTimeout(state.timeoutId);
|
|
136
|
+
}
|
|
137
|
+
// Why: avoid leaving the page hidden forever if the network is blocked or the project has no translations yet.
|
|
138
|
+
state.timeoutId = window.setTimeout(() => {
|
|
139
|
+
disablePrehide();
|
|
140
|
+
}, 2500);
|
|
141
|
+
}, []);
|
|
142
|
+
const disablePrehide = useCallback(() => {
|
|
143
|
+
if (typeof document === "undefined")
|
|
144
|
+
return;
|
|
145
|
+
const html = document.documentElement;
|
|
146
|
+
const body = document.body;
|
|
147
|
+
if (!html || !body)
|
|
148
|
+
return;
|
|
149
|
+
const state = prehideStateRef.current;
|
|
150
|
+
if (state.timeoutId != null) {
|
|
151
|
+
window.clearTimeout(state.timeoutId);
|
|
152
|
+
state.timeoutId = null;
|
|
153
|
+
}
|
|
154
|
+
if (!state.active)
|
|
155
|
+
return;
|
|
156
|
+
state.active = false;
|
|
157
|
+
html.style.visibility = state.prevHtmlVisibility;
|
|
158
|
+
body.style.visibility = state.prevBodyVisibility;
|
|
159
|
+
html.style.backgroundColor = state.prevHtmlBg;
|
|
160
|
+
body.style.backgroundColor = state.prevBodyBg;
|
|
161
|
+
}, []);
|
|
162
|
+
const buildCriticalCacheKey = useCallback((targetLocale, normalizedPath) => {
|
|
163
|
+
const key = `${resolvedApiKey || "anonymous"}:${targetLocale}:${normalizedPath || "/"}`;
|
|
164
|
+
return `${CRITICAL_CACHE_PREFIX}:${hashContent(key)}`;
|
|
165
|
+
}, [resolvedApiKey]);
|
|
166
|
+
const readCriticalCache = useCallback((targetLocale, normalizedPath) => {
|
|
167
|
+
const key = buildCriticalCacheKey(targetLocale, normalizedPath);
|
|
168
|
+
try {
|
|
169
|
+
const raw = localStorage.getItem(key);
|
|
170
|
+
if (!raw)
|
|
171
|
+
return null;
|
|
172
|
+
const parsed = JSON.parse(raw);
|
|
173
|
+
if (!parsed || typeof parsed !== "object")
|
|
174
|
+
return null;
|
|
175
|
+
const record = parsed;
|
|
176
|
+
const map = record.map && typeof record.map === "object" && !Array.isArray(record.map) ? record.map : null;
|
|
177
|
+
const exclusionsRaw = Array.isArray(record.exclusions) ? record.exclusions : [];
|
|
178
|
+
const exclusions = exclusionsRaw
|
|
179
|
+
.map((row) => {
|
|
180
|
+
if (!row || typeof row !== "object")
|
|
181
|
+
return null;
|
|
182
|
+
const r = row;
|
|
183
|
+
const selector = typeof r.selector === "string" ? r.selector.trim() : "";
|
|
184
|
+
const type = typeof r.type === "string" ? r.type.trim() : "";
|
|
185
|
+
if (!selector)
|
|
186
|
+
return null;
|
|
187
|
+
if (type !== "css" && type !== "xpath")
|
|
188
|
+
return null;
|
|
189
|
+
return { selector, type: type };
|
|
190
|
+
})
|
|
191
|
+
.filter(Boolean);
|
|
192
|
+
const bg = typeof record.loading_bg_color === "string" ? record.loading_bg_color.trim() : "";
|
|
193
|
+
return {
|
|
194
|
+
map: map || {},
|
|
195
|
+
exclusions,
|
|
196
|
+
loading_bg_color: /^#[0-9a-fA-F]{6}$/.test(bg) ? bg : null,
|
|
197
|
+
};
|
|
198
|
+
}
|
|
199
|
+
catch {
|
|
200
|
+
return null;
|
|
201
|
+
}
|
|
202
|
+
}, [buildCriticalCacheKey]);
|
|
203
|
+
const writeCriticalCache = useCallback((targetLocale, normalizedPath, entry) => {
|
|
204
|
+
const key = buildCriticalCacheKey(targetLocale, normalizedPath);
|
|
205
|
+
try {
|
|
206
|
+
localStorage.setItem(key, JSON.stringify({
|
|
207
|
+
stored_at: Date.now(),
|
|
208
|
+
map: entry.map || {},
|
|
209
|
+
exclusions: entry.exclusions || [],
|
|
210
|
+
loading_bg_color: entry.loading_bg_color,
|
|
211
|
+
}));
|
|
212
|
+
}
|
|
213
|
+
catch {
|
|
214
|
+
// ignore
|
|
215
|
+
}
|
|
216
|
+
}, [buildCriticalCacheKey]);
|
|
65
217
|
const config = {
|
|
66
218
|
apiKey: resolvedApiKey,
|
|
67
219
|
publicAnonKey: resolvedApiKey,
|
|
@@ -99,15 +251,74 @@ navigateRef, // For path mode routing
|
|
|
99
251
|
return false;
|
|
100
252
|
return seo !== false;
|
|
101
253
|
}, [entitlements, seo]);
|
|
102
|
-
|
|
254
|
+
// Marker engine: always mark full DOM content for deterministic pipeline extraction.
|
|
255
|
+
useEffect(() => {
|
|
256
|
+
const stop = startMarkerEngine({ throttleMs: 120 });
|
|
257
|
+
return () => stop();
|
|
258
|
+
}, []);
|
|
259
|
+
useEffect(() => {
|
|
260
|
+
return () => disablePrehide();
|
|
261
|
+
}, [disablePrehide]);
|
|
262
|
+
// Detect locale from URL or localStorage
|
|
263
|
+
const detectLocale = useCallback(() => {
|
|
264
|
+
// 1. Check URL first based on routing mode
|
|
265
|
+
if (routing === 'path') {
|
|
266
|
+
// Path mode: language is in path (/en/pricing, /fr/about)
|
|
267
|
+
const pathLocale = window.location.pathname.split('/')[1];
|
|
268
|
+
if (allLocales.includes(pathLocale)) {
|
|
269
|
+
return pathLocale;
|
|
270
|
+
}
|
|
271
|
+
}
|
|
272
|
+
else if (routing === 'query') {
|
|
273
|
+
// Query mode: language is in query param (/pricing?t=fr)
|
|
274
|
+
const params = new URLSearchParams(window.location.search);
|
|
275
|
+
const queryLocale = params.get('t') || params.get('locale');
|
|
276
|
+
if (queryLocale && allLocales.includes(queryLocale)) {
|
|
277
|
+
return queryLocale;
|
|
278
|
+
}
|
|
279
|
+
}
|
|
280
|
+
// 2. Check localStorage (fallback for all routing modes)
|
|
281
|
+
try {
|
|
282
|
+
const storedLocale = localStorage.getItem(LOCALE_STORAGE_KEY);
|
|
283
|
+
if (storedLocale && allLocales.includes(storedLocale)) {
|
|
284
|
+
return storedLocale;
|
|
285
|
+
}
|
|
286
|
+
}
|
|
287
|
+
catch (e) {
|
|
288
|
+
// localStorage might be unavailable (SSR, private browsing)
|
|
289
|
+
warnDebug('localStorage not available:', e);
|
|
290
|
+
}
|
|
291
|
+
// 3. Default locale
|
|
292
|
+
return defaultLocale;
|
|
293
|
+
}, [allLocales, defaultLocale, routing]);
|
|
294
|
+
// Fetch entitlements early so SEO can be enabled even on default locale
|
|
295
|
+
useEffect(() => {
|
|
296
|
+
if (locale !== defaultLocale)
|
|
297
|
+
return;
|
|
298
|
+
if (entitlements)
|
|
299
|
+
return;
|
|
300
|
+
let cancelled = false;
|
|
301
|
+
(async () => {
|
|
302
|
+
const bootstrap = await apiRef.current.fetchBootstrap(locale, window.location.pathname + window.location.search);
|
|
303
|
+
if (cancelled)
|
|
304
|
+
return;
|
|
305
|
+
if (bootstrap?.entitlements)
|
|
306
|
+
setEntitlements(bootstrap.entitlements);
|
|
307
|
+
if (bootstrap?.loading_bg_color)
|
|
308
|
+
setCachedLoadingBgColor(bootstrap.loading_bg_color);
|
|
309
|
+
})();
|
|
310
|
+
return () => {
|
|
311
|
+
cancelled = true;
|
|
312
|
+
};
|
|
313
|
+
}, [defaultLocale, entitlements, locale, setCachedLoadingBgColor]);
|
|
314
|
+
const applySeoBundle = useCallback((bundle, hreflangEnabled) => {
|
|
103
315
|
try {
|
|
104
316
|
const head = document.head;
|
|
105
317
|
if (!head)
|
|
106
318
|
return;
|
|
107
319
|
head.querySelectorAll('link[data-Lovalingo="hreflang"], link[data-Lovalingo="canonical"]').forEach((el) => el.remove());
|
|
108
|
-
if (!
|
|
320
|
+
if (!bundle)
|
|
109
321
|
return;
|
|
110
|
-
const bundle = await apiRef.current.fetchSeoBundle(activeLocale);
|
|
111
322
|
const seo = (bundle?.seo && typeof bundle.seo === "object" ? bundle.seo : {});
|
|
112
323
|
const alternates = (bundle?.alternates && typeof bundle.alternates === "object" ? bundle.alternates : {});
|
|
113
324
|
const setOrCreateMeta = (attrs, content) => {
|
|
@@ -203,66 +414,36 @@ navigateRef, // For path mode routing
|
|
|
203
414
|
head.appendChild(xDefault);
|
|
204
415
|
}
|
|
205
416
|
}
|
|
206
|
-
catch
|
|
207
|
-
|
|
417
|
+
catch {
|
|
418
|
+
// ignore SEO errors
|
|
208
419
|
}
|
|
209
|
-
}, [isSeoActive]);
|
|
210
|
-
// Marker engine: always mark full DOM content for deterministic pipeline extraction.
|
|
211
|
-
useEffect(() => {
|
|
212
|
-
const stop = startMarkerEngine({ throttleMs: 120 });
|
|
213
|
-
return () => stop();
|
|
214
420
|
}, []);
|
|
215
|
-
//
|
|
216
|
-
const detectLocale = useCallback(() => {
|
|
217
|
-
// 1. Check URL first based on routing mode
|
|
218
|
-
if (routing === 'path') {
|
|
219
|
-
// Path mode: language is in path (/en/pricing, /fr/about)
|
|
220
|
-
const pathLocale = window.location.pathname.split('/')[1];
|
|
221
|
-
if (allLocales.includes(pathLocale)) {
|
|
222
|
-
return pathLocale;
|
|
223
|
-
}
|
|
224
|
-
}
|
|
225
|
-
else if (routing === 'query') {
|
|
226
|
-
// Query mode: language is in query param (/pricing?t=fr)
|
|
227
|
-
const params = new URLSearchParams(window.location.search);
|
|
228
|
-
const queryLocale = params.get('t') || params.get('locale');
|
|
229
|
-
if (queryLocale && allLocales.includes(queryLocale)) {
|
|
230
|
-
return queryLocale;
|
|
231
|
-
}
|
|
232
|
-
}
|
|
233
|
-
// 2. Check localStorage (fallback for all routing modes)
|
|
234
|
-
try {
|
|
235
|
-
const storedLocale = localStorage.getItem(LOCALE_STORAGE_KEY);
|
|
236
|
-
if (storedLocale && allLocales.includes(storedLocale)) {
|
|
237
|
-
return storedLocale;
|
|
238
|
-
}
|
|
239
|
-
}
|
|
240
|
-
catch (e) {
|
|
241
|
-
// localStorage might be unavailable (SSR, private browsing)
|
|
242
|
-
warnDebug('localStorage not available:', e);
|
|
243
|
-
}
|
|
244
|
-
// 3. Default locale
|
|
245
|
-
return defaultLocale;
|
|
246
|
-
}, [allLocales, defaultLocale, routing]);
|
|
247
|
-
// Fetch entitlements early so SEO can be enabled even on default locale
|
|
248
|
-
useEffect(() => {
|
|
249
|
-
let cancelled = false;
|
|
250
|
-
(async () => {
|
|
251
|
-
const next = await apiRef.current.fetchEntitlements(detectLocale());
|
|
252
|
-
if (!cancelled && next)
|
|
253
|
-
setEntitlements(next);
|
|
254
|
-
})();
|
|
255
|
-
return () => {
|
|
256
|
-
cancelled = true;
|
|
257
|
-
};
|
|
258
|
-
}, [detectLocale]);
|
|
259
|
-
// Keep <html lang> + canonical/hreflang in sync with routing + entitlements
|
|
421
|
+
// Keep <html lang> in sync and apply default-locale SEO (non-default locales use the bootstrap payload).
|
|
260
422
|
useEffect(() => {
|
|
261
423
|
setDocumentLocale(locale);
|
|
262
|
-
|
|
263
|
-
|
|
264
|
-
|
|
265
|
-
|
|
424
|
+
if (locale !== defaultLocale)
|
|
425
|
+
return;
|
|
426
|
+
if (!isSeoActive())
|
|
427
|
+
return;
|
|
428
|
+
void apiRef.current.fetchSeoBundle(locale).then((bundle) => {
|
|
429
|
+
applySeoBundle(bundle, Boolean(entitlements?.hreflangEnabled));
|
|
430
|
+
});
|
|
431
|
+
}, [applySeoBundle, defaultLocale, entitlements, isSeoActive, locale, setDocumentLocale]);
|
|
432
|
+
const toTranslations = useCallback((map, targetLocale) => {
|
|
433
|
+
const out = [];
|
|
434
|
+
for (const [source_text, translated_text] of Object.entries(map || {})) {
|
|
435
|
+
if (!source_text || !translated_text)
|
|
436
|
+
continue;
|
|
437
|
+
out.push({
|
|
438
|
+
source_text,
|
|
439
|
+
translated_text,
|
|
440
|
+
source_locale: defaultLocale,
|
|
441
|
+
target_locale: targetLocale,
|
|
442
|
+
});
|
|
443
|
+
}
|
|
444
|
+
return out;
|
|
445
|
+
}, [defaultLocale]);
|
|
446
|
+
const loadData = useCallback(async (targetLocale, previousLocale) => {
|
|
266
447
|
// Cancel any pending retry scan to prevent race conditions
|
|
267
448
|
if (retryTimeoutRef.current) {
|
|
268
449
|
clearTimeout(retryTimeoutRef.current);
|
|
@@ -271,23 +452,23 @@ navigateRef, // For path mode routing
|
|
|
271
452
|
// If switching to default locale, clear translations and translate with empty map
|
|
272
453
|
// This will show original text using stored data-Lovalingo-original-html
|
|
273
454
|
if (targetLocale === defaultLocale) {
|
|
274
|
-
|
|
275
|
-
setIsNavigationLoading(false);
|
|
455
|
+
disablePrehide();
|
|
276
456
|
setActiveTranslations(null);
|
|
277
457
|
restoreDom(document.body); // React-safe: only text/attrs, no DOM structure mutation
|
|
278
458
|
isNavigatingRef.current = false;
|
|
279
459
|
return;
|
|
280
460
|
}
|
|
281
|
-
|
|
282
|
-
const
|
|
283
|
-
const cacheKey = `${targetLocale}:${
|
|
461
|
+
const currentPath = window.location.pathname + window.location.search;
|
|
462
|
+
const normalizedPath = processPath(window.location.pathname, enhancedPathConfig);
|
|
463
|
+
const cacheKey = `${targetLocale}:${normalizedPath}`;
|
|
284
464
|
// Check if we have cached translations for this locale + path
|
|
285
465
|
const cachedEntry = translationCacheRef.current.get(cacheKey);
|
|
286
466
|
const cachedExclusions = exclusionsCacheRef.current;
|
|
287
467
|
const cachedDomRules = domRulesCacheRef.current.get(cacheKey);
|
|
288
468
|
if (cachedEntry && cachedExclusions) {
|
|
289
469
|
// CACHE HIT - Use cached data immediately (FAST!)
|
|
290
|
-
logDebug(`[Lovalingo] Using cached translations for ${targetLocale} on ${
|
|
470
|
+
logDebug(`[Lovalingo] Using cached translations for ${targetLocale} on ${normalizedPath}`);
|
|
471
|
+
enablePrehide(getCachedLoadingBgColor());
|
|
291
472
|
setActiveTranslations(cachedEntry.translations);
|
|
292
473
|
setMarkerEngineExclusions(cachedExclusions);
|
|
293
474
|
if (mode === 'dom') {
|
|
@@ -318,49 +499,84 @@ navigateRef, // For path mode routing
|
|
|
318
499
|
applyDomRules(rules);
|
|
319
500
|
}
|
|
320
501
|
}, 500);
|
|
321
|
-
|
|
322
|
-
setTimeout(() => setIsNavigationLoading(false), 50);
|
|
323
|
-
}
|
|
502
|
+
disablePrehide();
|
|
324
503
|
isNavigatingRef.current = false;
|
|
325
504
|
return;
|
|
326
505
|
}
|
|
327
506
|
// CACHE MISS - Fetch from API
|
|
328
|
-
logDebug(`[Lovalingo] Fetching translations for ${targetLocale} on ${
|
|
507
|
+
logDebug(`[Lovalingo] Fetching translations for ${targetLocale} on ${normalizedPath}`);
|
|
329
508
|
setIsLoading(true);
|
|
509
|
+
enablePrehide(getCachedLoadingBgColor());
|
|
330
510
|
try {
|
|
331
511
|
if (previousLocale && previousLocale !== defaultLocale) {
|
|
332
512
|
logDebug(`[Lovalingo] Switching from ${previousLocale} to ${targetLocale}`);
|
|
333
513
|
}
|
|
334
|
-
const
|
|
335
|
-
|
|
336
|
-
|
|
337
|
-
|
|
338
|
-
|
|
339
|
-
|
|
514
|
+
const cachedCritical = readCriticalCache(targetLocale, normalizedPath);
|
|
515
|
+
if (cachedCritical?.loading_bg_color) {
|
|
516
|
+
setCachedLoadingBgColor(cachedCritical.loading_bg_color);
|
|
517
|
+
enablePrehide(cachedCritical.loading_bg_color);
|
|
518
|
+
}
|
|
519
|
+
if (cachedCritical?.exclusions && cachedCritical.exclusions.length > 0) {
|
|
520
|
+
exclusionsCacheRef.current = cachedCritical.exclusions;
|
|
521
|
+
setMarkerEngineExclusions(cachedCritical.exclusions);
|
|
522
|
+
}
|
|
523
|
+
if (cachedCritical?.map && Object.keys(cachedCritical.map).length > 0) {
|
|
524
|
+
setActiveTranslations(toTranslations(cachedCritical.map, targetLocale));
|
|
525
|
+
if (mode === "dom") {
|
|
526
|
+
applyActiveTranslations(document.body);
|
|
527
|
+
}
|
|
528
|
+
disablePrehide();
|
|
529
|
+
}
|
|
530
|
+
const bootstrap = await apiRef.current.fetchBootstrap(targetLocale, currentPath);
|
|
531
|
+
const nextEntitlements = bootstrap?.entitlements || apiRef.current.getEntitlements();
|
|
340
532
|
if (nextEntitlements)
|
|
341
533
|
setEntitlements(nextEntitlements);
|
|
342
|
-
|
|
343
|
-
|
|
344
|
-
|
|
345
|
-
translated_text,
|
|
346
|
-
source_locale: defaultLocale,
|
|
347
|
-
target_locale: targetLocale,
|
|
348
|
-
}))
|
|
349
|
-
: [];
|
|
350
|
-
// Store in cache for next time
|
|
351
|
-
translationCacheRef.current.set(cacheKey, { translations });
|
|
352
|
-
exclusionsCacheRef.current = exclusions;
|
|
353
|
-
if (autoApplyRules) {
|
|
354
|
-
domRulesCacheRef.current.set(cacheKey, domRules);
|
|
534
|
+
if (bootstrap?.loading_bg_color) {
|
|
535
|
+
setCachedLoadingBgColor(bootstrap.loading_bg_color);
|
|
536
|
+
enablePrehide(bootstrap.loading_bg_color);
|
|
355
537
|
}
|
|
356
|
-
|
|
538
|
+
const exclusions = Array.isArray(bootstrap?.exclusions)
|
|
539
|
+
? bootstrap.exclusions
|
|
540
|
+
.map((row) => {
|
|
541
|
+
if (!row || typeof row !== "object")
|
|
542
|
+
return null;
|
|
543
|
+
const r = row;
|
|
544
|
+
const selector = typeof r.selector === "string" ? r.selector.trim() : "";
|
|
545
|
+
const type = typeof r.type === "string" ? r.type.trim() : "";
|
|
546
|
+
if (!selector)
|
|
547
|
+
return null;
|
|
548
|
+
if (type !== "css" && type !== "xpath")
|
|
549
|
+
return null;
|
|
550
|
+
return { selector, type: type };
|
|
551
|
+
})
|
|
552
|
+
.filter(Boolean)
|
|
553
|
+
: await apiRef.current.fetchExclusions();
|
|
554
|
+
exclusionsCacheRef.current = exclusions;
|
|
357
555
|
setMarkerEngineExclusions(exclusions);
|
|
358
|
-
|
|
359
|
-
|
|
556
|
+
const criticalMap = bootstrap?.critical?.map && typeof bootstrap.critical.map === "object" && !Array.isArray(bootstrap.critical.map)
|
|
557
|
+
? bootstrap.critical.map
|
|
558
|
+
: {};
|
|
559
|
+
if (Object.keys(criticalMap).length > 0) {
|
|
560
|
+
setActiveTranslations(toTranslations(criticalMap, targetLocale));
|
|
561
|
+
if (mode === "dom") {
|
|
562
|
+
applyActiveTranslations(document.body);
|
|
563
|
+
}
|
|
360
564
|
}
|
|
361
565
|
if (autoApplyRules) {
|
|
566
|
+
const domRules = Array.isArray(bootstrap?.dom_rules) ? bootstrap.dom_rules : await apiRef.current.fetchDomRules(targetLocale);
|
|
567
|
+
domRulesCacheRef.current.set(cacheKey, domRules);
|
|
362
568
|
applyDomRules(domRules);
|
|
363
569
|
}
|
|
570
|
+
if (isSeoActive() && bootstrap) {
|
|
571
|
+
const hreflangEnabled = Boolean((bootstrap.entitlements || nextEntitlements)?.hreflangEnabled);
|
|
572
|
+
applySeoBundle({ seo: bootstrap.seo, alternates: bootstrap.alternates, jsonld: bootstrap.jsonld }, hreflangEnabled);
|
|
573
|
+
}
|
|
574
|
+
writeCriticalCache(targetLocale, normalizedPath, {
|
|
575
|
+
map: criticalMap,
|
|
576
|
+
exclusions,
|
|
577
|
+
loading_bg_color: bootstrap?.loading_bg_color && /^#[0-9a-fA-F]{6}$/.test(bootstrap.loading_bg_color) ? bootstrap.loading_bg_color : null,
|
|
578
|
+
});
|
|
579
|
+
disablePrehide();
|
|
364
580
|
// Delayed retry scan to catch late-rendering content
|
|
365
581
|
retryTimeoutRef.current = setTimeout(() => {
|
|
366
582
|
// Don't scan if we're navigating (prevents React conflicts)
|
|
@@ -372,24 +588,48 @@ navigateRef, // For path mode routing
|
|
|
372
588
|
applyActiveTranslations(document.body);
|
|
373
589
|
}
|
|
374
590
|
if (autoApplyRules) {
|
|
375
|
-
const rules = domRulesCacheRef.current.get(cacheKey) ||
|
|
591
|
+
const rules = domRulesCacheRef.current.get(cacheKey) || [];
|
|
376
592
|
applyDomRules(rules);
|
|
377
593
|
}
|
|
378
594
|
}, 500);
|
|
379
|
-
|
|
380
|
-
|
|
381
|
-
|
|
595
|
+
// Lazy-load the full page bundle after first paint.
|
|
596
|
+
void (async () => {
|
|
597
|
+
const bundle = await apiRef.current.fetchBundle(targetLocale, currentPath);
|
|
598
|
+
if (!bundle || !bundle.map)
|
|
599
|
+
return;
|
|
600
|
+
const translations = toTranslations(bundle.map, targetLocale);
|
|
601
|
+
if (translations.length === 0)
|
|
602
|
+
return;
|
|
603
|
+
translationCacheRef.current.set(cacheKey, { translations });
|
|
604
|
+
setActiveTranslations(translations);
|
|
605
|
+
if (mode === "dom") {
|
|
606
|
+
applyActiveTranslations(document.body);
|
|
607
|
+
}
|
|
608
|
+
})();
|
|
382
609
|
}
|
|
383
610
|
catch (error) {
|
|
384
611
|
errorDebug('Error loading translations:', error);
|
|
385
|
-
|
|
386
|
-
setIsNavigationLoading(false);
|
|
612
|
+
disablePrehide();
|
|
387
613
|
}
|
|
388
614
|
finally {
|
|
389
615
|
setIsLoading(false);
|
|
390
616
|
isNavigatingRef.current = false;
|
|
391
617
|
}
|
|
392
|
-
}, [
|
|
618
|
+
}, [
|
|
619
|
+
applySeoBundle,
|
|
620
|
+
autoApplyRules,
|
|
621
|
+
defaultLocale,
|
|
622
|
+
disablePrehide,
|
|
623
|
+
enablePrehide,
|
|
624
|
+
enhancedPathConfig,
|
|
625
|
+
getCachedLoadingBgColor,
|
|
626
|
+
isSeoActive,
|
|
627
|
+
mode,
|
|
628
|
+
readCriticalCache,
|
|
629
|
+
setCachedLoadingBgColor,
|
|
630
|
+
toTranslations,
|
|
631
|
+
writeCriticalCache,
|
|
632
|
+
]);
|
|
393
633
|
// SPA router hook-in: track History API navigations (React Router/Next/etc) without app changes.
|
|
394
634
|
useEffect(() => {
|
|
395
635
|
const historyObj = window.history;
|
|
@@ -405,7 +645,7 @@ navigateRef, // For path mode routing
|
|
|
405
645
|
const nextLocale = detectLocale();
|
|
406
646
|
if (nextLocale !== locale) {
|
|
407
647
|
setLocaleState(nextLocale);
|
|
408
|
-
void loadData(nextLocale, locale
|
|
648
|
+
void loadData(nextLocale, locale);
|
|
409
649
|
}
|
|
410
650
|
else if (mode === "dom" && nextLocale !== defaultLocale) {
|
|
411
651
|
applyActiveTranslations(document.body);
|
|
@@ -444,10 +684,6 @@ navigateRef, // For path mode routing
|
|
|
444
684
|
warnDebug('Failed to save locale to localStorage:', e);
|
|
445
685
|
}
|
|
446
686
|
isInternalNavigationRef.current = true;
|
|
447
|
-
// Show navigation overlay immediately (only when a non-default locale is involved)
|
|
448
|
-
if (locale !== defaultLocale || newLocale !== defaultLocale) {
|
|
449
|
-
setIsNavigationLoading(true);
|
|
450
|
-
}
|
|
451
687
|
// Prevent MutationObserver work during the switch to avoid React conflicts
|
|
452
688
|
isNavigatingRef.current = true;
|
|
453
689
|
// Update URL based on routing strategy
|
|
@@ -480,24 +716,17 @@ navigateRef, // For path mode routing
|
|
|
480
716
|
window.history.pushState({}, '', url.toString());
|
|
481
717
|
}
|
|
482
718
|
setLocaleState(newLocale);
|
|
483
|
-
|
|
484
|
-
await loadData(newLocale, previousLocale, true);
|
|
719
|
+
await loadData(newLocale, previousLocale);
|
|
485
720
|
})().finally(() => {
|
|
486
721
|
isInternalNavigationRef.current = false;
|
|
487
722
|
});
|
|
488
|
-
}, [allLocales, locale, routing, loadData,
|
|
723
|
+
}, [allLocales, locale, routing, loadData, navigateRef]);
|
|
489
724
|
// No string-level "misses" from the client: the pipeline (Browser Rendering) is source-of-truth for string discovery.
|
|
490
725
|
// Initialize
|
|
491
726
|
useEffect(() => {
|
|
492
727
|
const initialLocale = detectLocale();
|
|
493
|
-
setLocaleState(initialLocale);
|
|
494
728
|
// Track initial page (fallback discovery for pages not present in the routes feed).
|
|
495
729
|
apiRef.current.trackPageview(window.location.pathname + window.location.search);
|
|
496
|
-
// Fetch tier/entitlements early (so the badge can render even on default locale)
|
|
497
|
-
apiRef.current.fetchEntitlements(initialLocale).then((next) => {
|
|
498
|
-
if (next)
|
|
499
|
-
setEntitlements(next);
|
|
500
|
-
});
|
|
501
730
|
// Always prefetch artifacts for the initial locale (pipeline-produced translations + rules).
|
|
502
731
|
loadData(initialLocale);
|
|
503
732
|
// Set up keyboard shortcut for edit mode
|
|
@@ -541,80 +770,6 @@ navigateRef, // For path mode routing
|
|
|
541
770
|
};
|
|
542
771
|
}
|
|
543
772
|
}, [sitemap, resolvedApiKey, apiBase, isSeoActive]);
|
|
544
|
-
// Watch for route changes (browser back/forward + SPA navigation)
|
|
545
|
-
useEffect(() => {
|
|
546
|
-
const handlePopState = () => {
|
|
547
|
-
if (isInternalNavigationRef.current)
|
|
548
|
-
return;
|
|
549
|
-
apiRef.current.trackPageview(window.location.pathname + window.location.search);
|
|
550
|
-
const newLocale = detectLocale();
|
|
551
|
-
const previousLocale = locale;
|
|
552
|
-
// Show navigation overlay immediately
|
|
553
|
-
if (locale !== defaultLocale || newLocale !== defaultLocale) {
|
|
554
|
-
setIsNavigationLoading(true);
|
|
555
|
-
}
|
|
556
|
-
// SET NAVIGATION FLAG (MutationObserver callback will ignore while navigating)
|
|
557
|
-
isNavigatingRef.current = true;
|
|
558
|
-
if (newLocale !== locale) {
|
|
559
|
-
setLocaleState(newLocale);
|
|
560
|
-
// Load translations immediately (no delay needed with overlay)
|
|
561
|
-
loadData(newLocale, previousLocale, true);
|
|
562
|
-
}
|
|
563
|
-
else if (locale !== defaultLocale) {
|
|
564
|
-
// Same locale but NEW PATH - fetch translations for this path
|
|
565
|
-
// Load translations immediately (no delay needed with overlay)
|
|
566
|
-
loadData(locale, previousLocale, true);
|
|
567
|
-
}
|
|
568
|
-
else {
|
|
569
|
-
// Going back to default locale on same path - hide overlay
|
|
570
|
-
setIsNavigationLoading(false);
|
|
571
|
-
}
|
|
572
|
-
};
|
|
573
|
-
// Patch history.pushState and replaceState to detect SPA navigation
|
|
574
|
-
const originalPushState = history.pushState;
|
|
575
|
-
const originalReplaceState = history.replaceState;
|
|
576
|
-
const handleNavigation = () => {
|
|
577
|
-
if (isInternalNavigationRef.current)
|
|
578
|
-
return;
|
|
579
|
-
apiRef.current.trackPageview(window.location.pathname + window.location.search);
|
|
580
|
-
const newLocale = detectLocale();
|
|
581
|
-
const previousLocale = locale;
|
|
582
|
-
// Show navigation overlay immediately
|
|
583
|
-
if (locale !== defaultLocale || newLocale !== defaultLocale) {
|
|
584
|
-
setIsNavigationLoading(true);
|
|
585
|
-
}
|
|
586
|
-
// SET NAVIGATION FLAG (MutationObserver callback will ignore while navigating)
|
|
587
|
-
isNavigatingRef.current = true;
|
|
588
|
-
if (newLocale !== locale) {
|
|
589
|
-
setLocaleState(newLocale);
|
|
590
|
-
// Load translations immediately (no delay needed with overlay)
|
|
591
|
-
loadData(newLocale, previousLocale, true);
|
|
592
|
-
}
|
|
593
|
-
else if (locale !== defaultLocale) {
|
|
594
|
-
// Same locale but NEW PATH - fetch translations for this path
|
|
595
|
-
// Load translations immediately (no delay needed with overlay)
|
|
596
|
-
loadData(locale, previousLocale, true);
|
|
597
|
-
}
|
|
598
|
-
else {
|
|
599
|
-
// Navigating on default locale - hide overlay
|
|
600
|
-
setIsNavigationLoading(false);
|
|
601
|
-
}
|
|
602
|
-
};
|
|
603
|
-
history.pushState = function (...args) {
|
|
604
|
-
originalPushState.apply(history, args);
|
|
605
|
-
handleNavigation();
|
|
606
|
-
};
|
|
607
|
-
history.replaceState = function (...args) {
|
|
608
|
-
originalReplaceState.apply(history, args);
|
|
609
|
-
handleNavigation();
|
|
610
|
-
};
|
|
611
|
-
window.addEventListener('popstate', handlePopState);
|
|
612
|
-
return () => {
|
|
613
|
-
window.removeEventListener('popstate', handlePopState);
|
|
614
|
-
history.pushState = originalPushState;
|
|
615
|
-
history.replaceState = originalReplaceState;
|
|
616
|
-
};
|
|
617
|
-
}, [locale, detectLocale, loadData, defaultLocale]);
|
|
618
773
|
// PATH mode: auto-prefix internal links that are missing a locale segment.
|
|
619
774
|
// This prevents "losing" the current locale when the app renders absolute links like "/projects/slug"
|
|
620
775
|
// while the user is on "/de/...".
|
|
@@ -766,6 +921,83 @@ navigateRef, // For path mode routing
|
|
|
766
921
|
document.removeEventListener('click', onClickCapture, true);
|
|
767
922
|
};
|
|
768
923
|
}, [routing, autoPrefixLinks, allLocales, locale, navigateRef]);
|
|
924
|
+
// Navigation prefetch: warm the HTTP cache for bootstrap + page bundle before the user clicks (reduces EN→FR flash on SPA route changes).
|
|
925
|
+
useEffect(() => {
|
|
926
|
+
if (!resolvedApiKey)
|
|
927
|
+
return;
|
|
928
|
+
if (typeof window === "undefined" || typeof document === "undefined")
|
|
929
|
+
return;
|
|
930
|
+
const connection = navigator?.connection;
|
|
931
|
+
if (connection?.saveData)
|
|
932
|
+
return;
|
|
933
|
+
if (typeof connection?.effectiveType === "string" && /(^|-)2g$/.test(connection.effectiveType))
|
|
934
|
+
return;
|
|
935
|
+
const prefetched = new Set();
|
|
936
|
+
// Why: cap speculative requests to avoid flooding the network on pages with many links.
|
|
937
|
+
const maxPrefetch = 40;
|
|
938
|
+
const isAssetPath = (pathname) => {
|
|
939
|
+
if (pathname === "/robots.txt" || pathname === "/sitemap.xml")
|
|
940
|
+
return true;
|
|
941
|
+
if (pathname.startsWith("/.well-known/"))
|
|
942
|
+
return true;
|
|
943
|
+
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);
|
|
944
|
+
};
|
|
945
|
+
const pickLocaleForUrl = (url) => {
|
|
946
|
+
if (routing === "path") {
|
|
947
|
+
const segment = url.pathname.split("/")[1] || "";
|
|
948
|
+
if (segment && allLocales.includes(segment))
|
|
949
|
+
return segment;
|
|
950
|
+
return locale;
|
|
951
|
+
}
|
|
952
|
+
const q = url.searchParams.get("t") || url.searchParams.get("locale");
|
|
953
|
+
if (q && allLocales.includes(q))
|
|
954
|
+
return q;
|
|
955
|
+
return locale;
|
|
956
|
+
};
|
|
957
|
+
const onIntent = (event) => {
|
|
958
|
+
if (prefetched.size >= maxPrefetch)
|
|
959
|
+
return;
|
|
960
|
+
const target = event.target;
|
|
961
|
+
const anchor = target?.closest?.("a[href]");
|
|
962
|
+
if (!anchor)
|
|
963
|
+
return;
|
|
964
|
+
const href = anchor.getAttribute("href") || "";
|
|
965
|
+
if (!href || /^(?:#|mailto:|tel:|sms:|javascript:)/i.test(href))
|
|
966
|
+
return;
|
|
967
|
+
let url;
|
|
968
|
+
try {
|
|
969
|
+
url = new URL(href, window.location.origin);
|
|
970
|
+
}
|
|
971
|
+
catch {
|
|
972
|
+
return;
|
|
973
|
+
}
|
|
974
|
+
if (url.origin !== window.location.origin)
|
|
975
|
+
return;
|
|
976
|
+
if (isAssetPath(url.pathname))
|
|
977
|
+
return;
|
|
978
|
+
const targetLocale = pickLocaleForUrl(url);
|
|
979
|
+
if (!targetLocale || targetLocale === defaultLocale)
|
|
980
|
+
return;
|
|
981
|
+
const normalizedPath = processPath(url.pathname, enhancedPathConfig);
|
|
982
|
+
const key = `${targetLocale}:${normalizedPath}`;
|
|
983
|
+
if (prefetched.has(key))
|
|
984
|
+
return;
|
|
985
|
+
prefetched.add(key);
|
|
986
|
+
const pathParam = `${url.pathname}${url.search}`;
|
|
987
|
+
const bootstrapUrl = `${apiBase}/functions/v1/bootstrap?key=${encodeURIComponent(resolvedApiKey)}&locale=${encodeURIComponent(targetLocale)}&path=${encodeURIComponent(pathParam)}`;
|
|
988
|
+
const bundleUrl = `${apiBase}/functions/v1/bundle?key=${encodeURIComponent(resolvedApiKey)}&locale=${encodeURIComponent(targetLocale)}&path=${encodeURIComponent(pathParam)}`;
|
|
989
|
+
void fetch(bootstrapUrl, { cache: "force-cache" }).catch(() => undefined);
|
|
990
|
+
void fetch(bundleUrl, { cache: "force-cache" }).catch(() => undefined);
|
|
991
|
+
};
|
|
992
|
+
document.addEventListener("pointerover", onIntent, { passive: true });
|
|
993
|
+
document.addEventListener("touchstart", onIntent, { passive: true });
|
|
994
|
+
document.addEventListener("focusin", onIntent);
|
|
995
|
+
return () => {
|
|
996
|
+
document.removeEventListener("pointerover", onIntent);
|
|
997
|
+
document.removeEventListener("touchstart", onIntent);
|
|
998
|
+
document.removeEventListener("focusin", onIntent);
|
|
999
|
+
};
|
|
1000
|
+
}, [allLocales, apiBase, defaultLocale, enhancedPathConfig, locale, resolvedApiKey, routing]);
|
|
769
1001
|
// Dynamic DOM updates are handled by the marker engine observer (React-safe in-place text/attr updates).
|
|
770
1002
|
// No periodic string-miss reporting. Page discovery is tracked via pageview only.
|
|
771
1003
|
const translateElement = useCallback((element) => {
|
|
@@ -802,6 +1034,5 @@ navigateRef, // For path mode routing
|
|
|
802
1034
|
children,
|
|
803
1035
|
React.createElement(LanguageSwitcher, { locales: allLocales, currentLocale: locale, onLocaleChange: setLocale, position: switcherPosition, offsetY: switcherOffsetY, theme: switcherTheme, branding: entitlements?.brandingRequired
|
|
804
1036
|
? { required: true, href: "https://lovalingo.com" }
|
|
805
|
-
: undefined })
|
|
806
|
-
React.createElement(NavigationOverlay, { isVisible: isNavigationLoading })));
|
|
1037
|
+
: undefined })));
|
|
807
1038
|
};
|
package/dist/utils/api.d.ts
CHANGED
|
@@ -21,6 +21,34 @@ export type SeoBundleResponse = {
|
|
|
21
21
|
seoEnabled?: boolean;
|
|
22
22
|
entitlements?: ProjectEntitlements;
|
|
23
23
|
};
|
|
24
|
+
export type BootstrapResponse = {
|
|
25
|
+
locale?: string;
|
|
26
|
+
normalized_path?: string;
|
|
27
|
+
routing_strategy?: string;
|
|
28
|
+
loading_bg_color?: string | null;
|
|
29
|
+
seoEnabled?: boolean;
|
|
30
|
+
entitlements?: ProjectEntitlements;
|
|
31
|
+
alternates?: {
|
|
32
|
+
canonical?: string;
|
|
33
|
+
xDefault?: string;
|
|
34
|
+
languages?: Record<string, string>;
|
|
35
|
+
} | null;
|
|
36
|
+
seo?: Record<string, unknown>;
|
|
37
|
+
jsonld?: Array<{
|
|
38
|
+
type: string;
|
|
39
|
+
json: unknown;
|
|
40
|
+
hash?: string;
|
|
41
|
+
}>;
|
|
42
|
+
dom_rules?: DomRule[];
|
|
43
|
+
exclusions?: unknown[];
|
|
44
|
+
critical?: {
|
|
45
|
+
map?: Record<string, string>;
|
|
46
|
+
keys?: number;
|
|
47
|
+
viewport?: unknown;
|
|
48
|
+
etag?: string;
|
|
49
|
+
};
|
|
50
|
+
etag?: string;
|
|
51
|
+
};
|
|
24
52
|
export declare class LovalingoAPI {
|
|
25
53
|
private apiKey;
|
|
26
54
|
private apiBase;
|
|
@@ -28,6 +56,7 @@ export declare class LovalingoAPI {
|
|
|
28
56
|
private entitlements;
|
|
29
57
|
constructor(apiKey: string, apiBase: string, pathConfig?: PathNormalizationConfig);
|
|
30
58
|
private hasApiKey;
|
|
59
|
+
private buildPathParam;
|
|
31
60
|
private warnMissingApiKey;
|
|
32
61
|
private logActivationRequired;
|
|
33
62
|
private isActivationRequiredPayload;
|
|
@@ -37,10 +66,11 @@ export declare class LovalingoAPI {
|
|
|
37
66
|
fetchSeoBundle(localeHint: string): Promise<SeoBundleResponse | null>;
|
|
38
67
|
trackPageview(pathOrUrl: string): Promise<void>;
|
|
39
68
|
fetchTranslations(sourceLocale: string, targetLocale: string): Promise<Translation[]>;
|
|
40
|
-
fetchBundle(localeHint: string): Promise<{
|
|
69
|
+
fetchBundle(localeHint: string, pathOrUrl?: string): Promise<{
|
|
41
70
|
map: Record<string, string>;
|
|
42
71
|
hashMap: Record<string, string>;
|
|
43
72
|
} | null>;
|
|
73
|
+
fetchBootstrap(localeHint: string, pathOrUrl?: string): Promise<BootstrapResponse | null>;
|
|
44
74
|
fetchExclusions(): Promise<Exclusion[]>;
|
|
45
75
|
fetchDomRules(targetLocale: string): Promise<DomRule[]>;
|
|
46
76
|
saveExclusion(selector: string, type: 'css' | 'xpath'): Promise<void>;
|
package/dist/utils/api.js
CHANGED
|
@@ -1,4 +1,3 @@
|
|
|
1
|
-
import { processPath } from './pathNormalizer';
|
|
2
1
|
import { warnDebug, errorDebug } from './logger';
|
|
3
2
|
export class LovalingoAPI {
|
|
4
3
|
constructor(apiKey, apiBase, pathConfig) {
|
|
@@ -10,6 +9,23 @@ export class LovalingoAPI {
|
|
|
10
9
|
hasApiKey() {
|
|
11
10
|
return typeof this.apiKey === 'string' && this.apiKey.trim().length > 0;
|
|
12
11
|
}
|
|
12
|
+
buildPathParam(pathOrUrl) {
|
|
13
|
+
if (typeof window === "undefined")
|
|
14
|
+
return "/";
|
|
15
|
+
const input = (pathOrUrl || "").toString().trim();
|
|
16
|
+
if (!input)
|
|
17
|
+
return window.location.pathname + window.location.search;
|
|
18
|
+
try {
|
|
19
|
+
if (/^https?:\/\//i.test(input)) {
|
|
20
|
+
const url = new URL(input);
|
|
21
|
+
return url.pathname + url.search;
|
|
22
|
+
}
|
|
23
|
+
}
|
|
24
|
+
catch {
|
|
25
|
+
// ignore invalid URL strings
|
|
26
|
+
}
|
|
27
|
+
return input;
|
|
28
|
+
}
|
|
13
29
|
warnMissingApiKey(action) {
|
|
14
30
|
// Avoid hard-crashing apps; make the failure mode obvious.
|
|
15
31
|
warnDebug(`[Lovalingo] Missing public project key: ${action} was skipped. Pass publicAnonKey to <LovalingoProvider ...> (or set VITE_LOVALINGO_PUBLIC_ANON_KEY).`);
|
|
@@ -44,8 +60,8 @@ export class LovalingoAPI {
|
|
|
44
60
|
this.warnMissingApiKey('fetchEntitlements');
|
|
45
61
|
return null;
|
|
46
62
|
}
|
|
47
|
-
const
|
|
48
|
-
const response = await fetch(`${this.apiBase}/functions/v1/bundle?key=${this.apiKey}&locale=${localeHint}&path=${
|
|
63
|
+
const pathParam = this.buildPathParam();
|
|
64
|
+
const response = await fetch(`${this.apiBase}/functions/v1/bundle?key=${this.apiKey}&locale=${encodeURIComponent(localeHint)}&path=${encodeURIComponent(pathParam)}`);
|
|
49
65
|
if (this.isActivationRequiredResponse(response)) {
|
|
50
66
|
this.logActivationRequired('fetchEntitlements', response);
|
|
51
67
|
return null;
|
|
@@ -76,17 +92,23 @@ export class LovalingoAPI {
|
|
|
76
92
|
this.warnMissingApiKey("fetchSeoBundle");
|
|
77
93
|
return null;
|
|
78
94
|
}
|
|
79
|
-
const
|
|
80
|
-
const
|
|
95
|
+
const pathParam = this.buildPathParam();
|
|
96
|
+
const requestUrl = `${this.apiBase}/functions/v1/seo-bundle?key=${this.apiKey}&locale=${encodeURIComponent(localeHint)}&path=${encodeURIComponent(pathParam)}`;
|
|
97
|
+
const response = await fetch(requestUrl);
|
|
81
98
|
if (this.isActivationRequiredResponse(response)) {
|
|
82
99
|
this.logActivationRequired("fetchSeoBundle", response);
|
|
83
100
|
return null;
|
|
84
101
|
}
|
|
85
|
-
|
|
102
|
+
const resolvedResponse = response.status === 304 ? await fetch(requestUrl, { cache: "force-cache" }) : response;
|
|
103
|
+
if (resolvedResponse !== response && this.isActivationRequiredResponse(resolvedResponse)) {
|
|
104
|
+
this.logActivationRequired("fetchSeoBundle", resolvedResponse);
|
|
86
105
|
return null;
|
|
87
|
-
|
|
88
|
-
if (
|
|
89
|
-
|
|
106
|
+
}
|
|
107
|
+
if (!resolvedResponse.ok)
|
|
108
|
+
return null;
|
|
109
|
+
const data = (await resolvedResponse.json());
|
|
110
|
+
if (this.isActivationRequiredResponse(resolvedResponse, data)) {
|
|
111
|
+
this.logActivationRequired("fetchSeoBundle", resolvedResponse);
|
|
90
112
|
return null;
|
|
91
113
|
}
|
|
92
114
|
return (data || null);
|
|
@@ -99,11 +121,7 @@ export class LovalingoAPI {
|
|
|
99
121
|
try {
|
|
100
122
|
if (!this.hasApiKey())
|
|
101
123
|
return;
|
|
102
|
-
const response = await fetch(`${this.apiBase}/functions/v1/pageview`, {
|
|
103
|
-
method: "POST",
|
|
104
|
-
headers: { "Content-Type": "application/json" },
|
|
105
|
-
body: JSON.stringify({ key: this.apiKey, path: pathOrUrl }),
|
|
106
|
-
});
|
|
124
|
+
const response = await fetch(`${this.apiBase}/functions/v1/pageview?key=${encodeURIComponent(this.apiKey)}&path=${encodeURIComponent(pathOrUrl)}`, { method: "GET", keepalive: true });
|
|
107
125
|
if (response.status === 403) {
|
|
108
126
|
// Tracking should never block app behavior; keep logging consistent.
|
|
109
127
|
this.logActivationRequired("trackPageview", response);
|
|
@@ -134,23 +152,29 @@ export class LovalingoAPI {
|
|
|
134
152
|
return [];
|
|
135
153
|
}
|
|
136
154
|
}
|
|
137
|
-
async fetchBundle(localeHint) {
|
|
155
|
+
async fetchBundle(localeHint, pathOrUrl) {
|
|
138
156
|
try {
|
|
139
157
|
if (!this.hasApiKey()) {
|
|
140
158
|
this.warnMissingApiKey("fetchBundle");
|
|
141
159
|
return null;
|
|
142
160
|
}
|
|
143
|
-
const
|
|
144
|
-
const
|
|
161
|
+
const pathParam = this.buildPathParam(pathOrUrl);
|
|
162
|
+
const requestUrl = `${this.apiBase}/functions/v1/bundle?key=${this.apiKey}&locale=${encodeURIComponent(localeHint)}&path=${encodeURIComponent(pathParam)}&scoped=1`;
|
|
163
|
+
const response = await fetch(requestUrl);
|
|
145
164
|
if (this.isActivationRequiredResponse(response)) {
|
|
146
165
|
this.logActivationRequired("fetchBundle", response);
|
|
147
166
|
return null;
|
|
148
167
|
}
|
|
149
|
-
|
|
168
|
+
const resolvedResponse = response.status === 304 ? await fetch(requestUrl, { cache: "force-cache" }) : response;
|
|
169
|
+
if (resolvedResponse !== response && this.isActivationRequiredResponse(resolvedResponse)) {
|
|
170
|
+
this.logActivationRequired("fetchBundle", resolvedResponse);
|
|
150
171
|
return null;
|
|
151
|
-
|
|
152
|
-
if (
|
|
153
|
-
|
|
172
|
+
}
|
|
173
|
+
if (!resolvedResponse.ok)
|
|
174
|
+
return null;
|
|
175
|
+
const data = await resolvedResponse.json();
|
|
176
|
+
if (this.isActivationRequiredResponse(resolvedResponse, data)) {
|
|
177
|
+
this.logActivationRequired("fetchBundle", resolvedResponse);
|
|
154
178
|
return null;
|
|
155
179
|
}
|
|
156
180
|
if (data?.entitlements) {
|
|
@@ -167,6 +191,37 @@ export class LovalingoAPI {
|
|
|
167
191
|
return null;
|
|
168
192
|
}
|
|
169
193
|
}
|
|
194
|
+
async fetchBootstrap(localeHint, pathOrUrl) {
|
|
195
|
+
try {
|
|
196
|
+
if (!this.hasApiKey()) {
|
|
197
|
+
this.warnMissingApiKey("fetchBootstrap");
|
|
198
|
+
return null;
|
|
199
|
+
}
|
|
200
|
+
const pathParam = this.buildPathParam(pathOrUrl);
|
|
201
|
+
const requestUrl = `${this.apiBase}/functions/v1/bootstrap?key=${this.apiKey}&locale=${encodeURIComponent(localeHint)}&path=${encodeURIComponent(pathParam)}`;
|
|
202
|
+
const response = await fetch(requestUrl);
|
|
203
|
+
if (this.isActivationRequiredResponse(response)) {
|
|
204
|
+
this.logActivationRequired("fetchBootstrap", response);
|
|
205
|
+
return null;
|
|
206
|
+
}
|
|
207
|
+
const resolvedResponse = response.status === 304 ? await fetch(requestUrl, { cache: "force-cache" }) : response;
|
|
208
|
+
if (resolvedResponse !== response && this.isActivationRequiredResponse(resolvedResponse)) {
|
|
209
|
+
this.logActivationRequired("fetchBootstrap", resolvedResponse);
|
|
210
|
+
return null;
|
|
211
|
+
}
|
|
212
|
+
if (!resolvedResponse.ok)
|
|
213
|
+
return null;
|
|
214
|
+
const data = (await resolvedResponse.json());
|
|
215
|
+
if (this.isActivationRequiredResponse(resolvedResponse, data)) {
|
|
216
|
+
this.logActivationRequired("fetchBootstrap", resolvedResponse);
|
|
217
|
+
return null;
|
|
218
|
+
}
|
|
219
|
+
return (data || null);
|
|
220
|
+
}
|
|
221
|
+
catch {
|
|
222
|
+
return null;
|
|
223
|
+
}
|
|
224
|
+
}
|
|
170
225
|
async fetchExclusions() {
|
|
171
226
|
try {
|
|
172
227
|
if (!this.hasApiKey()) {
|
|
@@ -181,8 +236,28 @@ export class LovalingoAPI {
|
|
|
181
236
|
if (!response.ok)
|
|
182
237
|
throw new Error('Failed to fetch exclusions');
|
|
183
238
|
const data = await response.json();
|
|
184
|
-
// Handle
|
|
185
|
-
|
|
239
|
+
// Handle legacy and vNext row shapes.
|
|
240
|
+
const rows = Array.isArray(data.exclusions) ? data.exclusions : [];
|
|
241
|
+
const out = [];
|
|
242
|
+
for (const row of rows) {
|
|
243
|
+
if (!row || typeof row !== "object")
|
|
244
|
+
continue;
|
|
245
|
+
const record = row;
|
|
246
|
+
const selector = (typeof record.selector === "string" ? record.selector : "") ||
|
|
247
|
+
(typeof record.selector_value === "string" ? record.selector_value : "") ||
|
|
248
|
+
(typeof record.selectorValue === "string" ? record.selectorValue : "");
|
|
249
|
+
const type = (typeof record.type === "string" ? record.type : "") ||
|
|
250
|
+
(typeof record.selector_type === "string" ? record.selector_type : "") ||
|
|
251
|
+
(typeof record.selectorType === "string" ? record.selectorType : "");
|
|
252
|
+
const trimmedSelector = selector.trim();
|
|
253
|
+
const trimmedType = type.trim();
|
|
254
|
+
if (!trimmedSelector)
|
|
255
|
+
continue;
|
|
256
|
+
if (trimmedType !== "css" && trimmedType !== "xpath")
|
|
257
|
+
continue;
|
|
258
|
+
out.push({ selector: trimmedSelector, type: trimmedType });
|
|
259
|
+
}
|
|
260
|
+
return out;
|
|
186
261
|
}
|
|
187
262
|
catch (error) {
|
|
188
263
|
errorDebug('Error fetching exclusions:', error);
|
|
@@ -195,8 +270,8 @@ export class LovalingoAPI {
|
|
|
195
270
|
this.warnMissingApiKey('fetchDomRules');
|
|
196
271
|
return [];
|
|
197
272
|
}
|
|
198
|
-
const
|
|
199
|
-
const response = await fetch(`${this.apiBase}/functions/v1/dom-rules?key=${this.apiKey}&locale=${targetLocale}&path=${
|
|
273
|
+
const pathParam = this.buildPathParam();
|
|
274
|
+
const response = await fetch(`${this.apiBase}/functions/v1/dom-rules?key=${this.apiKey}&locale=${encodeURIComponent(targetLocale)}&path=${encodeURIComponent(pathParam)}`);
|
|
200
275
|
if (this.isActivationRequiredResponse(response)) {
|
|
201
276
|
this.logActivationRequired('fetchDomRules', response);
|
|
202
277
|
return [];
|
|
@@ -32,6 +32,11 @@ export type DomScanResult = {
|
|
|
32
32
|
stats: MarkerStats;
|
|
33
33
|
segments: DomScanSegment[];
|
|
34
34
|
occurrences: DomScanOccurrence[];
|
|
35
|
+
critical_occurrences?: DomScanOccurrence[];
|
|
36
|
+
viewport?: {
|
|
37
|
+
width: number;
|
|
38
|
+
height: number;
|
|
39
|
+
};
|
|
35
40
|
truncated: boolean;
|
|
36
41
|
};
|
|
37
42
|
export declare function startMarkerEngine(options?: MarkerEngineOptions): typeof stopMarkerEngine;
|
|
@@ -1,5 +1,8 @@
|
|
|
1
1
|
import { hashContent } from "./hash";
|
|
2
|
+
// Why: keep marker scans cheap while still capturing a small above-the-fold "critical slice" for first paint.
|
|
2
3
|
const DEFAULT_THROTTLE_MS = 150;
|
|
4
|
+
const DEFAULT_CRITICAL_BUFFER_PX = 200;
|
|
5
|
+
const DEFAULT_CRITICAL_MAX = 800;
|
|
3
6
|
const EXCLUDE_SELECTOR = "[data-lovalingo-exclude],[data-notranslate],[translate-no],[data-no-translate]";
|
|
4
7
|
const UNSAFE_CONTAINER_TAGS = new Set(["script", "style", "noscript", "template", "svg", "canvas"]);
|
|
5
8
|
const ATTRIBUTE_MARKS = [
|
|
@@ -46,7 +49,7 @@ function setGlobalStats(stats) {
|
|
|
46
49
|
if (!g.__lovalingo.dom)
|
|
47
50
|
g.__lovalingo.dom = {};
|
|
48
51
|
g.__lovalingo.dom.getStats = () => lastStats;
|
|
49
|
-
g.__lovalingo.dom.scan = () => scanDom({ maxSegments: 20000 });
|
|
52
|
+
g.__lovalingo.dom.scan = () => scanDom({ maxSegments: 20000, includeCritical: true });
|
|
50
53
|
g.__lovalingo.dom.apply = (bundle) => ({ applied: applyTranslationMap(bundle, document.body) });
|
|
51
54
|
g.__lovalingo.dom.restore = () => restoreDom(document.body);
|
|
52
55
|
}
|
|
@@ -172,7 +175,34 @@ function getOrInitAttrOriginal(el, attr) {
|
|
|
172
175
|
map.set(attr, value);
|
|
173
176
|
return value;
|
|
174
177
|
}
|
|
175
|
-
function
|
|
178
|
+
function isInViewport(rect, viewportHeight, bufferPx) {
|
|
179
|
+
if (!rect)
|
|
180
|
+
return false;
|
|
181
|
+
if (!Number.isFinite(rect.top) || !Number.isFinite(rect.bottom))
|
|
182
|
+
return false;
|
|
183
|
+
if (rect.width <= 0 || rect.height <= 0)
|
|
184
|
+
return false;
|
|
185
|
+
return rect.bottom > -bufferPx && rect.top < viewportHeight + bufferPx;
|
|
186
|
+
}
|
|
187
|
+
function getTextNodeRect(node) {
|
|
188
|
+
try {
|
|
189
|
+
const range = document.createRange();
|
|
190
|
+
range.selectNodeContents(node);
|
|
191
|
+
const rect = range.getBoundingClientRect();
|
|
192
|
+
if (rect && rect.width > 0 && rect.height > 0)
|
|
193
|
+
return rect;
|
|
194
|
+
}
|
|
195
|
+
catch {
|
|
196
|
+
// ignore
|
|
197
|
+
}
|
|
198
|
+
try {
|
|
199
|
+
return node.parentElement ? node.parentElement.getBoundingClientRect() : null;
|
|
200
|
+
}
|
|
201
|
+
catch {
|
|
202
|
+
return null;
|
|
203
|
+
}
|
|
204
|
+
}
|
|
205
|
+
function considerTextNode(node, stats, segments, occurrences, seen, maxSegments, critical) {
|
|
176
206
|
const raw = node.nodeValue || "";
|
|
177
207
|
if (!raw)
|
|
178
208
|
return;
|
|
@@ -217,9 +247,16 @@ function considerTextNode(node, stats, segments, occurrences, seen, maxSegments)
|
|
|
217
247
|
seen.add(originalText);
|
|
218
248
|
occurrences.push({ source_text: originalText, semantic_context: "text" });
|
|
219
249
|
}
|
|
250
|
+
if (critical?.enabled && originalText && !critical.seen.has(originalText)) {
|
|
251
|
+
const rect = getTextNodeRect(node);
|
|
252
|
+
if (isInViewport(rect, critical.viewportHeight, critical.bufferPx)) {
|
|
253
|
+
critical.seen.add(originalText);
|
|
254
|
+
critical.occurrences.push({ source_text: originalText, semantic_context: "critical:text" });
|
|
255
|
+
}
|
|
256
|
+
}
|
|
220
257
|
}
|
|
221
258
|
}
|
|
222
|
-
function considerAttributes(root, segments, occurrences, seen, maxSegments) {
|
|
259
|
+
function considerAttributes(root, segments, occurrences, seen, maxSegments, critical) {
|
|
223
260
|
const nodes = root.querySelectorAll("[title],[aria-label],[placeholder]");
|
|
224
261
|
nodes.forEach((el) => {
|
|
225
262
|
if (isExcludedElement(el))
|
|
@@ -249,6 +286,19 @@ function considerAttributes(root, segments, occurrences, seen, maxSegments) {
|
|
|
249
286
|
seen.add(original);
|
|
250
287
|
occurrences.push({ source_text: original, semantic_context: `attr:${attr}` });
|
|
251
288
|
}
|
|
289
|
+
if (critical?.enabled && original && !critical.seen.has(original)) {
|
|
290
|
+
let rect = null;
|
|
291
|
+
try {
|
|
292
|
+
rect = el.getBoundingClientRect();
|
|
293
|
+
}
|
|
294
|
+
catch {
|
|
295
|
+
rect = null;
|
|
296
|
+
}
|
|
297
|
+
if (isInViewport(rect, critical.viewportHeight, critical.bufferPx)) {
|
|
298
|
+
critical.seen.add(original);
|
|
299
|
+
critical.occurrences.push({ source_text: original, semantic_context: `critical:attr:${attr}` });
|
|
300
|
+
}
|
|
301
|
+
}
|
|
252
302
|
}
|
|
253
303
|
});
|
|
254
304
|
}
|
|
@@ -273,6 +323,20 @@ function scanDom(opts) {
|
|
|
273
323
|
}
|
|
274
324
|
const stats = buildEmptyStats();
|
|
275
325
|
const maxSegments = Math.max(0, Math.floor(opts.maxSegments || 0)) || 20000;
|
|
326
|
+
const includeCritical = opts.includeCritical === true;
|
|
327
|
+
const viewportHeight = includeCritical ? Math.max(0, Math.floor(window.innerHeight || 0)) : 0;
|
|
328
|
+
const viewportWidth = includeCritical ? Math.max(0, Math.floor(window.innerWidth || 0)) : 0;
|
|
329
|
+
// Why: include a small buffer so "near the fold" text is ready without delaying first paint.
|
|
330
|
+
const critical = includeCritical
|
|
331
|
+
? {
|
|
332
|
+
enabled: true,
|
|
333
|
+
viewportHeight,
|
|
334
|
+
bufferPx: DEFAULT_CRITICAL_BUFFER_PX,
|
|
335
|
+
max: DEFAULT_CRITICAL_MAX,
|
|
336
|
+
seen: new Set(),
|
|
337
|
+
occurrences: [],
|
|
338
|
+
}
|
|
339
|
+
: null;
|
|
276
340
|
const walker = document.createTreeWalker(root, NodeFilter.SHOW_TEXT);
|
|
277
341
|
const nodes = [];
|
|
278
342
|
const segments = [];
|
|
@@ -284,8 +348,13 @@ function scanDom(opts) {
|
|
|
284
348
|
nodes.push(node);
|
|
285
349
|
node = walker.nextNode();
|
|
286
350
|
}
|
|
287
|
-
nodes.forEach((textNode) =>
|
|
288
|
-
|
|
351
|
+
nodes.forEach((textNode) => {
|
|
352
|
+
if (critical?.enabled && critical.occurrences.length >= critical.max) {
|
|
353
|
+
critical.enabled = false;
|
|
354
|
+
}
|
|
355
|
+
considerTextNode(textNode, stats, segments, occurrences, seen, maxSegments, critical);
|
|
356
|
+
});
|
|
357
|
+
considerAttributes(root, segments, occurrences, seen, maxSegments, critical);
|
|
289
358
|
finalizeStats(stats);
|
|
290
359
|
setGlobalStats(stats);
|
|
291
360
|
const truncated = segments.length >= maxSegments;
|
|
@@ -294,6 +363,12 @@ function scanDom(opts) {
|
|
|
294
363
|
stats,
|
|
295
364
|
segments,
|
|
296
365
|
occurrences,
|
|
366
|
+
...(includeCritical
|
|
367
|
+
? {
|
|
368
|
+
critical_occurrences: critical?.occurrences ?? [],
|
|
369
|
+
viewport: { width: viewportWidth, height: viewportHeight },
|
|
370
|
+
}
|
|
371
|
+
: {}),
|
|
297
372
|
truncated,
|
|
298
373
|
};
|
|
299
374
|
}
|
package/dist/version.d.ts
CHANGED
|
@@ -1 +1 @@
|
|
|
1
|
-
export declare const VERSION = "0.2
|
|
1
|
+
export declare const VERSION = "0.3.2";
|
package/dist/version.js
CHANGED
|
@@ -1 +1 @@
|
|
|
1
|
-
export const VERSION = "0.2
|
|
1
|
+
export const VERSION = "0.3.2";
|
package/package.json
CHANGED