@lovalingo/lovalingo 0.5.5 → 0.5.6
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/dist/components/AixsterProvider.d.ts +1 -10
- package/dist/components/AixsterProvider.js +1 -1251
- package/dist/components/LanguageSwitcher.js +6 -4
- package/dist/components/LovalingoProvider.d.ts +10 -1
- package/dist/components/LovalingoProvider.js +604 -1
- package/dist/hooks/provider/useBundleLoading.d.ts +32 -0
- package/dist/hooks/provider/useBundleLoading.js +354 -0
- package/dist/hooks/provider/useDomRules.d.ts +15 -0
- package/dist/hooks/provider/useDomRules.js +38 -0
- package/dist/hooks/provider/useLinkAutoPrefix.d.ts +12 -0
- package/dist/hooks/provider/useLinkAutoPrefix.js +146 -0
- package/dist/hooks/provider/useNavigationPrefetch.d.ts +12 -0
- package/dist/hooks/provider/useNavigationPrefetch.js +81 -0
- package/dist/hooks/provider/usePageviewTracking.d.ts +10 -0
- package/dist/hooks/provider/usePageviewTracking.js +44 -0
- package/dist/hooks/provider/usePrehide.d.ts +5 -0
- package/dist/hooks/provider/usePrehide.js +72 -0
- package/dist/hooks/provider/useSitemapLinkTag.d.ts +7 -0
- package/dist/hooks/provider/useSitemapLinkTag.js +28 -0
- package/package.json +1 -1
|
@@ -1,1251 +1 @@
|
|
|
1
|
-
|
|
2
|
-
import { LovalingoContext } from '../context/LovalingoContext';
|
|
3
|
-
import { LangRoutingContext } from '../context/LangRoutingContext';
|
|
4
|
-
import { LovalingoAPI } from '../utils/api';
|
|
5
|
-
import { applyDomRules } from '../utils/domRules';
|
|
6
|
-
import { hashContent } from '../utils/hash';
|
|
7
|
-
import { applyActiveTranslations, getCriticalFingerprint, restoreDom, setActiveTranslations, setMarkerEngineExclusions, startMarkerEngine } from '../utils/markerEngine';
|
|
8
|
-
import { logDebug, warnDebug, errorDebug } from '../utils/logger';
|
|
9
|
-
import { isNonLocalizedPath, stripLocalePrefix } from '../utils/nonLocalizedPaths';
|
|
10
|
-
import { processPath } from '../utils/pathNormalizer';
|
|
11
|
-
import { LanguageSwitcher } from './LanguageSwitcher';
|
|
12
|
-
const LOCALE_STORAGE_KEY = 'Lovalingo_locale';
|
|
13
|
-
const LOADING_BG_STORAGE_PREFIX = "Lovalingo_loading_bg_color";
|
|
14
|
-
const BRANDING_STORAGE_PREFIX = "Lovalingo_branding_enabled";
|
|
15
|
-
const CRITICAL_CACHE_PREFIX = "Lovalingo_critical_v0_3";
|
|
16
|
-
// Why: avoid long blank screens on blocked/untranslated routes by always revealing the original page quickly.
|
|
17
|
-
const PREHIDE_FAILSAFE_MS = 1700;
|
|
18
|
-
export const LovalingoProvider = ({ children, apiKey: apiKeyProp, publicAnonKey, defaultLocale, locales, apiBase = 'https://cdn.lovalingo.com', routing = 'path', // Default to path mode (SEO-friendly, recommended)
|
|
19
|
-
autoPrefixLinks = true, overlayBgColor, autoApplyRules = true, switcherPosition = 'bottom-right', switcherOffsetY = 20, switcherTheme = 'dark', editMode: initialEditMode = false, editKey = 'KeyE', pathNormalization = { enabled: true }, // Enable by default
|
|
20
|
-
mode = 'dom', // Default to legacy DOM mode for backward compatibility
|
|
21
|
-
sitemap = true, // Default: true - Auto-inject sitemap link tag
|
|
22
|
-
seo = true, // Default: true - Can be disabled per project entitlements
|
|
23
|
-
navigateRef, // For path mode routing
|
|
24
|
-
}) => {
|
|
25
|
-
const metaKey = typeof document !== "undefined"
|
|
26
|
-
? document.querySelector('meta[name="lovalingo-public-anon-key"]')?.content?.trim() || ""
|
|
27
|
-
: "";
|
|
28
|
-
const resolvedApiKey = (typeof apiKeyProp === "string" && apiKeyProp.trim().length > 0
|
|
29
|
-
? apiKeyProp
|
|
30
|
-
: typeof publicAnonKey === "string" && publicAnonKey.trim().length > 0
|
|
31
|
-
? publicAnonKey
|
|
32
|
-
: globalThis
|
|
33
|
-
.__LOVALINGO_PUBLIC_ANON_KEY__ ||
|
|
34
|
-
globalThis.__LOVALINGO_API_KEY__ ||
|
|
35
|
-
metaKey ||
|
|
36
|
-
"");
|
|
37
|
-
const rawLocales = Array.isArray(locales) ? locales : [];
|
|
38
|
-
// Stabilize locale lists even when callers pass inline arrays (e.g. locales={["en","de"]})
|
|
39
|
-
// so effects/callbacks don't re-run every render.
|
|
40
|
-
const localesKey = rawLocales.join(",");
|
|
41
|
-
const allLocales = useMemo(() => {
|
|
42
|
-
const base = rawLocales.includes(defaultLocale) ? rawLocales : [defaultLocale, ...rawLocales];
|
|
43
|
-
return Array.from(new Set(base));
|
|
44
|
-
}, [defaultLocale, localesKey, rawLocales]);
|
|
45
|
-
// Why: read locale synchronously from the URL to avoid an initial default-locale render (EN → FR flash) before effects run.
|
|
46
|
-
const [locale, setLocaleState] = useState(() => {
|
|
47
|
-
if (typeof window === "undefined")
|
|
48
|
-
return defaultLocale;
|
|
49
|
-
if (routing === "path") {
|
|
50
|
-
const pathLocale = window.location.pathname.split("/")[1];
|
|
51
|
-
if (pathLocale && allLocales.includes(pathLocale)) {
|
|
52
|
-
return pathLocale;
|
|
53
|
-
}
|
|
54
|
-
}
|
|
55
|
-
else if (routing === "query") {
|
|
56
|
-
const params = new URLSearchParams(window.location.search);
|
|
57
|
-
const queryLocale = params.get("t") || params.get("locale");
|
|
58
|
-
if (queryLocale && allLocales.includes(queryLocale)) {
|
|
59
|
-
return queryLocale;
|
|
60
|
-
}
|
|
61
|
-
}
|
|
62
|
-
try {
|
|
63
|
-
const stored = localStorage.getItem(LOCALE_STORAGE_KEY);
|
|
64
|
-
if (stored && allLocales.includes(stored)) {
|
|
65
|
-
return stored;
|
|
66
|
-
}
|
|
67
|
-
}
|
|
68
|
-
catch {
|
|
69
|
-
// ignore
|
|
70
|
-
}
|
|
71
|
-
return defaultLocale;
|
|
72
|
-
});
|
|
73
|
-
const [isLoading, setIsLoading] = useState(false);
|
|
74
|
-
const [editMode, setEditMode] = useState(initialEditMode);
|
|
75
|
-
const enhancedPathConfig = useMemo(() => (routing === "path" ? { ...pathNormalization, supportedLocales: allLocales } : pathNormalization), [allLocales, pathNormalization, routing]);
|
|
76
|
-
const apiRef = useRef(new LovalingoAPI(resolvedApiKey, apiBase, enhancedPathConfig));
|
|
77
|
-
useEffect(() => {
|
|
78
|
-
apiRef.current = new LovalingoAPI(resolvedApiKey, apiBase, enhancedPathConfig);
|
|
79
|
-
}, [apiBase, enhancedPathConfig, resolvedApiKey]);
|
|
80
|
-
const routingConfig = useContext(LangRoutingContext);
|
|
81
|
-
const [entitlements, setEntitlements] = useState(() => apiRef.current.getEntitlements());
|
|
82
|
-
const lastPageviewRef = useRef("");
|
|
83
|
-
const lastPageviewFingerprintRef = useRef("");
|
|
84
|
-
const pageviewFingerprintTimeoutRef = useRef(null);
|
|
85
|
-
const pageviewFingerprintRetryTimeoutRef = useRef(null);
|
|
86
|
-
const lastNormalizedPathRef = useRef("");
|
|
87
|
-
const historyPatchedRef = useRef(false);
|
|
88
|
-
const originalHistoryRef = useRef(null);
|
|
89
|
-
const onNavigateRef = useRef(() => undefined);
|
|
90
|
-
const retryTimeoutRef = useRef(null);
|
|
91
|
-
const loadingFailsafeTimeoutRef = useRef(null);
|
|
92
|
-
const isNavigatingRef = useRef(false);
|
|
93
|
-
const isInternalNavigationRef = useRef(false);
|
|
94
|
-
const inFlightLoadKeyRef = useRef(null);
|
|
95
|
-
const translationCacheRef = useRef(new Map());
|
|
96
|
-
const exclusionsCacheRef = useRef(null);
|
|
97
|
-
const domRulesCacheRef = useRef(new Map());
|
|
98
|
-
const loadingBgStorageKey = `${LOADING_BG_STORAGE_PREFIX}:${resolvedApiKey || "anonymous"}`;
|
|
99
|
-
const brandingStorageKey = `${BRANDING_STORAGE_PREFIX}:${resolvedApiKey || "anonymous"}`;
|
|
100
|
-
const readBrandingCache = () => {
|
|
101
|
-
try {
|
|
102
|
-
const cached = (localStorage.getItem(brandingStorageKey) || "").trim();
|
|
103
|
-
if (cached === "0")
|
|
104
|
-
return false;
|
|
105
|
-
if (cached === "1")
|
|
106
|
-
return true;
|
|
107
|
-
}
|
|
108
|
-
catch {
|
|
109
|
-
// ignore
|
|
110
|
-
}
|
|
111
|
-
return true;
|
|
112
|
-
};
|
|
113
|
-
const [brandingEnabled, setBrandingEnabled] = useState(readBrandingCache);
|
|
114
|
-
const prehideStateRef = useRef({
|
|
115
|
-
active: false,
|
|
116
|
-
timeoutId: null,
|
|
117
|
-
startedAtMs: null,
|
|
118
|
-
prevHtmlVisibility: "",
|
|
119
|
-
prevBodyVisibility: "",
|
|
120
|
-
prevHtmlBg: "",
|
|
121
|
-
prevBodyBg: "",
|
|
122
|
-
});
|
|
123
|
-
const getCachedLoadingBgColor = useCallback(() => {
|
|
124
|
-
const configured = (overlayBgColor || "").toString().trim();
|
|
125
|
-
if (/^#[0-9a-fA-F]{6}$/.test(configured))
|
|
126
|
-
return configured;
|
|
127
|
-
try {
|
|
128
|
-
const cached = localStorage.getItem(loadingBgStorageKey) || "";
|
|
129
|
-
if (/^#[0-9a-fA-F]{6}$/.test(cached.trim()))
|
|
130
|
-
return cached.trim();
|
|
131
|
-
}
|
|
132
|
-
catch {
|
|
133
|
-
// ignore
|
|
134
|
-
}
|
|
135
|
-
return "#ffffff";
|
|
136
|
-
}, [loadingBgStorageKey, overlayBgColor]);
|
|
137
|
-
const setCachedLoadingBgColor = useCallback((color) => {
|
|
138
|
-
const next = (color || "").toString().trim();
|
|
139
|
-
if (!/^#[0-9a-fA-F]{6}$/.test(next))
|
|
140
|
-
return;
|
|
141
|
-
try {
|
|
142
|
-
localStorage.setItem(loadingBgStorageKey, next);
|
|
143
|
-
}
|
|
144
|
-
catch {
|
|
145
|
-
// ignore
|
|
146
|
-
}
|
|
147
|
-
}, [loadingBgStorageKey]);
|
|
148
|
-
useEffect(() => {
|
|
149
|
-
// Why: make `overlayBgColor` the source of truth while keeping the existing cache key for backwards compatibility.
|
|
150
|
-
const configured = (overlayBgColor || "").toString().trim();
|
|
151
|
-
if (!/^#[0-9a-fA-F]{6}$/.test(configured))
|
|
152
|
-
return;
|
|
153
|
-
setCachedLoadingBgColor(configured);
|
|
154
|
-
}, [overlayBgColor, setCachedLoadingBgColor]);
|
|
155
|
-
const setCachedBrandingEnabled = useCallback((enabled) => {
|
|
156
|
-
try {
|
|
157
|
-
localStorage.setItem(brandingStorageKey, enabled === false ? "0" : "1");
|
|
158
|
-
}
|
|
159
|
-
catch {
|
|
160
|
-
// ignore
|
|
161
|
-
}
|
|
162
|
-
}, [brandingStorageKey]);
|
|
163
|
-
useEffect(() => {
|
|
164
|
-
setBrandingEnabled(readBrandingCache());
|
|
165
|
-
// eslint-disable-next-line react-hooks/exhaustive-deps
|
|
166
|
-
}, [brandingStorageKey]);
|
|
167
|
-
useEffect(() => {
|
|
168
|
-
lastPageviewRef.current = "";
|
|
169
|
-
lastPageviewFingerprintRef.current = "";
|
|
170
|
-
}, [resolvedApiKey]);
|
|
171
|
-
const trackPageviewOnce = useCallback((path) => {
|
|
172
|
-
const next = (path || "").toString();
|
|
173
|
-
if (!next)
|
|
174
|
-
return;
|
|
175
|
-
if (lastPageviewRef.current === next)
|
|
176
|
-
return;
|
|
177
|
-
lastPageviewRef.current = next;
|
|
178
|
-
apiRef.current.trackPageview(next);
|
|
179
|
-
const trySendFingerprint = () => {
|
|
180
|
-
if (typeof window === "undefined")
|
|
181
|
-
return;
|
|
182
|
-
const markersReady = window.__lovalingoMarkersReady === true;
|
|
183
|
-
if (!markersReady)
|
|
184
|
-
return;
|
|
185
|
-
const fp = getCriticalFingerprint();
|
|
186
|
-
if (!fp || fp.critical_count <= 0)
|
|
187
|
-
return;
|
|
188
|
-
const signature = `${next}|${fp.critical_hash}|${fp.critical_count}`;
|
|
189
|
-
if (lastPageviewFingerprintRef.current === signature)
|
|
190
|
-
return;
|
|
191
|
-
lastPageviewFingerprintRef.current = signature;
|
|
192
|
-
apiRef.current.trackPageview(next, fp);
|
|
193
|
-
};
|
|
194
|
-
if (pageviewFingerprintTimeoutRef.current != null)
|
|
195
|
-
window.clearTimeout(pageviewFingerprintTimeoutRef.current);
|
|
196
|
-
if (pageviewFingerprintRetryTimeoutRef.current != null)
|
|
197
|
-
window.clearTimeout(pageviewFingerprintRetryTimeoutRef.current);
|
|
198
|
-
// Why: wait briefly for markers/content to settle before computing a critical fingerprint for change detection.
|
|
199
|
-
pageviewFingerprintTimeoutRef.current = window.setTimeout(trySendFingerprint, 800);
|
|
200
|
-
pageviewFingerprintRetryTimeoutRef.current = window.setTimeout(trySendFingerprint, 2000);
|
|
201
|
-
}, []);
|
|
202
|
-
const forceDisablePrehide = useCallback(() => {
|
|
203
|
-
if (typeof document === "undefined")
|
|
204
|
-
return;
|
|
205
|
-
const html = document.documentElement;
|
|
206
|
-
const body = document.body;
|
|
207
|
-
if (!html || !body)
|
|
208
|
-
return;
|
|
209
|
-
const state = prehideStateRef.current;
|
|
210
|
-
if (state.timeoutId != null) {
|
|
211
|
-
window.clearTimeout(state.timeoutId);
|
|
212
|
-
state.timeoutId = null;
|
|
213
|
-
}
|
|
214
|
-
if (!state.active)
|
|
215
|
-
return;
|
|
216
|
-
state.active = false;
|
|
217
|
-
state.startedAtMs = null;
|
|
218
|
-
html.style.visibility = state.prevHtmlVisibility;
|
|
219
|
-
body.style.visibility = state.prevBodyVisibility;
|
|
220
|
-
html.style.backgroundColor = state.prevHtmlBg;
|
|
221
|
-
body.style.backgroundColor = state.prevBodyBg;
|
|
222
|
-
}, []);
|
|
223
|
-
const enablePrehide = useCallback((bgColor) => {
|
|
224
|
-
if (typeof document === "undefined")
|
|
225
|
-
return;
|
|
226
|
-
const html = document.documentElement;
|
|
227
|
-
const body = document.body;
|
|
228
|
-
if (!html || !body)
|
|
229
|
-
return;
|
|
230
|
-
const state = prehideStateRef.current;
|
|
231
|
-
// Why: avoid "perma-hidden" pages when repeated navigation/errors keep prehide active; always hard-stop after a few seconds.
|
|
232
|
-
if (state.active && state.startedAtMs != null && Date.now() - state.startedAtMs > PREHIDE_FAILSAFE_MS * 3) {
|
|
233
|
-
forceDisablePrehide();
|
|
234
|
-
}
|
|
235
|
-
if (!state.active) {
|
|
236
|
-
state.active = true;
|
|
237
|
-
state.startedAtMs = Date.now();
|
|
238
|
-
state.prevHtmlVisibility = html.style.visibility || "";
|
|
239
|
-
state.prevBodyVisibility = body.style.visibility || "";
|
|
240
|
-
state.prevHtmlBg = html.style.backgroundColor || "";
|
|
241
|
-
state.prevBodyBg = body.style.backgroundColor || "";
|
|
242
|
-
}
|
|
243
|
-
html.style.visibility = "hidden";
|
|
244
|
-
body.style.visibility = "hidden";
|
|
245
|
-
if (bgColor) {
|
|
246
|
-
html.style.backgroundColor = bgColor;
|
|
247
|
-
body.style.backgroundColor = bgColor;
|
|
248
|
-
}
|
|
249
|
-
if (state.timeoutId != null) {
|
|
250
|
-
return;
|
|
251
|
-
}
|
|
252
|
-
// Why: avoid a "perma-hide" when navigation events repeatedly re-trigger prehide and keep extending the timeout.
|
|
253
|
-
state.timeoutId = window.setTimeout(() => forceDisablePrehide(), PREHIDE_FAILSAFE_MS);
|
|
254
|
-
}, [forceDisablePrehide]);
|
|
255
|
-
const disablePrehide = forceDisablePrehide;
|
|
256
|
-
const buildCriticalCacheKey = useCallback((targetLocale, normalizedPath) => {
|
|
257
|
-
const key = `${resolvedApiKey || "anonymous"}:${targetLocale}:${normalizedPath || "/"}`;
|
|
258
|
-
return `${CRITICAL_CACHE_PREFIX}:${hashContent(key)}`;
|
|
259
|
-
}, [resolvedApiKey]);
|
|
260
|
-
const readCriticalCache = useCallback((targetLocale, normalizedPath) => {
|
|
261
|
-
const key = buildCriticalCacheKey(targetLocale, normalizedPath);
|
|
262
|
-
try {
|
|
263
|
-
const raw = localStorage.getItem(key);
|
|
264
|
-
if (!raw)
|
|
265
|
-
return null;
|
|
266
|
-
const parsed = JSON.parse(raw);
|
|
267
|
-
if (!parsed || typeof parsed !== "object")
|
|
268
|
-
return null;
|
|
269
|
-
const record = parsed;
|
|
270
|
-
const map = record.map && typeof record.map === "object" && !Array.isArray(record.map) ? record.map : null;
|
|
271
|
-
const exclusionsRaw = Array.isArray(record.exclusions) ? record.exclusions : [];
|
|
272
|
-
const exclusions = exclusionsRaw
|
|
273
|
-
.map((row) => {
|
|
274
|
-
if (!row || typeof row !== "object")
|
|
275
|
-
return null;
|
|
276
|
-
const r = row;
|
|
277
|
-
const selector = typeof r.selector === "string" ? r.selector.trim() : "";
|
|
278
|
-
const type = typeof r.type === "string" ? r.type.trim() : "";
|
|
279
|
-
if (!selector)
|
|
280
|
-
return null;
|
|
281
|
-
if (type !== "css" && type !== "xpath")
|
|
282
|
-
return null;
|
|
283
|
-
return { selector, type: type };
|
|
284
|
-
})
|
|
285
|
-
.filter(Boolean);
|
|
286
|
-
const bg = typeof record.loading_bg_color === "string" ? record.loading_bg_color.trim() : "";
|
|
287
|
-
return {
|
|
288
|
-
map: map || {},
|
|
289
|
-
exclusions,
|
|
290
|
-
loading_bg_color: /^#[0-9a-fA-F]{6}$/.test(bg) ? bg : null,
|
|
291
|
-
};
|
|
292
|
-
}
|
|
293
|
-
catch {
|
|
294
|
-
return null;
|
|
295
|
-
}
|
|
296
|
-
}, [buildCriticalCacheKey]);
|
|
297
|
-
const writeCriticalCache = useCallback((targetLocale, normalizedPath, entry) => {
|
|
298
|
-
const key = buildCriticalCacheKey(targetLocale, normalizedPath);
|
|
299
|
-
try {
|
|
300
|
-
localStorage.setItem(key, JSON.stringify({
|
|
301
|
-
stored_at: Date.now(),
|
|
302
|
-
map: entry.map || {},
|
|
303
|
-
exclusions: entry.exclusions || [],
|
|
304
|
-
loading_bg_color: entry.loading_bg_color,
|
|
305
|
-
}));
|
|
306
|
-
}
|
|
307
|
-
catch {
|
|
308
|
-
// ignore
|
|
309
|
-
}
|
|
310
|
-
}, [buildCriticalCacheKey]);
|
|
311
|
-
const config = {
|
|
312
|
-
apiKey: resolvedApiKey,
|
|
313
|
-
publicAnonKey: resolvedApiKey,
|
|
314
|
-
defaultLocale,
|
|
315
|
-
locales: allLocales,
|
|
316
|
-
apiBase,
|
|
317
|
-
routing,
|
|
318
|
-
autoPrefixLinks,
|
|
319
|
-
overlayBgColor,
|
|
320
|
-
switcherPosition,
|
|
321
|
-
switcherOffsetY,
|
|
322
|
-
switcherTheme,
|
|
323
|
-
editMode: initialEditMode,
|
|
324
|
-
editKey,
|
|
325
|
-
pathNormalization,
|
|
326
|
-
mode,
|
|
327
|
-
autoApplyRules,
|
|
328
|
-
};
|
|
329
|
-
const setDocumentLocale = useCallback((nextLocale) => {
|
|
330
|
-
try {
|
|
331
|
-
const html = document.documentElement;
|
|
332
|
-
if (!html)
|
|
333
|
-
return;
|
|
334
|
-
html.setAttribute("lang", nextLocale);
|
|
335
|
-
const rtlLocales = new Set(["ar", "he", "fa", "ur"]);
|
|
336
|
-
html.setAttribute("dir", rtlLocales.has(nextLocale) ? "rtl" : "ltr");
|
|
337
|
-
}
|
|
338
|
-
catch {
|
|
339
|
-
// ignore
|
|
340
|
-
}
|
|
341
|
-
}, []);
|
|
342
|
-
const isSeoActive = useCallback(() => {
|
|
343
|
-
// Prop can force-disable SEO; server can also disable per project.
|
|
344
|
-
const serverEnabled = entitlements?.seoEnabled;
|
|
345
|
-
if (serverEnabled === false)
|
|
346
|
-
return false;
|
|
347
|
-
return seo !== false;
|
|
348
|
-
}, [entitlements, seo]);
|
|
349
|
-
// Marker engine: always mark full DOM content for deterministic pipeline extraction.
|
|
350
|
-
useEffect(() => {
|
|
351
|
-
const stop = startMarkerEngine({ throttleMs: 120 });
|
|
352
|
-
return () => stop();
|
|
353
|
-
}, []);
|
|
354
|
-
useEffect(() => {
|
|
355
|
-
return () => disablePrehide();
|
|
356
|
-
}, [disablePrehide]);
|
|
357
|
-
// Detect locale from URL or localStorage
|
|
358
|
-
const detectLocale = useCallback(() => {
|
|
359
|
-
// 1. Check URL first based on routing mode
|
|
360
|
-
if (routing === 'path') {
|
|
361
|
-
// Path mode: language is in path (/en/pricing, /fr/about)
|
|
362
|
-
const pathLocale = window.location.pathname.split('/')[1];
|
|
363
|
-
if (allLocales.includes(pathLocale)) {
|
|
364
|
-
return pathLocale;
|
|
365
|
-
}
|
|
366
|
-
}
|
|
367
|
-
else if (routing === 'query') {
|
|
368
|
-
// Query mode: language is in query param (/pricing?t=fr)
|
|
369
|
-
const params = new URLSearchParams(window.location.search);
|
|
370
|
-
const queryLocale = params.get('t') || params.get('locale');
|
|
371
|
-
if (queryLocale && allLocales.includes(queryLocale)) {
|
|
372
|
-
return queryLocale;
|
|
373
|
-
}
|
|
374
|
-
}
|
|
375
|
-
// 2. Check localStorage (fallback for all routing modes)
|
|
376
|
-
try {
|
|
377
|
-
const storedLocale = localStorage.getItem(LOCALE_STORAGE_KEY);
|
|
378
|
-
if (storedLocale && allLocales.includes(storedLocale)) {
|
|
379
|
-
return storedLocale;
|
|
380
|
-
}
|
|
381
|
-
}
|
|
382
|
-
catch (e) {
|
|
383
|
-
// localStorage might be unavailable (SSR, private browsing)
|
|
384
|
-
warnDebug('localStorage not available:', e);
|
|
385
|
-
}
|
|
386
|
-
// 3. Default locale
|
|
387
|
-
return defaultLocale;
|
|
388
|
-
}, [allLocales, defaultLocale, routing]);
|
|
389
|
-
// Fetch entitlements early so SEO can be enabled even on default locale
|
|
390
|
-
useEffect(() => {
|
|
391
|
-
if (locale !== defaultLocale)
|
|
392
|
-
return;
|
|
393
|
-
if (entitlements)
|
|
394
|
-
return;
|
|
395
|
-
let cancelled = false;
|
|
396
|
-
(async () => {
|
|
397
|
-
const bootstrap = await apiRef.current.fetchBootstrap(locale, window.location.pathname + window.location.search);
|
|
398
|
-
if (cancelled)
|
|
399
|
-
return;
|
|
400
|
-
if (bootstrap?.entitlements)
|
|
401
|
-
setEntitlements(bootstrap.entitlements);
|
|
402
|
-
if (bootstrap?.loading_bg_color)
|
|
403
|
-
setCachedLoadingBgColor(bootstrap.loading_bg_color);
|
|
404
|
-
if (bootstrap?.entitlements?.brandingRequired) {
|
|
405
|
-
setBrandingEnabled(true);
|
|
406
|
-
setCachedBrandingEnabled(true);
|
|
407
|
-
}
|
|
408
|
-
else if (typeof bootstrap?.branding_enabled === "boolean") {
|
|
409
|
-
setBrandingEnabled(bootstrap.branding_enabled);
|
|
410
|
-
setCachedBrandingEnabled(bootstrap.branding_enabled);
|
|
411
|
-
}
|
|
412
|
-
})();
|
|
413
|
-
return () => {
|
|
414
|
-
cancelled = true;
|
|
415
|
-
};
|
|
416
|
-
}, [defaultLocale, entitlements, locale, setCachedBrandingEnabled, setCachedLoadingBgColor]);
|
|
417
|
-
const applySeoBundle = useCallback((bundle, hreflangEnabled) => {
|
|
418
|
-
try {
|
|
419
|
-
const head = document.head;
|
|
420
|
-
if (!head)
|
|
421
|
-
return;
|
|
422
|
-
head.querySelectorAll('link[data-Lovalingo="hreflang"], link[data-Lovalingo="canonical"]').forEach((el) => el.remove());
|
|
423
|
-
if (!bundle)
|
|
424
|
-
return;
|
|
425
|
-
const seo = (bundle?.seo && typeof bundle.seo === "object" ? bundle.seo : {});
|
|
426
|
-
const alternates = (bundle?.alternates && typeof bundle.alternates === "object" ? bundle.alternates : {});
|
|
427
|
-
const setOrCreateMeta = (attrs, content) => {
|
|
428
|
-
const key = attrs.name ? `meta[name="${attrs.name}"]` : attrs.property ? `meta[property="${attrs.property}"]` : "";
|
|
429
|
-
const selector = key || "meta";
|
|
430
|
-
const existing = selector ? head.querySelector(selector) : null;
|
|
431
|
-
const el = existing || document.createElement("meta");
|
|
432
|
-
for (const [k, v] of Object.entries(attrs)) {
|
|
433
|
-
el.setAttribute(k, v);
|
|
434
|
-
}
|
|
435
|
-
el.setAttribute("content", content);
|
|
436
|
-
if (!existing)
|
|
437
|
-
head.appendChild(el);
|
|
438
|
-
};
|
|
439
|
-
const setOrCreateTitle = (value) => {
|
|
440
|
-
const existing = head.querySelector("title");
|
|
441
|
-
if (existing) {
|
|
442
|
-
existing.textContent = value;
|
|
443
|
-
return;
|
|
444
|
-
}
|
|
445
|
-
const el = document.createElement("title");
|
|
446
|
-
el.textContent = value;
|
|
447
|
-
head.appendChild(el);
|
|
448
|
-
};
|
|
449
|
-
const getString = (value) => (typeof value === "string" && value.trim() ? value.trim() : "");
|
|
450
|
-
const title = getString(seo.title);
|
|
451
|
-
if (title)
|
|
452
|
-
setOrCreateTitle(title);
|
|
453
|
-
const description = getString(seo.description);
|
|
454
|
-
if (description)
|
|
455
|
-
setOrCreateMeta({ name: "description" }, description);
|
|
456
|
-
const robots = getString(seo.robots);
|
|
457
|
-
if (robots)
|
|
458
|
-
setOrCreateMeta({ name: "robots" }, robots);
|
|
459
|
-
const ogTitle = getString(seo.og_title);
|
|
460
|
-
if (ogTitle)
|
|
461
|
-
setOrCreateMeta({ property: "og:title" }, ogTitle);
|
|
462
|
-
const ogDescription = getString(seo.og_description);
|
|
463
|
-
if (ogDescription)
|
|
464
|
-
setOrCreateMeta({ property: "og:description" }, ogDescription);
|
|
465
|
-
const ogImage = getString(seo.og_image);
|
|
466
|
-
if (ogImage)
|
|
467
|
-
setOrCreateMeta({ property: "og:image" }, ogImage);
|
|
468
|
-
const ogImageAlt = getString(seo.og_image_alt);
|
|
469
|
-
if (ogImageAlt)
|
|
470
|
-
setOrCreateMeta({ property: "og:image:alt" }, ogImageAlt);
|
|
471
|
-
const twitterCard = getString(seo.twitter_card);
|
|
472
|
-
if (twitterCard)
|
|
473
|
-
setOrCreateMeta({ name: "twitter:card" }, twitterCard);
|
|
474
|
-
const twitterTitle = getString(seo.twitter_title);
|
|
475
|
-
if (twitterTitle)
|
|
476
|
-
setOrCreateMeta({ name: "twitter:title" }, twitterTitle);
|
|
477
|
-
const twitterDescription = getString(seo.twitter_description);
|
|
478
|
-
if (twitterDescription)
|
|
479
|
-
setOrCreateMeta({ name: "twitter:description" }, twitterDescription);
|
|
480
|
-
const twitterImage = getString(seo.twitter_image);
|
|
481
|
-
if (twitterImage)
|
|
482
|
-
setOrCreateMeta({ name: "twitter:image" }, twitterImage);
|
|
483
|
-
const twitterImageAlt = getString(seo.twitter_image_alt);
|
|
484
|
-
if (twitterImageAlt)
|
|
485
|
-
setOrCreateMeta({ name: "twitter:image:alt" }, twitterImageAlt);
|
|
486
|
-
const canonicalHref = typeof seo.canonical_url === "string" && seo.canonical_url.trim()
|
|
487
|
-
? seo.canonical_url.trim()
|
|
488
|
-
: typeof alternates.canonical === "string" && alternates.canonical.trim()
|
|
489
|
-
? alternates.canonical.trim()
|
|
490
|
-
: "";
|
|
491
|
-
if (canonicalHref) {
|
|
492
|
-
const canonical = document.createElement("link");
|
|
493
|
-
canonical.rel = "canonical";
|
|
494
|
-
canonical.href = canonicalHref;
|
|
495
|
-
canonical.setAttribute("data-Lovalingo", "canonical");
|
|
496
|
-
head.appendChild(canonical);
|
|
497
|
-
}
|
|
498
|
-
if (!hreflangEnabled)
|
|
499
|
-
return;
|
|
500
|
-
const languages = alternates.languages && typeof alternates.languages === "object" ? alternates.languages : {};
|
|
501
|
-
for (const [lang, href] of Object.entries(languages)) {
|
|
502
|
-
if (!href)
|
|
503
|
-
continue;
|
|
504
|
-
const link = document.createElement("link");
|
|
505
|
-
link.rel = "alternate";
|
|
506
|
-
link.hreflang = lang;
|
|
507
|
-
link.href = href;
|
|
508
|
-
link.setAttribute("data-Lovalingo", "hreflang");
|
|
509
|
-
head.appendChild(link);
|
|
510
|
-
}
|
|
511
|
-
if (alternates.xDefault) {
|
|
512
|
-
const xDefault = document.createElement("link");
|
|
513
|
-
xDefault.rel = "alternate";
|
|
514
|
-
xDefault.hreflang = "x-default";
|
|
515
|
-
xDefault.href = alternates.xDefault;
|
|
516
|
-
xDefault.setAttribute("data-Lovalingo", "hreflang");
|
|
517
|
-
head.appendChild(xDefault);
|
|
518
|
-
}
|
|
519
|
-
}
|
|
520
|
-
catch {
|
|
521
|
-
// ignore SEO errors
|
|
522
|
-
}
|
|
523
|
-
}, []);
|
|
524
|
-
// Keep <html lang> in sync and apply default-locale SEO (non-default locales use the bootstrap payload).
|
|
525
|
-
useEffect(() => {
|
|
526
|
-
setDocumentLocale(locale);
|
|
527
|
-
if (locale !== defaultLocale)
|
|
528
|
-
return;
|
|
529
|
-
if (!isSeoActive())
|
|
530
|
-
return;
|
|
531
|
-
void apiRef.current.fetchSeoBundle(locale).then((bundle) => {
|
|
532
|
-
applySeoBundle(bundle, Boolean(entitlements?.hreflangEnabled));
|
|
533
|
-
});
|
|
534
|
-
}, [applySeoBundle, defaultLocale, entitlements, isSeoActive, locale, setDocumentLocale]);
|
|
535
|
-
const toTranslations = useCallback((map, targetLocale) => {
|
|
536
|
-
const out = [];
|
|
537
|
-
for (const [source_text, translated_text] of Object.entries(map || {})) {
|
|
538
|
-
if (!source_text || !translated_text)
|
|
539
|
-
continue;
|
|
540
|
-
out.push({
|
|
541
|
-
source_text,
|
|
542
|
-
translated_text,
|
|
543
|
-
source_locale: defaultLocale,
|
|
544
|
-
target_locale: targetLocale,
|
|
545
|
-
});
|
|
546
|
-
}
|
|
547
|
-
return out;
|
|
548
|
-
}, [defaultLocale]);
|
|
549
|
-
const loadData = useCallback(async (targetLocale, previousLocale) => {
|
|
550
|
-
// Cancel any pending retry scan to prevent race conditions
|
|
551
|
-
if (retryTimeoutRef.current) {
|
|
552
|
-
clearTimeout(retryTimeoutRef.current);
|
|
553
|
-
retryTimeoutRef.current = null;
|
|
554
|
-
}
|
|
555
|
-
if (loadingFailsafeTimeoutRef.current != null) {
|
|
556
|
-
window.clearTimeout(loadingFailsafeTimeoutRef.current);
|
|
557
|
-
loadingFailsafeTimeoutRef.current = null;
|
|
558
|
-
}
|
|
559
|
-
// If switching to default locale, clear translations and translate with empty map
|
|
560
|
-
// This will show original text using stored data-Lovalingo-original-html
|
|
561
|
-
if (targetLocale === defaultLocale) {
|
|
562
|
-
disablePrehide();
|
|
563
|
-
setActiveTranslations(null);
|
|
564
|
-
restoreDom(document.body); // React-safe: only text/attrs, no DOM structure mutation
|
|
565
|
-
isNavigatingRef.current = false;
|
|
566
|
-
return;
|
|
567
|
-
}
|
|
568
|
-
if (routing === "path") {
|
|
569
|
-
const stripped = stripLocalePrefix(window.location.pathname, allLocales);
|
|
570
|
-
if (isNonLocalizedPath(stripped, routingConfig.nonLocalizedPaths)) {
|
|
571
|
-
// Why: auth/admin (non-localized) routes must never be blocked or mutated by the translation runtime.
|
|
572
|
-
disablePrehide();
|
|
573
|
-
setActiveTranslations(null);
|
|
574
|
-
restoreDom(document.body);
|
|
575
|
-
isNavigatingRef.current = false;
|
|
576
|
-
return;
|
|
577
|
-
}
|
|
578
|
-
}
|
|
579
|
-
const currentPath = window.location.pathname + window.location.search;
|
|
580
|
-
const normalizedPath = processPath(window.location.pathname, enhancedPathConfig);
|
|
581
|
-
const cacheKey = `${targetLocale}:${normalizedPath}`;
|
|
582
|
-
if (inFlightLoadKeyRef.current === cacheKey) {
|
|
583
|
-
return;
|
|
584
|
-
}
|
|
585
|
-
inFlightLoadKeyRef.current = cacheKey;
|
|
586
|
-
// Check if we have cached translations for this locale + path
|
|
587
|
-
const cachedEntry = translationCacheRef.current.get(cacheKey);
|
|
588
|
-
const cachedExclusions = exclusionsCacheRef.current;
|
|
589
|
-
const cachedDomRules = domRulesCacheRef.current.get(cacheKey);
|
|
590
|
-
if (cachedEntry && cachedExclusions) {
|
|
591
|
-
// CACHE HIT - Use cached data immediately (FAST!)
|
|
592
|
-
logDebug(`[Lovalingo] Using cached translations for ${targetLocale} on ${normalizedPath}`);
|
|
593
|
-
enablePrehide(getCachedLoadingBgColor());
|
|
594
|
-
setActiveTranslations(cachedEntry.translations);
|
|
595
|
-
setMarkerEngineExclusions(cachedExclusions);
|
|
596
|
-
if (mode === 'dom') {
|
|
597
|
-
applyActiveTranslations(document.body);
|
|
598
|
-
}
|
|
599
|
-
if (autoApplyRules) {
|
|
600
|
-
if (Array.isArray(cachedDomRules)) {
|
|
601
|
-
applyDomRules(cachedDomRules);
|
|
602
|
-
}
|
|
603
|
-
void (async () => {
|
|
604
|
-
const rules = await apiRef.current.fetchDomRules(targetLocale);
|
|
605
|
-
domRulesCacheRef.current.set(cacheKey, rules);
|
|
606
|
-
applyDomRules(rules);
|
|
607
|
-
})();
|
|
608
|
-
}
|
|
609
|
-
// Delayed retry scan to catch late-rendering content
|
|
610
|
-
retryTimeoutRef.current = setTimeout(() => {
|
|
611
|
-
// Don't scan if we're navigating (prevents React conflicts)
|
|
612
|
-
if (isNavigatingRef.current) {
|
|
613
|
-
return;
|
|
614
|
-
}
|
|
615
|
-
logDebug(`[Lovalingo] 🔄 Retry scan for late-rendering content`);
|
|
616
|
-
if (mode === 'dom') {
|
|
617
|
-
applyActiveTranslations(document.body);
|
|
618
|
-
}
|
|
619
|
-
if (autoApplyRules) {
|
|
620
|
-
const rules = domRulesCacheRef.current.get(cacheKey) || cachedDomRules || [];
|
|
621
|
-
applyDomRules(rules);
|
|
622
|
-
}
|
|
623
|
-
}, 500);
|
|
624
|
-
disablePrehide();
|
|
625
|
-
isNavigatingRef.current = false;
|
|
626
|
-
if (inFlightLoadKeyRef.current === cacheKey) {
|
|
627
|
-
inFlightLoadKeyRef.current = null;
|
|
628
|
-
}
|
|
629
|
-
return;
|
|
630
|
-
}
|
|
631
|
-
// CACHE MISS - Fetch from API
|
|
632
|
-
logDebug(`[Lovalingo] Fetching translations for ${targetLocale} on ${normalizedPath}`);
|
|
633
|
-
setIsLoading(true);
|
|
634
|
-
enablePrehide(getCachedLoadingBgColor());
|
|
635
|
-
// Why: never keep the app hidden/blocked for longer than the UX budget; show the original content if translations aren't ready fast.
|
|
636
|
-
loadingFailsafeTimeoutRef.current = window.setTimeout(() => {
|
|
637
|
-
disablePrehide();
|
|
638
|
-
setIsLoading(false);
|
|
639
|
-
}, PREHIDE_FAILSAFE_MS);
|
|
640
|
-
try {
|
|
641
|
-
if (previousLocale && previousLocale !== defaultLocale) {
|
|
642
|
-
logDebug(`[Lovalingo] Switching from ${previousLocale} to ${targetLocale}`);
|
|
643
|
-
}
|
|
644
|
-
let revealedViaCachedCritical = false;
|
|
645
|
-
const cachedCritical = readCriticalCache(targetLocale, normalizedPath);
|
|
646
|
-
if (cachedCritical?.loading_bg_color) {
|
|
647
|
-
setCachedLoadingBgColor(cachedCritical.loading_bg_color);
|
|
648
|
-
enablePrehide(cachedCritical.loading_bg_color);
|
|
649
|
-
}
|
|
650
|
-
if (cachedCritical?.exclusions && cachedCritical.exclusions.length > 0) {
|
|
651
|
-
exclusionsCacheRef.current = cachedCritical.exclusions;
|
|
652
|
-
setMarkerEngineExclusions(cachedCritical.exclusions);
|
|
653
|
-
}
|
|
654
|
-
if (cachedCritical?.map && Object.keys(cachedCritical.map).length > 0) {
|
|
655
|
-
setActiveTranslations(toTranslations(cachedCritical.map, targetLocale));
|
|
656
|
-
if (mode === "dom") {
|
|
657
|
-
applyActiveTranslations(document.body);
|
|
658
|
-
}
|
|
659
|
-
disablePrehide();
|
|
660
|
-
revealedViaCachedCritical = true;
|
|
661
|
-
}
|
|
662
|
-
const bootstrap = await apiRef.current.fetchBootstrap(targetLocale, currentPath);
|
|
663
|
-
const nextEntitlements = bootstrap?.entitlements || apiRef.current.getEntitlements();
|
|
664
|
-
if (nextEntitlements)
|
|
665
|
-
setEntitlements(nextEntitlements);
|
|
666
|
-
if (bootstrap?.loading_bg_color) {
|
|
667
|
-
setCachedLoadingBgColor(bootstrap.loading_bg_color);
|
|
668
|
-
enablePrehide(bootstrap.loading_bg_color);
|
|
669
|
-
}
|
|
670
|
-
if ((bootstrap?.entitlements || nextEntitlements)?.brandingRequired) {
|
|
671
|
-
setBrandingEnabled(true);
|
|
672
|
-
setCachedBrandingEnabled(true);
|
|
673
|
-
}
|
|
674
|
-
else if (typeof bootstrap?.branding_enabled === "boolean") {
|
|
675
|
-
setBrandingEnabled(bootstrap.branding_enabled);
|
|
676
|
-
setCachedBrandingEnabled(bootstrap.branding_enabled);
|
|
677
|
-
}
|
|
678
|
-
const exclusions = Array.isArray(bootstrap?.exclusions)
|
|
679
|
-
? bootstrap.exclusions
|
|
680
|
-
.map((row) => {
|
|
681
|
-
if (!row || typeof row !== "object")
|
|
682
|
-
return null;
|
|
683
|
-
const r = row;
|
|
684
|
-
const selector = typeof r.selector === "string" ? r.selector.trim() : "";
|
|
685
|
-
const type = typeof r.type === "string" ? r.type.trim() : "";
|
|
686
|
-
if (!selector)
|
|
687
|
-
return null;
|
|
688
|
-
if (type !== "css" && type !== "xpath")
|
|
689
|
-
return null;
|
|
690
|
-
return { selector, type: type };
|
|
691
|
-
})
|
|
692
|
-
.filter(Boolean)
|
|
693
|
-
: await apiRef.current.fetchExclusions();
|
|
694
|
-
exclusionsCacheRef.current = exclusions;
|
|
695
|
-
setMarkerEngineExclusions(exclusions);
|
|
696
|
-
const criticalMap = bootstrap?.critical?.map && typeof bootstrap.critical.map === "object" && !Array.isArray(bootstrap.critical.map)
|
|
697
|
-
? bootstrap.critical.map
|
|
698
|
-
: {};
|
|
699
|
-
const hasBootstrapCritical = Object.keys(criticalMap).length > 0;
|
|
700
|
-
if (Object.keys(criticalMap).length > 0) {
|
|
701
|
-
setActiveTranslations(toTranslations(criticalMap, targetLocale));
|
|
702
|
-
if (mode === "dom") {
|
|
703
|
-
applyActiveTranslations(document.body);
|
|
704
|
-
}
|
|
705
|
-
}
|
|
706
|
-
if (autoApplyRules) {
|
|
707
|
-
const domRules = Array.isArray(bootstrap?.dom_rules) ? bootstrap.dom_rules : await apiRef.current.fetchDomRules(targetLocale);
|
|
708
|
-
domRulesCacheRef.current.set(cacheKey, domRules);
|
|
709
|
-
applyDomRules(domRules);
|
|
710
|
-
}
|
|
711
|
-
if (isSeoActive() && bootstrap) {
|
|
712
|
-
const hreflangEnabled = Boolean((bootstrap.entitlements || nextEntitlements)?.hreflangEnabled);
|
|
713
|
-
applySeoBundle({ seo: bootstrap.seo, alternates: bootstrap.alternates, jsonld: bootstrap.jsonld }, hreflangEnabled);
|
|
714
|
-
}
|
|
715
|
-
writeCriticalCache(targetLocale, normalizedPath, {
|
|
716
|
-
map: criticalMap,
|
|
717
|
-
exclusions,
|
|
718
|
-
loading_bg_color: bootstrap?.loading_bg_color && /^#[0-9a-fA-F]{6}$/.test(bootstrap.loading_bg_color) ? bootstrap.loading_bg_color : null,
|
|
719
|
-
});
|
|
720
|
-
const shouldWaitForBundle = !revealedViaCachedCritical && !hasBootstrapCritical;
|
|
721
|
-
if (shouldWaitForBundle) {
|
|
722
|
-
// Why: if there's no critical slice for first paint, wait for the bundle (within the prehide failsafe) to avoid a visible flash.
|
|
723
|
-
const bundle = await apiRef.current.fetchBundle(targetLocale, currentPath);
|
|
724
|
-
if (bundle?.map && typeof bundle.map === "object") {
|
|
725
|
-
const translations = toTranslations(bundle.map, targetLocale);
|
|
726
|
-
if (translations.length > 0) {
|
|
727
|
-
translationCacheRef.current.set(cacheKey, { translations });
|
|
728
|
-
setActiveTranslations(translations);
|
|
729
|
-
if (mode === "dom") {
|
|
730
|
-
applyActiveTranslations(document.body);
|
|
731
|
-
}
|
|
732
|
-
}
|
|
733
|
-
}
|
|
734
|
-
}
|
|
735
|
-
else {
|
|
736
|
-
// Lazy-load the full page bundle after first paint.
|
|
737
|
-
void (async () => {
|
|
738
|
-
const bundle = await apiRef.current.fetchBundle(targetLocale, currentPath);
|
|
739
|
-
if (!bundle || !bundle.map)
|
|
740
|
-
return;
|
|
741
|
-
const translations = toTranslations(bundle.map, targetLocale);
|
|
742
|
-
if (translations.length === 0)
|
|
743
|
-
return;
|
|
744
|
-
translationCacheRef.current.set(cacheKey, { translations });
|
|
745
|
-
setActiveTranslations(translations);
|
|
746
|
-
if (mode === "dom") {
|
|
747
|
-
applyActiveTranslations(document.body);
|
|
748
|
-
}
|
|
749
|
-
})();
|
|
750
|
-
}
|
|
751
|
-
disablePrehide();
|
|
752
|
-
// Delayed retry scan to catch late-rendering content
|
|
753
|
-
retryTimeoutRef.current = setTimeout(() => {
|
|
754
|
-
// Don't scan if we're navigating (prevents React conflicts)
|
|
755
|
-
if (isNavigatingRef.current) {
|
|
756
|
-
return;
|
|
757
|
-
}
|
|
758
|
-
logDebug(`[Lovalingo] 🔄 Retry scan for late-rendering content`);
|
|
759
|
-
if (mode === "dom") {
|
|
760
|
-
applyActiveTranslations(document.body);
|
|
761
|
-
}
|
|
762
|
-
if (autoApplyRules) {
|
|
763
|
-
const rules = domRulesCacheRef.current.get(cacheKey) || [];
|
|
764
|
-
applyDomRules(rules);
|
|
765
|
-
}
|
|
766
|
-
}, 500);
|
|
767
|
-
}
|
|
768
|
-
catch (error) {
|
|
769
|
-
errorDebug('Error loading translations:', error);
|
|
770
|
-
disablePrehide();
|
|
771
|
-
}
|
|
772
|
-
finally {
|
|
773
|
-
setIsLoading(false);
|
|
774
|
-
if (loadingFailsafeTimeoutRef.current != null) {
|
|
775
|
-
window.clearTimeout(loadingFailsafeTimeoutRef.current);
|
|
776
|
-
loadingFailsafeTimeoutRef.current = null;
|
|
777
|
-
}
|
|
778
|
-
isNavigatingRef.current = false;
|
|
779
|
-
if (inFlightLoadKeyRef.current === cacheKey) {
|
|
780
|
-
inFlightLoadKeyRef.current = null;
|
|
781
|
-
}
|
|
782
|
-
}
|
|
783
|
-
}, [
|
|
784
|
-
applySeoBundle,
|
|
785
|
-
allLocales,
|
|
786
|
-
autoApplyRules,
|
|
787
|
-
defaultLocale,
|
|
788
|
-
disablePrehide,
|
|
789
|
-
enablePrehide,
|
|
790
|
-
enhancedPathConfig,
|
|
791
|
-
getCachedLoadingBgColor,
|
|
792
|
-
isSeoActive,
|
|
793
|
-
mode,
|
|
794
|
-
readCriticalCache,
|
|
795
|
-
routing,
|
|
796
|
-
routingConfig.nonLocalizedPaths,
|
|
797
|
-
setCachedLoadingBgColor,
|
|
798
|
-
toTranslations,
|
|
799
|
-
writeCriticalCache,
|
|
800
|
-
]);
|
|
801
|
-
useEffect(() => {
|
|
802
|
-
onNavigateRef.current = () => {
|
|
803
|
-
trackPageviewOnce(window.location.pathname + window.location.search);
|
|
804
|
-
const nextLocale = detectLocale();
|
|
805
|
-
const normalizedPath = processPath(window.location.pathname, enhancedPathConfig);
|
|
806
|
-
const normalizedPathChanged = normalizedPath !== lastNormalizedPathRef.current;
|
|
807
|
-
lastNormalizedPathRef.current = normalizedPath;
|
|
808
|
-
// Why: bundles are path-scoped, so SPA navigations within the same locale must trigger a reload for the new route.
|
|
809
|
-
if (normalizedPathChanged && nextLocale !== defaultLocale && !isInternalNavigationRef.current) {
|
|
810
|
-
void loadData(nextLocale, locale);
|
|
811
|
-
return;
|
|
812
|
-
}
|
|
813
|
-
if (nextLocale !== locale) {
|
|
814
|
-
setLocaleState(nextLocale);
|
|
815
|
-
if (!isInternalNavigationRef.current) {
|
|
816
|
-
void loadData(nextLocale, locale);
|
|
817
|
-
}
|
|
818
|
-
}
|
|
819
|
-
else if (mode === "dom" && nextLocale !== defaultLocale) {
|
|
820
|
-
applyActiveTranslations(document.body);
|
|
821
|
-
}
|
|
822
|
-
};
|
|
823
|
-
}, [defaultLocale, detectLocale, enhancedPathConfig, loadData, locale, mode, trackPageviewOnce]);
|
|
824
|
-
// SPA router hook-in: patch History API once (prevents stacked wrappers → request storms).
|
|
825
|
-
useEffect(() => {
|
|
826
|
-
if (typeof window === "undefined")
|
|
827
|
-
return;
|
|
828
|
-
if (historyPatchedRef.current)
|
|
829
|
-
return;
|
|
830
|
-
historyPatchedRef.current = true;
|
|
831
|
-
const historyObj = window.history;
|
|
832
|
-
const originalPushState = historyObj.pushState.bind(historyObj);
|
|
833
|
-
const originalReplaceState = historyObj.replaceState.bind(historyObj);
|
|
834
|
-
originalHistoryRef.current = { pushState: originalPushState, replaceState: originalReplaceState };
|
|
835
|
-
const safeOnNavigate = () => {
|
|
836
|
-
try {
|
|
837
|
-
onNavigateRef.current();
|
|
838
|
-
}
|
|
839
|
-
catch {
|
|
840
|
-
// ignore
|
|
841
|
-
}
|
|
842
|
-
};
|
|
843
|
-
historyObj.pushState = ((...args) => {
|
|
844
|
-
const ret = originalPushState(...args);
|
|
845
|
-
safeOnNavigate();
|
|
846
|
-
return ret;
|
|
847
|
-
});
|
|
848
|
-
historyObj.replaceState = ((...args) => {
|
|
849
|
-
const ret = originalReplaceState(...args);
|
|
850
|
-
safeOnNavigate();
|
|
851
|
-
return ret;
|
|
852
|
-
});
|
|
853
|
-
window.addEventListener("popstate", safeOnNavigate);
|
|
854
|
-
window.addEventListener("hashchange", safeOnNavigate);
|
|
855
|
-
return () => {
|
|
856
|
-
const originals = originalHistoryRef.current;
|
|
857
|
-
if (originals) {
|
|
858
|
-
historyObj.pushState = originals.pushState;
|
|
859
|
-
historyObj.replaceState = originals.replaceState;
|
|
860
|
-
}
|
|
861
|
-
window.removeEventListener("popstate", safeOnNavigate);
|
|
862
|
-
window.removeEventListener("hashchange", safeOnNavigate);
|
|
863
|
-
originalHistoryRef.current = null;
|
|
864
|
-
historyPatchedRef.current = false;
|
|
865
|
-
};
|
|
866
|
-
}, []);
|
|
867
|
-
// Change locale
|
|
868
|
-
const setLocale = useCallback((newLocale) => {
|
|
869
|
-
void (async () => {
|
|
870
|
-
if (!allLocales.includes(newLocale))
|
|
871
|
-
return;
|
|
872
|
-
const previousLocale = locale; // Capture current locale before switching
|
|
873
|
-
// Save to localStorage
|
|
874
|
-
try {
|
|
875
|
-
localStorage.setItem(LOCALE_STORAGE_KEY, newLocale);
|
|
876
|
-
}
|
|
877
|
-
catch (e) {
|
|
878
|
-
warnDebug('Failed to save locale to localStorage:', e);
|
|
879
|
-
}
|
|
880
|
-
isInternalNavigationRef.current = true;
|
|
881
|
-
// Prevent MutationObserver work during the switch to avoid React conflicts
|
|
882
|
-
isNavigatingRef.current = true;
|
|
883
|
-
// Update URL based on routing strategy
|
|
884
|
-
if (routing === 'path') {
|
|
885
|
-
const stripped = stripLocalePrefix(window.location.pathname, allLocales);
|
|
886
|
-
if (isNonLocalizedPath(stripped, routingConfig.nonLocalizedPaths)) {
|
|
887
|
-
// Why: switching languages must not rewrite non-localized routes like "/auth" to "/de/auth".
|
|
888
|
-
setLocaleState(newLocale);
|
|
889
|
-
isNavigatingRef.current = false;
|
|
890
|
-
return;
|
|
891
|
-
}
|
|
892
|
-
const pathParts = window.location.pathname.split('/').filter(Boolean);
|
|
893
|
-
// Strip existing locale
|
|
894
|
-
if (allLocales.includes(pathParts[0])) {
|
|
895
|
-
pathParts.shift();
|
|
896
|
-
}
|
|
897
|
-
// Build new path with new locale
|
|
898
|
-
const basePath = pathParts.join('/');
|
|
899
|
-
const newPath = `/${newLocale}${basePath ? '/' + basePath : ''}${window.location.search}${window.location.hash}`;
|
|
900
|
-
// Prefer React Router navigation when available, but gracefully fallback for non-React-Router apps
|
|
901
|
-
const navigate = navigateRef?.current;
|
|
902
|
-
if (navigate) {
|
|
903
|
-
navigate(newPath);
|
|
904
|
-
}
|
|
905
|
-
else {
|
|
906
|
-
try {
|
|
907
|
-
window.history.pushState({}, '', newPath);
|
|
908
|
-
}
|
|
909
|
-
catch {
|
|
910
|
-
window.location.assign(newPath);
|
|
911
|
-
}
|
|
912
|
-
}
|
|
913
|
-
}
|
|
914
|
-
else if (routing === 'query') {
|
|
915
|
-
const url = new URL(window.location.href);
|
|
916
|
-
url.searchParams.set('t', newLocale);
|
|
917
|
-
window.history.pushState({}, '', url.toString());
|
|
918
|
-
}
|
|
919
|
-
setLocaleState(newLocale);
|
|
920
|
-
await loadData(newLocale, previousLocale);
|
|
921
|
-
})().finally(() => {
|
|
922
|
-
isInternalNavigationRef.current = false;
|
|
923
|
-
});
|
|
924
|
-
}, [allLocales, defaultLocale, locale, routing, loadData, navigateRef, routingConfig.nonLocalizedPaths]);
|
|
925
|
-
// No string-level "misses" from the client: the pipeline (Browser Rendering) is source-of-truth for string discovery.
|
|
926
|
-
// Why: prevent init/load effects from re-running (and calling bootstrap/bundle again) when loadData changes due to state updates.
|
|
927
|
-
const loadDataRef = useRef(loadData);
|
|
928
|
-
useEffect(() => {
|
|
929
|
-
loadDataRef.current = loadData;
|
|
930
|
-
}, [loadData]);
|
|
931
|
-
const detectLocaleRef = useRef(detectLocale);
|
|
932
|
-
useEffect(() => {
|
|
933
|
-
detectLocaleRef.current = detectLocale;
|
|
934
|
-
}, [detectLocale]);
|
|
935
|
-
// Initialize
|
|
936
|
-
useEffect(() => {
|
|
937
|
-
const initialLocale = detectLocaleRef.current();
|
|
938
|
-
lastNormalizedPathRef.current = processPath(window.location.pathname, enhancedPathConfig);
|
|
939
|
-
// Track initial page (fallback discovery for pages not present in the routes feed).
|
|
940
|
-
trackPageviewOnce(window.location.pathname + window.location.search);
|
|
941
|
-
// Always prefetch artifacts for the initial locale (pipeline-produced translations + rules).
|
|
942
|
-
loadDataRef.current(initialLocale);
|
|
943
|
-
// Set up keyboard shortcut for edit mode
|
|
944
|
-
const handleKeyPress = (e) => {
|
|
945
|
-
if (e.code === editKey && (e.ctrlKey || e.metaKey)) {
|
|
946
|
-
e.preventDefault();
|
|
947
|
-
setEditMode(prev => !prev);
|
|
948
|
-
}
|
|
949
|
-
};
|
|
950
|
-
window.addEventListener('keydown', handleKeyPress);
|
|
951
|
-
return () => {
|
|
952
|
-
window.removeEventListener('keydown', handleKeyPress);
|
|
953
|
-
// Clean up pending retry timeout
|
|
954
|
-
if (retryTimeoutRef.current) {
|
|
955
|
-
clearTimeout(retryTimeoutRef.current);
|
|
956
|
-
}
|
|
957
|
-
};
|
|
958
|
-
}, [editKey, enhancedPathConfig, trackPageviewOnce]);
|
|
959
|
-
// Auto-inject sitemap link tag
|
|
960
|
-
useEffect(() => {
|
|
961
|
-
if (sitemap && resolvedApiKey && isSeoActive()) {
|
|
962
|
-
// Prefer same-origin /sitemap.xml so crawlers discover the canonical sitemap URL.
|
|
963
|
-
// Reminder: /sitemap.xml should be published by the host app (recommended: build-time copy from Lovalingo CDN).
|
|
964
|
-
const sitemapUrl = `${window.location.origin}/sitemap.xml`;
|
|
965
|
-
// Check if link already exists to avoid duplicates
|
|
966
|
-
const existingLink = document.querySelector(`link[rel="sitemap"][href="${sitemapUrl}"]`);
|
|
967
|
-
if (existingLink)
|
|
968
|
-
return;
|
|
969
|
-
// Create and inject link tag
|
|
970
|
-
const link = document.createElement('link');
|
|
971
|
-
link.rel = 'sitemap';
|
|
972
|
-
link.type = 'application/xml';
|
|
973
|
-
link.href = sitemapUrl;
|
|
974
|
-
document.head.appendChild(link);
|
|
975
|
-
// Cleanup on unmount
|
|
976
|
-
return () => {
|
|
977
|
-
const linkToRemove = document.querySelector(`link[rel="sitemap"][href="${sitemapUrl}"]`);
|
|
978
|
-
if (linkToRemove) {
|
|
979
|
-
document.head.removeChild(linkToRemove);
|
|
980
|
-
}
|
|
981
|
-
};
|
|
982
|
-
}
|
|
983
|
-
}, [sitemap, resolvedApiKey, apiBase, isSeoActive]);
|
|
984
|
-
// PATH mode: auto-prefix internal links that are missing a locale segment.
|
|
985
|
-
// This prevents "losing" the current locale when the app renders absolute links like "/projects/slug"
|
|
986
|
-
// while the user is on "/de/...".
|
|
987
|
-
useEffect(() => {
|
|
988
|
-
if (routing !== 'path')
|
|
989
|
-
return;
|
|
990
|
-
if (!autoPrefixLinks)
|
|
991
|
-
return;
|
|
992
|
-
const supportedLocales = allLocales;
|
|
993
|
-
const shouldProcessCurrentPath = () => {
|
|
994
|
-
const parts = window.location.pathname.split('/').filter(Boolean);
|
|
995
|
-
return parts.length > 0 && supportedLocales.includes(parts[0]);
|
|
996
|
-
};
|
|
997
|
-
const buildLocalePrefixedPath = (rawHref) => {
|
|
998
|
-
if (!rawHref)
|
|
999
|
-
return null;
|
|
1000
|
-
const trimmed = rawHref.trim();
|
|
1001
|
-
if (!trimmed)
|
|
1002
|
-
return null;
|
|
1003
|
-
// Only rewrite absolute-path or same-origin absolute URLs.
|
|
1004
|
-
const isAbsolutePath = trimmed.startsWith('/');
|
|
1005
|
-
const isAbsoluteUrl = /^https?:\/\//i.test(trimmed) || trimmed.startsWith('//');
|
|
1006
|
-
if (!isAbsolutePath && !isAbsoluteUrl)
|
|
1007
|
-
return null;
|
|
1008
|
-
// Ignore special schemes / fragments
|
|
1009
|
-
if (/^(?:#|mailto:|tel:|sms:|javascript:)/i.test(trimmed))
|
|
1010
|
-
return null;
|
|
1011
|
-
let url;
|
|
1012
|
-
try {
|
|
1013
|
-
url = new URL(trimmed, window.location.origin);
|
|
1014
|
-
}
|
|
1015
|
-
catch {
|
|
1016
|
-
return null;
|
|
1017
|
-
}
|
|
1018
|
-
if (url.origin !== window.location.origin)
|
|
1019
|
-
return null;
|
|
1020
|
-
if (isNonLocalizedPath(url.pathname, routingConfig.nonLocalizedPaths))
|
|
1021
|
-
return null;
|
|
1022
|
-
const parts = url.pathname.split('/').filter(Boolean);
|
|
1023
|
-
// Root ("/") should be locale-prefixed too (e.g. clicking a logo linking to "https://example.com")
|
|
1024
|
-
// when we are currently on a locale URL like "/de/...".
|
|
1025
|
-
if (parts.length === 0) {
|
|
1026
|
-
return `/${locale}${url.search}${url.hash}`;
|
|
1027
|
-
}
|
|
1028
|
-
if (supportedLocales.includes(parts[0]))
|
|
1029
|
-
return null; // already locale-prefixed
|
|
1030
|
-
const pathWithoutLeadingSlashes = url.pathname.replace(/^\/+/, '');
|
|
1031
|
-
const nextPathname = pathWithoutLeadingSlashes
|
|
1032
|
-
? `/${locale}/${pathWithoutLeadingSlashes}`
|
|
1033
|
-
: `/${locale}`;
|
|
1034
|
-
return `${nextPathname}${url.search}${url.hash}`;
|
|
1035
|
-
};
|
|
1036
|
-
const ORIGINAL_HREF_KEY = 'data-Lovalingo-href-original';
|
|
1037
|
-
const patchAnchor = (a) => {
|
|
1038
|
-
if (!a || a.hasAttribute('data-Lovalingo-exclude'))
|
|
1039
|
-
return;
|
|
1040
|
-
const original = a.getAttribute(ORIGINAL_HREF_KEY) ?? a.getAttribute('href') ?? '';
|
|
1041
|
-
if (!a.getAttribute(ORIGINAL_HREF_KEY) && original) {
|
|
1042
|
-
a.setAttribute(ORIGINAL_HREF_KEY, original);
|
|
1043
|
-
}
|
|
1044
|
-
const fixed = buildLocalePrefixedPath(original);
|
|
1045
|
-
if (fixed) {
|
|
1046
|
-
if (a.getAttribute('href') !== fixed)
|
|
1047
|
-
a.setAttribute('href', fixed);
|
|
1048
|
-
}
|
|
1049
|
-
else if (original) {
|
|
1050
|
-
// If we previously rewrote it, restore the original when it no longer applies.
|
|
1051
|
-
if (a.getAttribute('href') !== original)
|
|
1052
|
-
a.setAttribute('href', original);
|
|
1053
|
-
}
|
|
1054
|
-
};
|
|
1055
|
-
const patchAllAnchors = () => {
|
|
1056
|
-
if (!shouldProcessCurrentPath())
|
|
1057
|
-
return;
|
|
1058
|
-
document.querySelectorAll('a[href]').forEach((node) => {
|
|
1059
|
-
if (node instanceof HTMLAnchorElement)
|
|
1060
|
-
patchAnchor(node);
|
|
1061
|
-
});
|
|
1062
|
-
};
|
|
1063
|
-
// Patch existing anchors (also updates when locale changes)
|
|
1064
|
-
patchAllAnchors();
|
|
1065
|
-
// Patch new anchors when the DOM changes
|
|
1066
|
-
const mo = new MutationObserver((mutations) => {
|
|
1067
|
-
if (!shouldProcessCurrentPath())
|
|
1068
|
-
return;
|
|
1069
|
-
for (const mutation of mutations) {
|
|
1070
|
-
mutation.addedNodes.forEach((node) => {
|
|
1071
|
-
if (!(node instanceof HTMLElement))
|
|
1072
|
-
return;
|
|
1073
|
-
if (node instanceof HTMLAnchorElement) {
|
|
1074
|
-
patchAnchor(node);
|
|
1075
|
-
return;
|
|
1076
|
-
}
|
|
1077
|
-
node.querySelectorAll?.('a[href]').forEach((a) => {
|
|
1078
|
-
if (a instanceof HTMLAnchorElement)
|
|
1079
|
-
patchAnchor(a);
|
|
1080
|
-
});
|
|
1081
|
-
});
|
|
1082
|
-
}
|
|
1083
|
-
});
|
|
1084
|
-
mo.observe(document.body, { childList: true, subtree: true });
|
|
1085
|
-
// Click interception (capture) to handle cases where frameworks (e.g. React Router <Link>)
|
|
1086
|
-
// navigate based on their "to" prop rather than the DOM href attribute.
|
|
1087
|
-
const onClickCapture = (event) => {
|
|
1088
|
-
if (!shouldProcessCurrentPath())
|
|
1089
|
-
return;
|
|
1090
|
-
if (event.defaultPrevented)
|
|
1091
|
-
return;
|
|
1092
|
-
if (event.button !== 0)
|
|
1093
|
-
return;
|
|
1094
|
-
if (event.metaKey || event.ctrlKey || event.shiftKey || event.altKey)
|
|
1095
|
-
return;
|
|
1096
|
-
const target = event.target;
|
|
1097
|
-
const a = target?.closest?.('a[href]');
|
|
1098
|
-
if (!a)
|
|
1099
|
-
return;
|
|
1100
|
-
// Let the browser handle new tabs/downloads/etc.
|
|
1101
|
-
if (a.target && a.target !== '_self')
|
|
1102
|
-
return;
|
|
1103
|
-
if (a.hasAttribute('download'))
|
|
1104
|
-
return;
|
|
1105
|
-
if (a.getAttribute('rel')?.includes('external'))
|
|
1106
|
-
return;
|
|
1107
|
-
const original = a.getAttribute(ORIGINAL_HREF_KEY) ?? a.getAttribute('href') ?? '';
|
|
1108
|
-
const fixed = buildLocalePrefixedPath(original);
|
|
1109
|
-
if (!fixed)
|
|
1110
|
-
return;
|
|
1111
|
-
event.preventDefault();
|
|
1112
|
-
event.stopImmediatePropagation();
|
|
1113
|
-
event.stopPropagation();
|
|
1114
|
-
const navigate = navigateRef?.current;
|
|
1115
|
-
if (navigate) {
|
|
1116
|
-
navigate(fixed);
|
|
1117
|
-
}
|
|
1118
|
-
else {
|
|
1119
|
-
window.location.assign(fixed);
|
|
1120
|
-
}
|
|
1121
|
-
};
|
|
1122
|
-
document.addEventListener('click', onClickCapture, true);
|
|
1123
|
-
return () => {
|
|
1124
|
-
mo.disconnect();
|
|
1125
|
-
document.removeEventListener('click', onClickCapture, true);
|
|
1126
|
-
};
|
|
1127
|
-
}, [routing, autoPrefixLinks, allLocales, locale, navigateRef, routingConfig.nonLocalizedPaths]);
|
|
1128
|
-
// Navigation prefetch: warm the HTTP cache for bootstrap + page bundle before the user clicks (reduces EN→FR flash on SPA route changes).
|
|
1129
|
-
useEffect(() => {
|
|
1130
|
-
if (!resolvedApiKey)
|
|
1131
|
-
return;
|
|
1132
|
-
if (typeof window === "undefined" || typeof document === "undefined")
|
|
1133
|
-
return;
|
|
1134
|
-
const connection = navigator?.connection;
|
|
1135
|
-
if (connection?.saveData)
|
|
1136
|
-
return;
|
|
1137
|
-
if (typeof connection?.effectiveType === "string" && /(^|-)2g$/.test(connection.effectiveType))
|
|
1138
|
-
return;
|
|
1139
|
-
const prefetched = new Set();
|
|
1140
|
-
// Why: cap speculative requests to avoid flooding the network on pages with many links.
|
|
1141
|
-
const maxPrefetch = 40;
|
|
1142
|
-
const isAssetPath = (pathname) => {
|
|
1143
|
-
if (pathname === "/robots.txt" || pathname === "/sitemap.xml")
|
|
1144
|
-
return true;
|
|
1145
|
-
if (pathname.startsWith("/.well-known/"))
|
|
1146
|
-
return true;
|
|
1147
|
-
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);
|
|
1148
|
-
};
|
|
1149
|
-
const pickLocaleForUrl = (url) => {
|
|
1150
|
-
if (routing === "path") {
|
|
1151
|
-
const segment = url.pathname.split("/")[1] || "";
|
|
1152
|
-
if (segment && allLocales.includes(segment))
|
|
1153
|
-
return segment;
|
|
1154
|
-
return locale;
|
|
1155
|
-
}
|
|
1156
|
-
const q = url.searchParams.get("t") || url.searchParams.get("locale");
|
|
1157
|
-
if (q && allLocales.includes(q))
|
|
1158
|
-
return q;
|
|
1159
|
-
return locale;
|
|
1160
|
-
};
|
|
1161
|
-
const onIntent = (event) => {
|
|
1162
|
-
if (prefetched.size >= maxPrefetch)
|
|
1163
|
-
return;
|
|
1164
|
-
const target = event.target;
|
|
1165
|
-
const anchor = target?.closest?.("a[href]");
|
|
1166
|
-
if (!anchor)
|
|
1167
|
-
return;
|
|
1168
|
-
const href = anchor.getAttribute("href") || "";
|
|
1169
|
-
if (!href || /^(?:#|mailto:|tel:|sms:|javascript:)/i.test(href))
|
|
1170
|
-
return;
|
|
1171
|
-
let url;
|
|
1172
|
-
try {
|
|
1173
|
-
url = new URL(href, window.location.origin);
|
|
1174
|
-
}
|
|
1175
|
-
catch {
|
|
1176
|
-
return;
|
|
1177
|
-
}
|
|
1178
|
-
if (url.origin !== window.location.origin)
|
|
1179
|
-
return;
|
|
1180
|
-
if (isAssetPath(url.pathname))
|
|
1181
|
-
return;
|
|
1182
|
-
const targetLocale = pickLocaleForUrl(url);
|
|
1183
|
-
if (!targetLocale || targetLocale === defaultLocale)
|
|
1184
|
-
return;
|
|
1185
|
-
const normalizedPath = processPath(url.pathname, enhancedPathConfig);
|
|
1186
|
-
const key = `${targetLocale}:${normalizedPath}`;
|
|
1187
|
-
if (prefetched.has(key))
|
|
1188
|
-
return;
|
|
1189
|
-
prefetched.add(key);
|
|
1190
|
-
const pathParam = `${url.pathname}${url.search}`;
|
|
1191
|
-
const bootstrapUrl = `${apiBase}/functions/v1/bootstrap?key=${encodeURIComponent(resolvedApiKey)}&locale=${encodeURIComponent(targetLocale)}&path=${encodeURIComponent(pathParam)}`;
|
|
1192
|
-
const bundleUrl = `${apiBase}/functions/v1/bundle?key=${encodeURIComponent(resolvedApiKey)}&locale=${encodeURIComponent(targetLocale)}&path=${encodeURIComponent(pathParam)}`;
|
|
1193
|
-
void fetch(bootstrapUrl, { cache: "force-cache" }).catch(() => undefined);
|
|
1194
|
-
void fetch(bundleUrl, { cache: "force-cache" }).catch(() => undefined);
|
|
1195
|
-
};
|
|
1196
|
-
document.addEventListener("pointerover", onIntent, { passive: true });
|
|
1197
|
-
document.addEventListener("touchstart", onIntent, { passive: true });
|
|
1198
|
-
document.addEventListener("focusin", onIntent);
|
|
1199
|
-
return () => {
|
|
1200
|
-
document.removeEventListener("pointerover", onIntent);
|
|
1201
|
-
document.removeEventListener("touchstart", onIntent);
|
|
1202
|
-
document.removeEventListener("focusin", onIntent);
|
|
1203
|
-
};
|
|
1204
|
-
}, [allLocales, apiBase, defaultLocale, enhancedPathConfig, locale, resolvedApiKey, routing]);
|
|
1205
|
-
// Dynamic DOM updates are handled by the marker engine observer (React-safe in-place text/attr updates).
|
|
1206
|
-
// No periodic string-miss reporting. Page discovery is tracked via pageview only.
|
|
1207
|
-
const translateElement = useCallback((element) => {
|
|
1208
|
-
if (mode !== "dom")
|
|
1209
|
-
return;
|
|
1210
|
-
applyActiveTranslations(element);
|
|
1211
|
-
}, []);
|
|
1212
|
-
const translateDOM = useCallback(() => {
|
|
1213
|
-
if (mode !== "dom")
|
|
1214
|
-
return;
|
|
1215
|
-
applyActiveTranslations(document.body);
|
|
1216
|
-
}, []);
|
|
1217
|
-
const toggleEditMode = useCallback(() => {
|
|
1218
|
-
setEditMode(prev => !prev);
|
|
1219
|
-
}, []);
|
|
1220
|
-
const excludeElement = useCallback(async (selector) => {
|
|
1221
|
-
await apiRef.current.saveExclusion(selector, 'css');
|
|
1222
|
-
const exclusions = await apiRef.current.fetchExclusions();
|
|
1223
|
-
setMarkerEngineExclusions(exclusions);
|
|
1224
|
-
}, []);
|
|
1225
|
-
const contextValue = {
|
|
1226
|
-
locale,
|
|
1227
|
-
setLocale,
|
|
1228
|
-
isLoading,
|
|
1229
|
-
translationMap: {},
|
|
1230
|
-
config,
|
|
1231
|
-
translateElement,
|
|
1232
|
-
translateDOM,
|
|
1233
|
-
editMode,
|
|
1234
|
-
toggleEditMode,
|
|
1235
|
-
excludeElement,
|
|
1236
|
-
};
|
|
1237
|
-
return (React.createElement(LovalingoContext.Provider, { value: contextValue },
|
|
1238
|
-
children,
|
|
1239
|
-
(() => {
|
|
1240
|
-
if (routing !== "path")
|
|
1241
|
-
return true;
|
|
1242
|
-
if (typeof window === "undefined")
|
|
1243
|
-
return true;
|
|
1244
|
-
const stripped = stripLocalePrefix(window.location.pathname, allLocales);
|
|
1245
|
-
return !isNonLocalizedPath(stripped, routingConfig.nonLocalizedPaths);
|
|
1246
|
-
})() && (React.createElement(LanguageSwitcher, { locales: allLocales, currentLocale: locale, onLocaleChange: setLocale, position: switcherPosition, offsetY: switcherOffsetY, theme: switcherTheme, branding: {
|
|
1247
|
-
required: Boolean(entitlements?.brandingRequired),
|
|
1248
|
-
enabled: brandingEnabled,
|
|
1249
|
-
href: "https://lovalingo.com",
|
|
1250
|
-
} }))));
|
|
1251
|
-
};
|
|
1
|
+
export { LovalingoProvider } from "./LovalingoProvider";
|