@lovalingo/lovalingo 0.5.5 → 0.5.7

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.
@@ -161,7 +161,8 @@ export const LanguageSwitcher = ({ locales, currentLocale, onLocaleChange, posit
161
161
  gap: '6px',
162
162
  };
163
163
  const flagButtonStyles = (locale) => ({
164
- pointerEvents: 'auto',
164
+ // Why: the panel stays mounted for the close animation, so buttons must be non-interactive while hidden.
165
+ pointerEvents: isOpen ? 'auto' : 'none',
165
166
  width: '32px',
166
167
  height: '32px',
167
168
  borderRadius: '50%',
@@ -206,7 +207,7 @@ export const LanguageSwitcher = ({ locales, currentLocale, onLocaleChange, posit
206
207
  e.currentTarget.style.transform = 'scale(1)';
207
208
  }, "aria-label": `Switch to ${locale.toUpperCase()}`, title: locale.toUpperCase(), tabIndex: isOpen ? 0 : -1 }, LANGUAGE_FLAGS[locale] || '🏳️')))),
208
209
  (branding?.required || branding?.enabled) && (React.createElement("div", { style: badgeRowStyles, "aria-label": "Lovalingo branding" },
209
- React.createElement("a", { href: branding.href || 'https://lovalingo.com', target: "_blank", rel: "noreferrer", style: badgeLinkStyles, tabIndex: isOpen ? 0 : -1, "aria-label": "Localized by Lovalingo", title: "Localized by Lovalingo" },
210
+ React.createElement("a", { href: branding.href || 'https://lovalingo.com', target: "_blank", rel: "noreferrer", style: { ...badgeLinkStyles, pointerEvents: isOpen ? 'auto' : 'none' }, tabIndex: isOpen ? 0 : -1, "aria-label": "Localized by Lovalingo", title: "Localized by Lovalingo" },
210
211
  React.createElement("span", { style: {
211
212
  width: '16px',
212
213
  height: '16px',
@@ -220,8 +221,9 @@ export const LanguageSwitcher = ({ locales, currentLocale, onLocaleChange, posit
220
221
  flexShrink: 0,
221
222
  } },
222
223
  React.createElement("svg", { width: "16", height: "16", viewBox: "0 0 512 512", fill: "none", "aria-hidden": "true" },
223
- React.createElement("path", { d: "M215.657 429.489C270.707 422.73 289.644 339.333 278 244.5C266.356 149.667 228.54 79.3089 173.49 86.0682C118.44 92.8275 83.253 175.184 94.8971 270.017C106.541 364.85 160.607 436.248 215.657 429.489Z", stroke: "#FFFFFF", strokeWidth: "44", strokeLinecap: "round", strokeLinejoin: "round" }),
224
- React.createElement("path", { d: "M168.218 408.885C188.661 447.333 263.959 447.277 336.399 408.759C408.84 370.242 450.992 307.849 430.549 269.401C410.106 230.953 334.808 231.009 262.368 269.526C189.927 308.044 147.775 370.437 168.218 408.885Z", stroke: "#FFFFFF", strokeWidth: "44", strokeLinecap: "round", strokeLinejoin: "round" }))),
224
+ React.createElement("path", { d: "M256 480C379.712 480 480 379.712 480 256C480 132.288 379.712 32 256 32C132.288 32 32 132.288 32 256C32 379.712 132.288 480 256 480Z", fill: "#DA2576" }),
225
+ React.createElement("path", { d: "M226.321 415.004C277.097 408.769 294.564 331.846 283.824 244.374C273.084 156.903 238.204 92.0061 187.427 98.2407C136.65 104.475 104.194 180.439 114.934 267.911C125.674 355.383 175.544 421.238 226.321 415.004Z", fill: "white", stroke: "white", strokeWidth: "10" }),
226
+ React.createElement("path", { d: "M182.564 395.999C201.42 431.462 270.873 431.411 337.69 395.883C404.508 360.356 443.388 302.806 424.531 267.342C405.675 231.879 336.223 231.931 269.405 267.458C202.588 302.986 163.708 370.535 182.564 395.999Z", fill: "white", stroke: "white", strokeWidth: "10" }))),
225
227
  React.createElement("span", null,
226
228
  branding.label || 'Localized by',
227
229
  " ",
@@ -1 +1,10 @@
1
- export { LovalingoProvider } from "./AixsterProvider";
1
+ import React from 'react';
2
+ import { LovalingoConfig } from '../types';
3
+ interface LovalingoProviderProps extends LovalingoConfig {
4
+ children: React.ReactNode;
5
+ sitemap?: boolean;
6
+ seo?: boolean;
7
+ navigateRef?: React.MutableRefObject<((path: string) => void) | undefined>;
8
+ }
9
+ export declare const LovalingoProvider: React.FC<LovalingoProviderProps>;
10
+ export {};
@@ -1 +1,610 @@
1
- export { LovalingoProvider } from "./AixsterProvider";
1
+ import React, { useCallback, useContext, useEffect, useMemo, useRef, useState } from 'react';
2
+ import { LovalingoContext } from '../context/LovalingoContext';
3
+ import { LangRoutingContext } from '../context/LangRoutingContext';
4
+ import { LovalingoAPI } from '../utils/api';
5
+ import { applyActiveTranslations, setMarkerEngineExclusions, startMarkerEngine } from '../utils/markerEngine';
6
+ import { warnDebug } from '../utils/logger';
7
+ import { isNonLocalizedPath, stripLocalePrefix } from '../utils/nonLocalizedPaths';
8
+ import { processPath } from '../utils/pathNormalizer';
9
+ import { useBundleLoading } from '../hooks/provider/useBundleLoading';
10
+ import { useNavigationPrefetch } from '../hooks/provider/useNavigationPrefetch';
11
+ import { useLinkAutoPrefix } from '../hooks/provider/useLinkAutoPrefix';
12
+ import { usePageviewTracking } from '../hooks/provider/usePageviewTracking';
13
+ import { useSitemapLinkTag } from '../hooks/provider/useSitemapLinkTag';
14
+ import { LanguageSwitcher } from './LanguageSwitcher';
15
+ const LOCALE_STORAGE_KEY = 'Lovalingo_locale';
16
+ const LOADING_BG_STORAGE_PREFIX = "Lovalingo_loading_bg_color";
17
+ const BRANDING_STORAGE_PREFIX = "Lovalingo_branding_enabled";
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 [editMode, setEditMode] = useState(initialEditMode);
74
+ const enhancedPathConfig = useMemo(() => (routing === "path" ? { ...pathNormalization, supportedLocales: allLocales } : pathNormalization), [allLocales, pathNormalization, routing]);
75
+ const apiRef = useRef(new LovalingoAPI(resolvedApiKey, apiBase, enhancedPathConfig));
76
+ useEffect(() => {
77
+ apiRef.current = new LovalingoAPI(resolvedApiKey, apiBase, enhancedPathConfig);
78
+ }, [apiBase, enhancedPathConfig, resolvedApiKey]);
79
+ const routingConfig = useContext(LangRoutingContext);
80
+ const [entitlements, setEntitlements] = useState(() => apiRef.current.getEntitlements());
81
+ const { trackPageviewOnce } = usePageviewTracking({ apiRef, resolvedApiKey });
82
+ const lastNormalizedPathRef = useRef("");
83
+ const historyPatchedRef = useRef(false);
84
+ const originalHistoryRef = useRef(null);
85
+ const onNavigateRef = useRef(() => undefined);
86
+ const isInternalNavigationRef = useRef(false);
87
+ const loadingBgStorageKey = `${LOADING_BG_STORAGE_PREFIX}:${resolvedApiKey || "anonymous"}`;
88
+ const brandingStorageKey = `${BRANDING_STORAGE_PREFIX}:${resolvedApiKey || "anonymous"}`;
89
+ const readBrandingCache = () => {
90
+ try {
91
+ const cached = (localStorage.getItem(brandingStorageKey) || "").trim();
92
+ if (cached === "0")
93
+ return false;
94
+ if (cached === "1")
95
+ return true;
96
+ }
97
+ catch {
98
+ // ignore
99
+ }
100
+ return true;
101
+ };
102
+ const [brandingEnabled, setBrandingEnabled] = useState(readBrandingCache);
103
+ const getCachedLoadingBgColor = useCallback(() => {
104
+ const configured = (overlayBgColor || "").toString().trim();
105
+ if (/^#[0-9a-fA-F]{6}$/.test(configured))
106
+ return configured;
107
+ try {
108
+ const cached = localStorage.getItem(loadingBgStorageKey) || "";
109
+ if (/^#[0-9a-fA-F]{6}$/.test(cached.trim()))
110
+ return cached.trim();
111
+ }
112
+ catch {
113
+ // ignore
114
+ }
115
+ return "#ffffff";
116
+ }, [loadingBgStorageKey, overlayBgColor]);
117
+ const setCachedLoadingBgColor = useCallback((color) => {
118
+ const next = (color || "").toString().trim();
119
+ if (!/^#[0-9a-fA-F]{6}$/.test(next))
120
+ return;
121
+ try {
122
+ localStorage.setItem(loadingBgStorageKey, next);
123
+ }
124
+ catch {
125
+ // ignore
126
+ }
127
+ }, [loadingBgStorageKey]);
128
+ useEffect(() => {
129
+ // Why: make `overlayBgColor` the source of truth while keeping the existing cache key for backwards compatibility.
130
+ const configured = (overlayBgColor || "").toString().trim();
131
+ if (!/^#[0-9a-fA-F]{6}$/.test(configured))
132
+ return;
133
+ setCachedLoadingBgColor(configured);
134
+ }, [overlayBgColor, setCachedLoadingBgColor]);
135
+ const setCachedBrandingEnabled = useCallback((enabled) => {
136
+ try {
137
+ localStorage.setItem(brandingStorageKey, enabled === false ? "0" : "1");
138
+ }
139
+ catch {
140
+ // ignore
141
+ }
142
+ }, [brandingStorageKey]);
143
+ useEffect(() => {
144
+ setBrandingEnabled(readBrandingCache());
145
+ // eslint-disable-next-line react-hooks/exhaustive-deps
146
+ }, [brandingStorageKey]);
147
+ const config = {
148
+ apiKey: resolvedApiKey,
149
+ publicAnonKey: resolvedApiKey,
150
+ defaultLocale,
151
+ locales: allLocales,
152
+ apiBase,
153
+ routing,
154
+ autoPrefixLinks,
155
+ overlayBgColor,
156
+ switcherPosition,
157
+ switcherOffsetY,
158
+ switcherTheme,
159
+ editMode: initialEditMode,
160
+ editKey,
161
+ pathNormalization,
162
+ mode,
163
+ autoApplyRules,
164
+ };
165
+ const setDocumentLocale = useCallback((nextLocale) => {
166
+ try {
167
+ const html = document.documentElement;
168
+ if (!html)
169
+ return;
170
+ html.setAttribute("lang", nextLocale);
171
+ const rtlLocales = new Set(["ar", "he", "fa", "ur"]);
172
+ html.setAttribute("dir", rtlLocales.has(nextLocale) ? "rtl" : "ltr");
173
+ }
174
+ catch {
175
+ // ignore
176
+ }
177
+ }, []);
178
+ const isSeoActive = useCallback(() => {
179
+ // Prop can force-disable SEO; server can also disable per project.
180
+ const serverEnabled = entitlements?.seoEnabled;
181
+ if (serverEnabled === false)
182
+ return false;
183
+ return seo !== false;
184
+ }, [entitlements, seo]);
185
+ // Marker engine: always mark full DOM content for deterministic pipeline extraction.
186
+ useEffect(() => {
187
+ const stop = startMarkerEngine({ throttleMs: 120 });
188
+ return () => stop();
189
+ }, []);
190
+ // Detect locale from URL or localStorage
191
+ const detectLocale = useCallback(() => {
192
+ // 1. Check URL first based on routing mode
193
+ if (routing === 'path') {
194
+ // Path mode: language is in path (/en/pricing, /fr/about)
195
+ const pathLocale = window.location.pathname.split('/')[1];
196
+ if (allLocales.includes(pathLocale)) {
197
+ return pathLocale;
198
+ }
199
+ }
200
+ else if (routing === 'query') {
201
+ // Query mode: language is in query param (/pricing?t=fr)
202
+ const params = new URLSearchParams(window.location.search);
203
+ const queryLocale = params.get('t') || params.get('locale');
204
+ if (queryLocale && allLocales.includes(queryLocale)) {
205
+ return queryLocale;
206
+ }
207
+ }
208
+ // 2. Check localStorage (fallback for all routing modes)
209
+ try {
210
+ const storedLocale = localStorage.getItem(LOCALE_STORAGE_KEY);
211
+ if (storedLocale && allLocales.includes(storedLocale)) {
212
+ return storedLocale;
213
+ }
214
+ }
215
+ catch (e) {
216
+ // localStorage might be unavailable (SSR, private browsing)
217
+ warnDebug('localStorage not available:', e);
218
+ }
219
+ // 3. Default locale
220
+ return defaultLocale;
221
+ }, [allLocales, defaultLocale, routing]);
222
+ // Fetch entitlements early so SEO can be enabled even on default locale
223
+ useEffect(() => {
224
+ if (locale !== defaultLocale)
225
+ return;
226
+ if (entitlements)
227
+ return;
228
+ let cancelled = false;
229
+ (async () => {
230
+ const bootstrap = await apiRef.current.fetchBootstrap(locale, window.location.pathname + window.location.search);
231
+ if (cancelled)
232
+ return;
233
+ if (bootstrap?.entitlements)
234
+ setEntitlements(bootstrap.entitlements);
235
+ if (bootstrap?.loading_bg_color)
236
+ setCachedLoadingBgColor(bootstrap.loading_bg_color);
237
+ if (bootstrap?.entitlements?.brandingRequired) {
238
+ setBrandingEnabled(true);
239
+ setCachedBrandingEnabled(true);
240
+ }
241
+ else if (typeof bootstrap?.branding_enabled === "boolean") {
242
+ setBrandingEnabled(bootstrap.branding_enabled);
243
+ setCachedBrandingEnabled(bootstrap.branding_enabled);
244
+ }
245
+ })();
246
+ return () => {
247
+ cancelled = true;
248
+ };
249
+ }, [defaultLocale, entitlements, locale, setCachedBrandingEnabled, setCachedLoadingBgColor]);
250
+ const applySeoBundle = useCallback((bundle, hreflangEnabled) => {
251
+ try {
252
+ const head = document.head;
253
+ if (!head)
254
+ return;
255
+ if (!bundle)
256
+ return;
257
+ const seo = (bundle?.seo && typeof bundle.seo === "object" ? bundle.seo : {});
258
+ const alternates = (bundle?.alternates && typeof bundle.alternates === "object" ? bundle.alternates : {});
259
+ const setOrCreateMeta = (attrs, content) => {
260
+ const key = attrs.name ? `meta[name="${attrs.name}"]` : attrs.property ? `meta[property="${attrs.property}"]` : "";
261
+ const selector = key || "meta";
262
+ const existing = selector ? head.querySelector(selector) : null;
263
+ const el = existing || document.createElement("meta");
264
+ for (const [k, v] of Object.entries(attrs)) {
265
+ el.setAttribute(k, v);
266
+ }
267
+ el.setAttribute("content", content);
268
+ if (!existing)
269
+ head.appendChild(el);
270
+ };
271
+ const setOrCreateTitle = (value) => {
272
+ const existing = head.querySelector("title");
273
+ if (existing) {
274
+ existing.textContent = value;
275
+ return;
276
+ }
277
+ const el = document.createElement("title");
278
+ el.textContent = value;
279
+ head.appendChild(el);
280
+ };
281
+ const getString = (value) => (typeof value === "string" && value.trim() ? value.trim() : "");
282
+ const title = getString(seo.title);
283
+ if (title)
284
+ setOrCreateTitle(title);
285
+ const description = getString(seo.description);
286
+ if (description)
287
+ setOrCreateMeta({ name: "description" }, description);
288
+ const robots = getString(seo.robots);
289
+ if (robots)
290
+ setOrCreateMeta({ name: "robots" }, robots);
291
+ const ogTitle = getString(seo.og_title);
292
+ if (ogTitle)
293
+ setOrCreateMeta({ property: "og:title" }, ogTitle);
294
+ const ogDescription = getString(seo.og_description);
295
+ if (ogDescription)
296
+ setOrCreateMeta({ property: "og:description" }, ogDescription);
297
+ const ogImage = getString(seo.og_image);
298
+ if (ogImage)
299
+ setOrCreateMeta({ property: "og:image" }, ogImage);
300
+ const ogImageAlt = getString(seo.og_image_alt);
301
+ if (ogImageAlt)
302
+ setOrCreateMeta({ property: "og:image:alt" }, ogImageAlt);
303
+ const twitterCard = getString(seo.twitter_card);
304
+ if (twitterCard)
305
+ setOrCreateMeta({ name: "twitter:card" }, twitterCard);
306
+ const twitterTitle = getString(seo.twitter_title);
307
+ if (twitterTitle)
308
+ setOrCreateMeta({ name: "twitter:title" }, twitterTitle);
309
+ const twitterDescription = getString(seo.twitter_description);
310
+ if (twitterDescription)
311
+ setOrCreateMeta({ name: "twitter:description" }, twitterDescription);
312
+ const twitterImage = getString(seo.twitter_image);
313
+ if (twitterImage)
314
+ setOrCreateMeta({ name: "twitter:image" }, twitterImage);
315
+ const twitterImageAlt = getString(seo.twitter_image_alt);
316
+ if (twitterImageAlt)
317
+ setOrCreateMeta({ name: "twitter:image:alt" }, twitterImageAlt);
318
+ const canonicalHref = typeof seo.canonical_url === "string" && seo.canonical_url.trim()
319
+ ? seo.canonical_url.trim()
320
+ : typeof alternates.canonical === "string" && alternates.canonical.trim()
321
+ ? alternates.canonical.trim()
322
+ : "";
323
+ const languages = alternates.languages && typeof alternates.languages === "object" ? alternates.languages : {};
324
+ const hasAnyAlternate = Boolean(alternates.xDefault) || Object.values(languages).some(Boolean);
325
+ if (!canonicalHref && !(hreflangEnabled && hasAnyAlternate))
326
+ return;
327
+ // Why: search engines may ignore hreflang/canonical when multiple conflicting tags exist (we want sitemap + head parity).
328
+ head
329
+ .querySelectorAll('link[rel="canonical"], link[rel="alternate"][hreflang], link[data-Lovalingo="hreflang"], link[data-Lovalingo="canonical"]')
330
+ .forEach((el) => el.remove());
331
+ if (canonicalHref) {
332
+ const canonical = document.createElement("link");
333
+ canonical.rel = "canonical";
334
+ canonical.href = canonicalHref;
335
+ canonical.setAttribute("data-Lovalingo", "canonical");
336
+ head.appendChild(canonical);
337
+ }
338
+ if (!hreflangEnabled)
339
+ return;
340
+ for (const [lang, href] of Object.entries(languages)) {
341
+ if (!href)
342
+ continue;
343
+ const link = document.createElement("link");
344
+ link.rel = "alternate";
345
+ link.hreflang = lang;
346
+ link.href = href;
347
+ link.setAttribute("data-Lovalingo", "hreflang");
348
+ head.appendChild(link);
349
+ }
350
+ if (alternates.xDefault) {
351
+ const xDefault = document.createElement("link");
352
+ xDefault.rel = "alternate";
353
+ xDefault.hreflang = "x-default";
354
+ xDefault.href = alternates.xDefault;
355
+ xDefault.setAttribute("data-Lovalingo", "hreflang");
356
+ head.appendChild(xDefault);
357
+ }
358
+ }
359
+ catch {
360
+ // ignore SEO errors
361
+ }
362
+ }, []);
363
+ // Keep <html lang> in sync and apply default-locale SEO (non-default locales use the bootstrap payload).
364
+ useEffect(() => {
365
+ setDocumentLocale(locale);
366
+ if (locale !== defaultLocale)
367
+ return;
368
+ if (!isSeoActive())
369
+ return;
370
+ void apiRef.current.fetchSeoBundle(locale).then((bundle) => {
371
+ applySeoBundle(bundle, Boolean(entitlements?.hreflangEnabled));
372
+ });
373
+ }, [applySeoBundle, defaultLocale, entitlements, isSeoActive, locale, setDocumentLocale]);
374
+ const { isLoading, isNavigatingRef, loadData } = useBundleLoading({
375
+ apiRef,
376
+ resolvedApiKey,
377
+ defaultLocale,
378
+ routing,
379
+ allLocales,
380
+ nonLocalizedPaths: routingConfig.nonLocalizedPaths,
381
+ enhancedPathConfig,
382
+ mode,
383
+ autoApplyRules,
384
+ isSeoActive,
385
+ applySeoBundle,
386
+ setEntitlements,
387
+ setBrandingEnabled,
388
+ setCachedBrandingEnabled,
389
+ setCachedLoadingBgColor,
390
+ getCachedLoadingBgColor,
391
+ });
392
+ useEffect(() => {
393
+ onNavigateRef.current = () => {
394
+ trackPageviewOnce(window.location.pathname + window.location.search);
395
+ const nextLocale = detectLocale();
396
+ const normalizedPath = processPath(window.location.pathname, enhancedPathConfig);
397
+ const normalizedPathChanged = normalizedPath !== lastNormalizedPathRef.current;
398
+ lastNormalizedPathRef.current = normalizedPath;
399
+ // Why: bundles are path-scoped, so SPA navigations within the same locale must trigger a reload for the new route.
400
+ if (normalizedPathChanged && nextLocale !== defaultLocale && !isInternalNavigationRef.current) {
401
+ void loadData(nextLocale, locale);
402
+ return;
403
+ }
404
+ if (nextLocale !== locale) {
405
+ setLocaleState(nextLocale);
406
+ if (!isInternalNavigationRef.current) {
407
+ void loadData(nextLocale, locale);
408
+ }
409
+ }
410
+ else if (mode === "dom" && nextLocale !== defaultLocale) {
411
+ applyActiveTranslations(document.body);
412
+ }
413
+ };
414
+ }, [defaultLocale, detectLocale, enhancedPathConfig, loadData, locale, mode, trackPageviewOnce]);
415
+ // SPA router hook-in: patch History API once (prevents stacked wrappers → request storms).
416
+ useEffect(() => {
417
+ if (typeof window === "undefined")
418
+ return;
419
+ if (historyPatchedRef.current)
420
+ return;
421
+ historyPatchedRef.current = true;
422
+ const historyObj = window.history;
423
+ const originalPushState = historyObj.pushState.bind(historyObj);
424
+ const originalReplaceState = historyObj.replaceState.bind(historyObj);
425
+ originalHistoryRef.current = { pushState: originalPushState, replaceState: originalReplaceState };
426
+ const safeOnNavigate = () => {
427
+ try {
428
+ onNavigateRef.current();
429
+ }
430
+ catch {
431
+ // ignore
432
+ }
433
+ };
434
+ historyObj.pushState = ((...args) => {
435
+ const ret = originalPushState(...args);
436
+ safeOnNavigate();
437
+ return ret;
438
+ });
439
+ historyObj.replaceState = ((...args) => {
440
+ const ret = originalReplaceState(...args);
441
+ safeOnNavigate();
442
+ return ret;
443
+ });
444
+ window.addEventListener("popstate", safeOnNavigate);
445
+ window.addEventListener("hashchange", safeOnNavigate);
446
+ return () => {
447
+ const originals = originalHistoryRef.current;
448
+ if (originals) {
449
+ historyObj.pushState = originals.pushState;
450
+ historyObj.replaceState = originals.replaceState;
451
+ }
452
+ window.removeEventListener("popstate", safeOnNavigate);
453
+ window.removeEventListener("hashchange", safeOnNavigate);
454
+ originalHistoryRef.current = null;
455
+ historyPatchedRef.current = false;
456
+ };
457
+ }, []);
458
+ // Change locale
459
+ const setLocale = useCallback((newLocale) => {
460
+ void (async () => {
461
+ if (!allLocales.includes(newLocale))
462
+ return;
463
+ const previousLocale = locale; // Capture current locale before switching
464
+ // Save to localStorage
465
+ try {
466
+ localStorage.setItem(LOCALE_STORAGE_KEY, newLocale);
467
+ }
468
+ catch (e) {
469
+ warnDebug('Failed to save locale to localStorage:', e);
470
+ }
471
+ isInternalNavigationRef.current = true;
472
+ // Prevent MutationObserver work during the switch to avoid React conflicts
473
+ isNavigatingRef.current = true;
474
+ // Update URL based on routing strategy
475
+ if (routing === 'path') {
476
+ const stripped = stripLocalePrefix(window.location.pathname, allLocales);
477
+ if (isNonLocalizedPath(stripped, routingConfig.nonLocalizedPaths)) {
478
+ // Why: switching languages must not rewrite non-localized routes like "/auth" to "/de/auth".
479
+ setLocaleState(newLocale);
480
+ isNavigatingRef.current = false;
481
+ return;
482
+ }
483
+ const pathParts = window.location.pathname.split('/').filter(Boolean);
484
+ // Strip existing locale
485
+ if (allLocales.includes(pathParts[0])) {
486
+ pathParts.shift();
487
+ }
488
+ // Build new path with new locale
489
+ const basePath = pathParts.join('/');
490
+ const newPath = `/${newLocale}${basePath ? '/' + basePath : ''}${window.location.search}${window.location.hash}`;
491
+ // Prefer React Router navigation when available, but gracefully fallback for non-React-Router apps
492
+ const navigate = navigateRef?.current;
493
+ if (navigate) {
494
+ navigate(newPath);
495
+ }
496
+ else {
497
+ try {
498
+ window.history.pushState({}, '', newPath);
499
+ }
500
+ catch {
501
+ window.location.assign(newPath);
502
+ }
503
+ }
504
+ }
505
+ else if (routing === 'query') {
506
+ const url = new URL(window.location.href);
507
+ url.searchParams.set('t', newLocale);
508
+ window.history.pushState({}, '', url.toString());
509
+ }
510
+ setLocaleState(newLocale);
511
+ await loadData(newLocale, previousLocale);
512
+ })().finally(() => {
513
+ isInternalNavigationRef.current = false;
514
+ });
515
+ }, [allLocales, defaultLocale, locale, routing, loadData, navigateRef, routingConfig.nonLocalizedPaths]);
516
+ // No string-level "misses" from the client: the pipeline (Browser Rendering) is source-of-truth for string discovery.
517
+ // Why: prevent init/load effects from re-running (and calling bootstrap/bundle again) when loadData changes due to state updates.
518
+ const loadDataRef = useRef(loadData);
519
+ useEffect(() => {
520
+ loadDataRef.current = loadData;
521
+ }, [loadData]);
522
+ const detectLocaleRef = useRef(detectLocale);
523
+ useEffect(() => {
524
+ detectLocaleRef.current = detectLocale;
525
+ }, [detectLocale]);
526
+ // Initialize
527
+ useEffect(() => {
528
+ const initialLocale = detectLocaleRef.current();
529
+ lastNormalizedPathRef.current = processPath(window.location.pathname, enhancedPathConfig);
530
+ // Track initial page (fallback discovery for pages not present in the routes feed).
531
+ trackPageviewOnce(window.location.pathname + window.location.search);
532
+ // Always prefetch artifacts for the initial locale (pipeline-produced translations + rules).
533
+ loadDataRef.current(initialLocale);
534
+ // Set up keyboard shortcut for edit mode
535
+ const handleKeyPress = (e) => {
536
+ if (e.code === editKey && (e.ctrlKey || e.metaKey)) {
537
+ e.preventDefault();
538
+ setEditMode(prev => !prev);
539
+ }
540
+ };
541
+ window.addEventListener('keydown', handleKeyPress);
542
+ return () => {
543
+ window.removeEventListener('keydown', handleKeyPress);
544
+ };
545
+ }, [editKey, enhancedPathConfig, trackPageviewOnce]);
546
+ useSitemapLinkTag({ enabled: sitemap, resolvedApiKey, isSeoActive });
547
+ useLinkAutoPrefix({
548
+ routing,
549
+ autoPrefixLinks,
550
+ allLocales,
551
+ locale,
552
+ navigateRef,
553
+ nonLocalizedPaths: routingConfig.nonLocalizedPaths,
554
+ });
555
+ useNavigationPrefetch({
556
+ resolvedApiKey,
557
+ apiBase,
558
+ defaultLocale,
559
+ locale,
560
+ routing,
561
+ allLocales,
562
+ enhancedPathConfig,
563
+ });
564
+ // Dynamic DOM updates are handled by the marker engine observer (React-safe in-place text/attr updates).
565
+ // No periodic string-miss reporting. Page discovery is tracked via pageview only.
566
+ const translateElement = useCallback((element) => {
567
+ if (mode !== "dom")
568
+ return;
569
+ applyActiveTranslations(element);
570
+ }, []);
571
+ const translateDOM = useCallback(() => {
572
+ if (mode !== "dom")
573
+ return;
574
+ applyActiveTranslations(document.body);
575
+ }, []);
576
+ const toggleEditMode = useCallback(() => {
577
+ setEditMode(prev => !prev);
578
+ }, []);
579
+ const excludeElement = useCallback(async (selector) => {
580
+ await apiRef.current.saveExclusion(selector, 'css');
581
+ const exclusions = await apiRef.current.fetchExclusions();
582
+ setMarkerEngineExclusions(exclusions);
583
+ }, []);
584
+ const contextValue = {
585
+ locale,
586
+ setLocale,
587
+ isLoading,
588
+ translationMap: {},
589
+ config,
590
+ translateElement,
591
+ translateDOM,
592
+ editMode,
593
+ toggleEditMode,
594
+ excludeElement,
595
+ };
596
+ return (React.createElement(LovalingoContext.Provider, { value: contextValue },
597
+ children,
598
+ (() => {
599
+ if (routing !== "path")
600
+ return true;
601
+ if (typeof window === "undefined")
602
+ return true;
603
+ const stripped = stripLocalePrefix(window.location.pathname, allLocales);
604
+ return !isNonLocalizedPath(stripped, routingConfig.nonLocalizedPaths);
605
+ })() && (React.createElement(LanguageSwitcher, { locales: allLocales, currentLocale: locale, onLocaleChange: setLocale, position: switcherPosition, offsetY: switcherOffsetY, theme: switcherTheme, branding: {
606
+ required: Boolean(entitlements?.brandingRequired),
607
+ enabled: brandingEnabled,
608
+ href: "https://lovalingo.com",
609
+ } }))));
610
+ };