@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.
@@ -1,1251 +1 @@
1
- import React, { useMemo, useState, useEffect, useCallback, useRef, useContext } from 'react';
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";