@lovalingo/lovalingo 0.2.0 → 0.3.0
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 +425 -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 +84 -8
- 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,70 @@ 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 next = await apiRef.current.fetchEntitlements(locale);
|
|
303
|
+
if (!cancelled && next)
|
|
304
|
+
setEntitlements(next);
|
|
305
|
+
})();
|
|
306
|
+
return () => {
|
|
307
|
+
cancelled = true;
|
|
308
|
+
};
|
|
309
|
+
}, [defaultLocale, entitlements, locale]);
|
|
310
|
+
const applySeoBundle = useCallback((bundle, hreflangEnabled) => {
|
|
103
311
|
try {
|
|
104
312
|
const head = document.head;
|
|
105
313
|
if (!head)
|
|
106
314
|
return;
|
|
107
315
|
head.querySelectorAll('link[data-Lovalingo="hreflang"], link[data-Lovalingo="canonical"]').forEach((el) => el.remove());
|
|
108
|
-
if (!
|
|
316
|
+
if (!bundle)
|
|
109
317
|
return;
|
|
110
|
-
const bundle = await apiRef.current.fetchSeoBundle(activeLocale);
|
|
111
318
|
const seo = (bundle?.seo && typeof bundle.seo === "object" ? bundle.seo : {});
|
|
112
319
|
const alternates = (bundle?.alternates && typeof bundle.alternates === "object" ? bundle.alternates : {});
|
|
113
320
|
const setOrCreateMeta = (attrs, content) => {
|
|
@@ -203,66 +410,36 @@ navigateRef, // For path mode routing
|
|
|
203
410
|
head.appendChild(xDefault);
|
|
204
411
|
}
|
|
205
412
|
}
|
|
206
|
-
catch
|
|
207
|
-
|
|
413
|
+
catch {
|
|
414
|
+
// ignore SEO errors
|
|
208
415
|
}
|
|
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
416
|
}, []);
|
|
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
|
|
417
|
+
// Keep <html lang> in sync and apply default-locale SEO (non-default locales use the bootstrap payload).
|
|
260
418
|
useEffect(() => {
|
|
261
419
|
setDocumentLocale(locale);
|
|
262
|
-
|
|
263
|
-
|
|
264
|
-
|
|
265
|
-
|
|
420
|
+
if (locale !== defaultLocale)
|
|
421
|
+
return;
|
|
422
|
+
if (!isSeoActive())
|
|
423
|
+
return;
|
|
424
|
+
void apiRef.current.fetchSeoBundle(locale).then((bundle) => {
|
|
425
|
+
applySeoBundle(bundle, Boolean(entitlements?.hreflangEnabled));
|
|
426
|
+
});
|
|
427
|
+
}, [applySeoBundle, defaultLocale, entitlements, isSeoActive, locale, setDocumentLocale]);
|
|
428
|
+
const toTranslations = useCallback((map, targetLocale) => {
|
|
429
|
+
const out = [];
|
|
430
|
+
for (const [source_text, translated_text] of Object.entries(map || {})) {
|
|
431
|
+
if (!source_text || !translated_text)
|
|
432
|
+
continue;
|
|
433
|
+
out.push({
|
|
434
|
+
source_text,
|
|
435
|
+
translated_text,
|
|
436
|
+
source_locale: defaultLocale,
|
|
437
|
+
target_locale: targetLocale,
|
|
438
|
+
});
|
|
439
|
+
}
|
|
440
|
+
return out;
|
|
441
|
+
}, [defaultLocale]);
|
|
442
|
+
const loadData = useCallback(async (targetLocale, previousLocale) => {
|
|
266
443
|
// Cancel any pending retry scan to prevent race conditions
|
|
267
444
|
if (retryTimeoutRef.current) {
|
|
268
445
|
clearTimeout(retryTimeoutRef.current);
|
|
@@ -271,23 +448,23 @@ navigateRef, // For path mode routing
|
|
|
271
448
|
// If switching to default locale, clear translations and translate with empty map
|
|
272
449
|
// This will show original text using stored data-Lovalingo-original-html
|
|
273
450
|
if (targetLocale === defaultLocale) {
|
|
274
|
-
|
|
275
|
-
setIsNavigationLoading(false);
|
|
451
|
+
disablePrehide();
|
|
276
452
|
setActiveTranslations(null);
|
|
277
453
|
restoreDom(document.body); // React-safe: only text/attrs, no DOM structure mutation
|
|
278
454
|
isNavigatingRef.current = false;
|
|
279
455
|
return;
|
|
280
456
|
}
|
|
281
|
-
|
|
282
|
-
const
|
|
283
|
-
const cacheKey = `${targetLocale}:${
|
|
457
|
+
const currentPath = window.location.pathname + window.location.search;
|
|
458
|
+
const normalizedPath = processPath(window.location.pathname, enhancedPathConfig);
|
|
459
|
+
const cacheKey = `${targetLocale}:${normalizedPath}`;
|
|
284
460
|
// Check if we have cached translations for this locale + path
|
|
285
461
|
const cachedEntry = translationCacheRef.current.get(cacheKey);
|
|
286
462
|
const cachedExclusions = exclusionsCacheRef.current;
|
|
287
463
|
const cachedDomRules = domRulesCacheRef.current.get(cacheKey);
|
|
288
464
|
if (cachedEntry && cachedExclusions) {
|
|
289
465
|
// CACHE HIT - Use cached data immediately (FAST!)
|
|
290
|
-
logDebug(`[Lovalingo] Using cached translations for ${targetLocale} on ${
|
|
466
|
+
logDebug(`[Lovalingo] Using cached translations for ${targetLocale} on ${normalizedPath}`);
|
|
467
|
+
enablePrehide(getCachedLoadingBgColor());
|
|
291
468
|
setActiveTranslations(cachedEntry.translations);
|
|
292
469
|
setMarkerEngineExclusions(cachedExclusions);
|
|
293
470
|
if (mode === 'dom') {
|
|
@@ -318,49 +495,84 @@ navigateRef, // For path mode routing
|
|
|
318
495
|
applyDomRules(rules);
|
|
319
496
|
}
|
|
320
497
|
}, 500);
|
|
321
|
-
|
|
322
|
-
setTimeout(() => setIsNavigationLoading(false), 50);
|
|
323
|
-
}
|
|
498
|
+
disablePrehide();
|
|
324
499
|
isNavigatingRef.current = false;
|
|
325
500
|
return;
|
|
326
501
|
}
|
|
327
502
|
// CACHE MISS - Fetch from API
|
|
328
|
-
logDebug(`[Lovalingo] Fetching translations for ${targetLocale} on ${
|
|
503
|
+
logDebug(`[Lovalingo] Fetching translations for ${targetLocale} on ${normalizedPath}`);
|
|
329
504
|
setIsLoading(true);
|
|
505
|
+
enablePrehide(getCachedLoadingBgColor());
|
|
330
506
|
try {
|
|
331
507
|
if (previousLocale && previousLocale !== defaultLocale) {
|
|
332
508
|
logDebug(`[Lovalingo] Switching from ${previousLocale} to ${targetLocale}`);
|
|
333
509
|
}
|
|
334
|
-
const
|
|
335
|
-
|
|
336
|
-
|
|
337
|
-
|
|
338
|
-
|
|
339
|
-
|
|
510
|
+
const cachedCritical = readCriticalCache(targetLocale, normalizedPath);
|
|
511
|
+
if (cachedCritical?.loading_bg_color) {
|
|
512
|
+
setCachedLoadingBgColor(cachedCritical.loading_bg_color);
|
|
513
|
+
enablePrehide(cachedCritical.loading_bg_color);
|
|
514
|
+
}
|
|
515
|
+
if (cachedCritical?.exclusions && cachedCritical.exclusions.length > 0) {
|
|
516
|
+
exclusionsCacheRef.current = cachedCritical.exclusions;
|
|
517
|
+
setMarkerEngineExclusions(cachedCritical.exclusions);
|
|
518
|
+
}
|
|
519
|
+
if (cachedCritical?.map && Object.keys(cachedCritical.map).length > 0) {
|
|
520
|
+
setActiveTranslations(toTranslations(cachedCritical.map, targetLocale));
|
|
521
|
+
if (mode === "dom") {
|
|
522
|
+
applyActiveTranslations(document.body);
|
|
523
|
+
}
|
|
524
|
+
disablePrehide();
|
|
525
|
+
}
|
|
526
|
+
const bootstrap = await apiRef.current.fetchBootstrap(targetLocale, currentPath);
|
|
527
|
+
const nextEntitlements = bootstrap?.entitlements || apiRef.current.getEntitlements();
|
|
340
528
|
if (nextEntitlements)
|
|
341
529
|
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);
|
|
530
|
+
if (bootstrap?.loading_bg_color) {
|
|
531
|
+
setCachedLoadingBgColor(bootstrap.loading_bg_color);
|
|
532
|
+
enablePrehide(bootstrap.loading_bg_color);
|
|
355
533
|
}
|
|
356
|
-
|
|
534
|
+
const exclusions = Array.isArray(bootstrap?.exclusions)
|
|
535
|
+
? bootstrap.exclusions
|
|
536
|
+
.map((row) => {
|
|
537
|
+
if (!row || typeof row !== "object")
|
|
538
|
+
return null;
|
|
539
|
+
const r = row;
|
|
540
|
+
const selector = typeof r.selector === "string" ? r.selector.trim() : "";
|
|
541
|
+
const type = typeof r.type === "string" ? r.type.trim() : "";
|
|
542
|
+
if (!selector)
|
|
543
|
+
return null;
|
|
544
|
+
if (type !== "css" && type !== "xpath")
|
|
545
|
+
return null;
|
|
546
|
+
return { selector, type: type };
|
|
547
|
+
})
|
|
548
|
+
.filter(Boolean)
|
|
549
|
+
: await apiRef.current.fetchExclusions();
|
|
550
|
+
exclusionsCacheRef.current = exclusions;
|
|
357
551
|
setMarkerEngineExclusions(exclusions);
|
|
358
|
-
|
|
359
|
-
|
|
552
|
+
const criticalMap = bootstrap?.critical?.map && typeof bootstrap.critical.map === "object" && !Array.isArray(bootstrap.critical.map)
|
|
553
|
+
? bootstrap.critical.map
|
|
554
|
+
: {};
|
|
555
|
+
if (Object.keys(criticalMap).length > 0) {
|
|
556
|
+
setActiveTranslations(toTranslations(criticalMap, targetLocale));
|
|
557
|
+
if (mode === "dom") {
|
|
558
|
+
applyActiveTranslations(document.body);
|
|
559
|
+
}
|
|
360
560
|
}
|
|
361
561
|
if (autoApplyRules) {
|
|
562
|
+
const domRules = Array.isArray(bootstrap?.dom_rules) ? bootstrap.dom_rules : await apiRef.current.fetchDomRules(targetLocale);
|
|
563
|
+
domRulesCacheRef.current.set(cacheKey, domRules);
|
|
362
564
|
applyDomRules(domRules);
|
|
363
565
|
}
|
|
566
|
+
if (isSeoActive() && bootstrap) {
|
|
567
|
+
const hreflangEnabled = Boolean((bootstrap.entitlements || nextEntitlements)?.hreflangEnabled);
|
|
568
|
+
applySeoBundle({ seo: bootstrap.seo, alternates: bootstrap.alternates, jsonld: bootstrap.jsonld }, hreflangEnabled);
|
|
569
|
+
}
|
|
570
|
+
writeCriticalCache(targetLocale, normalizedPath, {
|
|
571
|
+
map: criticalMap,
|
|
572
|
+
exclusions,
|
|
573
|
+
loading_bg_color: bootstrap?.loading_bg_color && /^#[0-9a-fA-F]{6}$/.test(bootstrap.loading_bg_color) ? bootstrap.loading_bg_color : null,
|
|
574
|
+
});
|
|
575
|
+
disablePrehide();
|
|
364
576
|
// Delayed retry scan to catch late-rendering content
|
|
365
577
|
retryTimeoutRef.current = setTimeout(() => {
|
|
366
578
|
// Don't scan if we're navigating (prevents React conflicts)
|
|
@@ -372,24 +584,48 @@ navigateRef, // For path mode routing
|
|
|
372
584
|
applyActiveTranslations(document.body);
|
|
373
585
|
}
|
|
374
586
|
if (autoApplyRules) {
|
|
375
|
-
const rules = domRulesCacheRef.current.get(cacheKey) ||
|
|
587
|
+
const rules = domRulesCacheRef.current.get(cacheKey) || [];
|
|
376
588
|
applyDomRules(rules);
|
|
377
589
|
}
|
|
378
590
|
}, 500);
|
|
379
|
-
|
|
380
|
-
|
|
381
|
-
|
|
591
|
+
// Lazy-load the full page bundle after first paint.
|
|
592
|
+
void (async () => {
|
|
593
|
+
const bundle = await apiRef.current.fetchBundle(targetLocale, currentPath);
|
|
594
|
+
if (!bundle || !bundle.map)
|
|
595
|
+
return;
|
|
596
|
+
const translations = toTranslations(bundle.map, targetLocale);
|
|
597
|
+
if (translations.length === 0)
|
|
598
|
+
return;
|
|
599
|
+
translationCacheRef.current.set(cacheKey, { translations });
|
|
600
|
+
setActiveTranslations(translations);
|
|
601
|
+
if (mode === "dom") {
|
|
602
|
+
applyActiveTranslations(document.body);
|
|
603
|
+
}
|
|
604
|
+
})();
|
|
382
605
|
}
|
|
383
606
|
catch (error) {
|
|
384
607
|
errorDebug('Error loading translations:', error);
|
|
385
|
-
|
|
386
|
-
setIsNavigationLoading(false);
|
|
608
|
+
disablePrehide();
|
|
387
609
|
}
|
|
388
610
|
finally {
|
|
389
611
|
setIsLoading(false);
|
|
390
612
|
isNavigatingRef.current = false;
|
|
391
613
|
}
|
|
392
|
-
}, [
|
|
614
|
+
}, [
|
|
615
|
+
applySeoBundle,
|
|
616
|
+
autoApplyRules,
|
|
617
|
+
defaultLocale,
|
|
618
|
+
disablePrehide,
|
|
619
|
+
enablePrehide,
|
|
620
|
+
enhancedPathConfig,
|
|
621
|
+
getCachedLoadingBgColor,
|
|
622
|
+
isSeoActive,
|
|
623
|
+
mode,
|
|
624
|
+
readCriticalCache,
|
|
625
|
+
setCachedLoadingBgColor,
|
|
626
|
+
toTranslations,
|
|
627
|
+
writeCriticalCache,
|
|
628
|
+
]);
|
|
393
629
|
// SPA router hook-in: track History API navigations (React Router/Next/etc) without app changes.
|
|
394
630
|
useEffect(() => {
|
|
395
631
|
const historyObj = window.history;
|
|
@@ -405,7 +641,7 @@ navigateRef, // For path mode routing
|
|
|
405
641
|
const nextLocale = detectLocale();
|
|
406
642
|
if (nextLocale !== locale) {
|
|
407
643
|
setLocaleState(nextLocale);
|
|
408
|
-
void loadData(nextLocale, locale
|
|
644
|
+
void loadData(nextLocale, locale);
|
|
409
645
|
}
|
|
410
646
|
else if (mode === "dom" && nextLocale !== defaultLocale) {
|
|
411
647
|
applyActiveTranslations(document.body);
|
|
@@ -444,10 +680,6 @@ navigateRef, // For path mode routing
|
|
|
444
680
|
warnDebug('Failed to save locale to localStorage:', e);
|
|
445
681
|
}
|
|
446
682
|
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
683
|
// Prevent MutationObserver work during the switch to avoid React conflicts
|
|
452
684
|
isNavigatingRef.current = true;
|
|
453
685
|
// Update URL based on routing strategy
|
|
@@ -480,24 +712,17 @@ navigateRef, // For path mode routing
|
|
|
480
712
|
window.history.pushState({}, '', url.toString());
|
|
481
713
|
}
|
|
482
714
|
setLocaleState(newLocale);
|
|
483
|
-
|
|
484
|
-
await loadData(newLocale, previousLocale, true);
|
|
715
|
+
await loadData(newLocale, previousLocale);
|
|
485
716
|
})().finally(() => {
|
|
486
717
|
isInternalNavigationRef.current = false;
|
|
487
718
|
});
|
|
488
|
-
}, [allLocales, locale, routing, loadData,
|
|
719
|
+
}, [allLocales, locale, routing, loadData, navigateRef]);
|
|
489
720
|
// No string-level "misses" from the client: the pipeline (Browser Rendering) is source-of-truth for string discovery.
|
|
490
721
|
// Initialize
|
|
491
722
|
useEffect(() => {
|
|
492
723
|
const initialLocale = detectLocale();
|
|
493
|
-
setLocaleState(initialLocale);
|
|
494
724
|
// Track initial page (fallback discovery for pages not present in the routes feed).
|
|
495
725
|
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
726
|
// Always prefetch artifacts for the initial locale (pipeline-produced translations + rules).
|
|
502
727
|
loadData(initialLocale);
|
|
503
728
|
// Set up keyboard shortcut for edit mode
|
|
@@ -541,80 +766,6 @@ navigateRef, // For path mode routing
|
|
|
541
766
|
};
|
|
542
767
|
}
|
|
543
768
|
}, [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
769
|
// PATH mode: auto-prefix internal links that are missing a locale segment.
|
|
619
770
|
// This prevents "losing" the current locale when the app renders absolute links like "/projects/slug"
|
|
620
771
|
// while the user is on "/de/...".
|
|
@@ -766,6 +917,83 @@ navigateRef, // For path mode routing
|
|
|
766
917
|
document.removeEventListener('click', onClickCapture, true);
|
|
767
918
|
};
|
|
768
919
|
}, [routing, autoPrefixLinks, allLocales, locale, navigateRef]);
|
|
920
|
+
// Navigation prefetch: warm the HTTP cache for bootstrap + page bundle before the user clicks (reduces EN→FR flash on SPA route changes).
|
|
921
|
+
useEffect(() => {
|
|
922
|
+
if (!resolvedApiKey)
|
|
923
|
+
return;
|
|
924
|
+
if (typeof window === "undefined" || typeof document === "undefined")
|
|
925
|
+
return;
|
|
926
|
+
const connection = navigator?.connection;
|
|
927
|
+
if (connection?.saveData)
|
|
928
|
+
return;
|
|
929
|
+
if (typeof connection?.effectiveType === "string" && /(^|-)2g$/.test(connection.effectiveType))
|
|
930
|
+
return;
|
|
931
|
+
const prefetched = new Set();
|
|
932
|
+
// Why: cap speculative requests to avoid flooding the network on pages with many links.
|
|
933
|
+
const maxPrefetch = 40;
|
|
934
|
+
const isAssetPath = (pathname) => {
|
|
935
|
+
if (pathname === "/robots.txt" || pathname === "/sitemap.xml")
|
|
936
|
+
return true;
|
|
937
|
+
if (pathname.startsWith("/.well-known/"))
|
|
938
|
+
return true;
|
|
939
|
+
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);
|
|
940
|
+
};
|
|
941
|
+
const pickLocaleForUrl = (url) => {
|
|
942
|
+
if (routing === "path") {
|
|
943
|
+
const segment = url.pathname.split("/")[1] || "";
|
|
944
|
+
if (segment && allLocales.includes(segment))
|
|
945
|
+
return segment;
|
|
946
|
+
return locale;
|
|
947
|
+
}
|
|
948
|
+
const q = url.searchParams.get("t") || url.searchParams.get("locale");
|
|
949
|
+
if (q && allLocales.includes(q))
|
|
950
|
+
return q;
|
|
951
|
+
return locale;
|
|
952
|
+
};
|
|
953
|
+
const onIntent = (event) => {
|
|
954
|
+
if (prefetched.size >= maxPrefetch)
|
|
955
|
+
return;
|
|
956
|
+
const target = event.target;
|
|
957
|
+
const anchor = target?.closest?.("a[href]");
|
|
958
|
+
if (!anchor)
|
|
959
|
+
return;
|
|
960
|
+
const href = anchor.getAttribute("href") || "";
|
|
961
|
+
if (!href || /^(?:#|mailto:|tel:|sms:|javascript:)/i.test(href))
|
|
962
|
+
return;
|
|
963
|
+
let url;
|
|
964
|
+
try {
|
|
965
|
+
url = new URL(href, window.location.origin);
|
|
966
|
+
}
|
|
967
|
+
catch {
|
|
968
|
+
return;
|
|
969
|
+
}
|
|
970
|
+
if (url.origin !== window.location.origin)
|
|
971
|
+
return;
|
|
972
|
+
if (isAssetPath(url.pathname))
|
|
973
|
+
return;
|
|
974
|
+
const targetLocale = pickLocaleForUrl(url);
|
|
975
|
+
if (!targetLocale || targetLocale === defaultLocale)
|
|
976
|
+
return;
|
|
977
|
+
const normalizedPath = processPath(url.pathname, enhancedPathConfig);
|
|
978
|
+
const key = `${targetLocale}:${normalizedPath}`;
|
|
979
|
+
if (prefetched.has(key))
|
|
980
|
+
return;
|
|
981
|
+
prefetched.add(key);
|
|
982
|
+
const pathParam = `${url.pathname}${url.search}`;
|
|
983
|
+
const bootstrapUrl = `${apiBase}/functions/v1/bootstrap?key=${encodeURIComponent(resolvedApiKey)}&locale=${encodeURIComponent(targetLocale)}&path=${encodeURIComponent(pathParam)}`;
|
|
984
|
+
const bundleUrl = `${apiBase}/functions/v1/bundle?key=${encodeURIComponent(resolvedApiKey)}&locale=${encodeURIComponent(targetLocale)}&path=${encodeURIComponent(pathParam)}`;
|
|
985
|
+
void fetch(bootstrapUrl, { cache: "force-cache" }).catch(() => undefined);
|
|
986
|
+
void fetch(bundleUrl, { cache: "force-cache" }).catch(() => undefined);
|
|
987
|
+
};
|
|
988
|
+
document.addEventListener("pointerover", onIntent, { passive: true });
|
|
989
|
+
document.addEventListener("touchstart", onIntent, { passive: true });
|
|
990
|
+
document.addEventListener("focusin", onIntent);
|
|
991
|
+
return () => {
|
|
992
|
+
document.removeEventListener("pointerover", onIntent);
|
|
993
|
+
document.removeEventListener("touchstart", onIntent);
|
|
994
|
+
document.removeEventListener("focusin", onIntent);
|
|
995
|
+
};
|
|
996
|
+
}, [allLocales, apiBase, defaultLocale, enhancedPathConfig, locale, resolvedApiKey, routing]);
|
|
769
997
|
// Dynamic DOM updates are handled by the marker engine observer (React-safe in-place text/attr updates).
|
|
770
998
|
// No periodic string-miss reporting. Page discovery is tracked via pageview only.
|
|
771
999
|
const translateElement = useCallback((element) => {
|
|
@@ -802,6 +1030,5 @@ navigateRef, // For path mode routing
|
|
|
802
1030
|
children,
|
|
803
1031
|
React.createElement(LanguageSwitcher, { locales: allLocales, currentLocale: locale, onLocaleChange: setLocale, position: switcherPosition, offsetY: switcherOffsetY, theme: switcherTheme, branding: entitlements?.brandingRequired
|
|
804
1032
|
? { required: true, href: "https://lovalingo.com" }
|
|
805
|
-
: undefined })
|
|
806
|
-
React.createElement(NavigationOverlay, { isVisible: isNavigationLoading })));
|
|
1033
|
+
: undefined })));
|
|
807
1034
|
};
|
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
|
}
|
|
@@ -376,7 +451,7 @@ export function setActiveTranslations(translations) {
|
|
|
376
451
|
}
|
|
377
452
|
const map = new Map();
|
|
378
453
|
for (const t of translations) {
|
|
379
|
-
const source = (t?.source_text || "").toString()
|
|
454
|
+
const source = normalizeWhitespace((t?.source_text || "").toString());
|
|
380
455
|
const translated = (t?.translated_text ?? "").toString();
|
|
381
456
|
if (!source || !translated)
|
|
382
457
|
continue;
|
|
@@ -389,7 +464,7 @@ function applyTranslationMap(bundle, root) {
|
|
|
389
464
|
return 0;
|
|
390
465
|
const map = new Map();
|
|
391
466
|
for (const [k, v] of Object.entries(bundle || {})) {
|
|
392
|
-
const source = (k || "").toString()
|
|
467
|
+
const source = normalizeWhitespace((k || "").toString());
|
|
393
468
|
const translated = (v ?? "").toString();
|
|
394
469
|
if (!source || !translated)
|
|
395
470
|
continue;
|
|
@@ -426,7 +501,8 @@ export function applyActiveTranslations(root = document.body) {
|
|
|
426
501
|
if (!isTranslatableText(trimmed))
|
|
427
502
|
continue;
|
|
428
503
|
const original = getOrInitTextOriginal(textNode, parent);
|
|
429
|
-
const
|
|
504
|
+
const key = normalizeWhitespace(original.trimmed);
|
|
505
|
+
const translation = map.get(key);
|
|
430
506
|
if (!translation)
|
|
431
507
|
continue;
|
|
432
508
|
const next = `${original.leading}${translation}${original.trailing}`;
|
package/dist/version.d.ts
CHANGED
|
@@ -1 +1 @@
|
|
|
1
|
-
export declare const VERSION = "0.
|
|
1
|
+
export declare const VERSION = "0.3.0";
|
package/dist/version.js
CHANGED
|
@@ -1 +1 @@
|
|
|
1
|
-
export const VERSION = "0.
|
|
1
|
+
export const VERSION = "0.3.0";
|
package/package.json
CHANGED