@lovalingo/lovalingo 0.5.25 → 0.5.28

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.
Files changed (67) hide show
  1. package/dist/__tests__/languageFlags.test.d.ts +1 -0
  2. package/dist/__tests__/languageFlags.test.js +42 -0
  3. package/dist/__tests__/mergeEntitlements.test.d.ts +1 -0
  4. package/dist/__tests__/mergeEntitlements.test.js +27 -0
  5. package/dist/components/LanguageSwitcher.js +80 -53
  6. package/dist/components/LovalingoProvider.js +18 -473
  7. package/dist/components/provider/__tests__/seoUtils.test.d.ts +1 -0
  8. package/dist/components/provider/__tests__/seoUtils.test.js +13 -0
  9. package/dist/components/provider/editModeUtils.d.ts +6 -0
  10. package/dist/components/provider/editModeUtils.js +59 -0
  11. package/dist/components/provider/localeUtils.d.ts +8 -0
  12. package/dist/components/provider/localeUtils.js +46 -0
  13. package/dist/components/provider/providerConstants.d.ts +12 -0
  14. package/dist/components/provider/providerConstants.js +11 -0
  15. package/dist/components/provider/seoUtils.d.ts +8 -0
  16. package/dist/components/provider/seoUtils.js +118 -0
  17. package/dist/components/provider/useEditModeOverlay.d.ts +7 -0
  18. package/dist/components/provider/useEditModeOverlay.js +134 -0
  19. package/dist/components/provider/useHistoryNavigationPatch.d.ts +3 -0
  20. package/dist/components/provider/useHistoryNavigationPatch.js +47 -0
  21. package/dist/components/provider/useProviderCache.d.ts +12 -0
  22. package/dist/components/provider/useProviderCache.js +82 -0
  23. package/dist/hooks/provider/useBundleLoading.d.ts +2 -1
  24. package/dist/hooks/provider/useBundleLoading.js +15 -3
  25. package/dist/utils/api.d.ts +3 -78
  26. package/dist/utils/api.js +1 -53
  27. package/dist/utils/apiTypes.d.ts +78 -0
  28. package/dist/utils/apiTypes.js +1 -0
  29. package/dist/utils/apiUtils.d.ts +4 -0
  30. package/dist/utils/apiUtils.js +54 -0
  31. package/dist/utils/languageFlags.d.ts +7 -0
  32. package/dist/utils/languageFlags.js +90 -0
  33. package/dist/utils/markerEngine.d.ts +8 -66
  34. package/dist/utils/markerEngine.js +19 -703
  35. package/dist/utils/markerEngineApply.d.ts +3 -0
  36. package/dist/utils/markerEngineApply.js +136 -0
  37. package/dist/utils/markerEngineConstants.d.ts +10 -0
  38. package/dist/utils/markerEngineConstants.js +12 -0
  39. package/dist/utils/markerEngineCritical.d.ts +2 -0
  40. package/dist/utils/markerEngineCritical.js +98 -0
  41. package/dist/utils/markerEngineDomUtils.d.ts +8 -0
  42. package/dist/utils/markerEngineDomUtils.js +74 -0
  43. package/dist/utils/markerEngineFilters.d.ts +2 -0
  44. package/dist/utils/markerEngineFilters.js +26 -0
  45. package/dist/utils/markerEngineMisses.d.ts +5 -0
  46. package/dist/utils/markerEngineMisses.js +81 -0
  47. package/dist/utils/markerEngineOriginals.d.ts +5 -0
  48. package/dist/utils/markerEngineOriginals.js +29 -0
  49. package/dist/utils/markerEngineScan.d.ts +5 -0
  50. package/dist/utils/markerEngineScan.js +162 -0
  51. package/dist/utils/markerEngineState.d.ts +4 -0
  52. package/dist/utils/markerEngineState.js +14 -0
  53. package/dist/utils/markerEngineStats.d.ts +3 -0
  54. package/dist/utils/markerEngineStats.js +28 -0
  55. package/dist/utils/markerEngineTranslations.d.ts +3 -0
  56. package/dist/utils/markerEngineTranslations.js +49 -0
  57. package/dist/utils/markerEngineTypes.d.ts +62 -0
  58. package/dist/utils/markerEngineTypes.js +1 -0
  59. package/dist/utils/markerEngineViewport.d.ts +2 -0
  60. package/dist/utils/markerEngineViewport.js +27 -0
  61. package/dist/utils/mergeEntitlements.d.ts +2 -0
  62. package/dist/utils/mergeEntitlements.js +7 -0
  63. package/dist/version.d.ts +1 -1
  64. package/dist/version.js +1 -1
  65. package/package.json +1 -1
  66. package/dist/utils/translator.d.ts +0 -80
  67. package/dist/utils/translator.js +0 -802
@@ -13,77 +13,16 @@ import { usePageviewTracking } from '../hooks/provider/usePageviewTracking';
13
13
  import { useSitemapLinkTag } from '../hooks/provider/useSitemapLinkTag';
14
14
  import { useStringMissReporting } from '../hooks/provider/useStringMissReporting';
15
15
  import { LanguageSwitcher } from './LanguageSwitcher';
16
- const LOCALE_STORAGE_KEY = 'Lovalingo_locale';
17
- const LOADING_BG_STORAGE_PREFIX = "Lovalingo_loading_bg_color";
18
- const BRANDING_STORAGE_PREFIX = "Lovalingo_branding_enabled";
19
- const EDIT_MODE_PARAM = "edit_mode";
20
- const EDIT_KEY_PARAM = "edit_key";
16
+ import { readEditParams } from './provider/editModeUtils';
17
+ import { applySeoBundle as applySeoBundleInternal } from './provider/seoUtils';
18
+ import { mergeEntitlementsSeoEnabled } from "../utils/mergeEntitlements";
19
+ import { useEditModeOverlay } from './provider/useEditModeOverlay';
20
+ import { useHistoryNavigationPatch } from './provider/useHistoryNavigationPatch';
21
+ import { detectLocaleFromLocation, setDocumentLocale } from './provider/localeUtils';
22
+ import { useProviderCache } from './provider/useProviderCache';
23
+ import { DEFAULT_PATH_NORMALIZATION, LIVE_MISSES_QUERY_PARAM, LOCALE_STORAGE_KEY } from './provider/providerConstants';
21
24
  // Why: run initial load before first paint on the client to avoid a prehide flash; useEffect on SSR.
22
25
  const useIsomorphicLayoutEffect = typeof window !== "undefined" ? useLayoutEffect : useEffect;
23
- const LIVE_MISSES_QUERY_PARAM = "lovalingo_live_misses";
24
- const DEFAULT_PATH_NORMALIZATION = { enabled: true };
25
- const EDIT_MODE_VALUES = new Set(["1", "true", "yes", "on"]);
26
- const EDIT_UI_ATTR = "data-lovalingo-edit-ui";
27
- const EDIT_HIGHLIGHT_ID = "lovalingo-edit-highlight";
28
- const EDIT_HINT_ID = "lovalingo-edit-hint";
29
- function readEditParams() {
30
- if (typeof window === "undefined")
31
- return { enabled: false, editKey: null };
32
- const params = new URLSearchParams(window.location.search);
33
- const rawFlag = (params.get(EDIT_MODE_PARAM) || params.get("editMode") || "").trim().toLowerCase();
34
- const enabled = EDIT_MODE_VALUES.has(rawFlag);
35
- const editKey = (params.get(EDIT_KEY_PARAM) || params.get("editKey") || "").trim() || null;
36
- return { enabled, editKey };
37
- }
38
- function cssEscape(value) {
39
- const esc = typeof window !== "undefined" && window?.CSS?.escape;
40
- if (typeof esc === "function")
41
- return esc(value);
42
- return value.replace(/[ !"#$%&'()*+,.\/:;<=>?@[\\\]^`{|}~]/g, "\\$&");
43
- }
44
- // Why: build stable, short selectors for exclusions without requiring IDs.
45
- function buildCssSelector(element, maxDepth = 5) {
46
- if (!element || !element.tagName)
47
- return null;
48
- if (element.id)
49
- return `#${cssEscape(element.id)}`;
50
- const parts = [];
51
- let node = element;
52
- let depth = 0;
53
- while (node && depth < maxDepth) {
54
- const tag = node.tagName.toLowerCase();
55
- if (!tag || tag === "html")
56
- break;
57
- let part = tag;
58
- const nodeTag = node.tagName;
59
- const classes = Array.from(node.classList || [])
60
- .filter(Boolean)
61
- .filter((cls) => !cls.startsWith("lovalingo-"))
62
- .slice(0, 2);
63
- if (classes.length > 0) {
64
- part += `.${classes.map(cssEscape).join(".")}`;
65
- }
66
- const parentEl = node.parentElement;
67
- if (parentEl) {
68
- const siblings = Array.from(parentEl.children).filter((child) => child instanceof HTMLElement && child.tagName === nodeTag);
69
- if (siblings.length > 1) {
70
- part += `:nth-of-type(${siblings.indexOf(node) + 1})`;
71
- }
72
- }
73
- parts.unshift(part);
74
- const selector = parts.join(" > ");
75
- try {
76
- if (document.querySelectorAll(selector).length === 1)
77
- return selector;
78
- }
79
- catch {
80
- // ignore invalid selector attempts
81
- }
82
- node = parentEl;
83
- depth += 1;
84
- }
85
- return parts.join(" > ") || null;
86
- }
87
26
  export const LovalingoProvider = ({ children, apiKey: apiKeyProp, publicAnonKey, defaultLocale, locales, apiBase = 'https://cdn.lovalingo.com', routing = 'path', // Default to path mode (SEO-friendly, recommended)
88
27
  autoPrefixLinks = true, overlayBgColor, autoApplyRules = true, switcherPosition = 'bottom-right', switcherOffsetY = 20, switcherTheme = 'dark', editMode: initialEditMode = false, editKey = 'KeyE', pathNormalization = DEFAULT_PATH_NORMALIZATION, // Enable by default
89
28
  mode = 'dom', // Default to legacy DOM mode for backward compatibility
@@ -123,7 +62,7 @@ navigateRef, // For path mode routing
123
62
  const rules = Array.isArray(pathNormalization?.rules) ? pathNormalization.rules : undefined;
124
63
  return rules ? { enabled, rules } : { enabled };
125
64
  }, [pathNormalizationKey]);
126
- // Why: read locale synchronously from the URL to avoid an initial default-locale render (EN FR flash) before effects run.
65
+ // Why: read locale synchronously from the URL to avoid an initial default-locale render (EN -> FR flash) before effects run.
127
66
  const [locale, setLocaleState] = useState(() => {
128
67
  if (typeof window === "undefined")
129
68
  return defaultLocale;
@@ -163,83 +102,9 @@ navigateRef, // For path mode routing
163
102
  const [entitlements, setEntitlements] = useState(() => apiRef.current.getEntitlements());
164
103
  const { trackPageviewOnce } = usePageviewTracking({ apiRef, resolvedApiKey });
165
104
  const lastNormalizedPathRef = useRef("");
166
- const historyPatchedRef = useRef(false);
167
- const editSavingRef = useRef(false);
168
- const originalHistoryRef = useRef(null);
169
105
  const onNavigateRef = useRef(() => undefined);
170
106
  const isInternalNavigationRef = useRef(false);
171
- const loadingBgStorageKey = `${LOADING_BG_STORAGE_PREFIX}:${resolvedApiKey || "anonymous"}`;
172
- const brandingStorageKey = `${BRANDING_STORAGE_PREFIX}:${resolvedApiKey || "anonymous"}`;
173
- const readBrandingCache = () => {
174
- try {
175
- const cached = (localStorage.getItem(brandingStorageKey) || "").trim();
176
- if (cached === "0")
177
- return false;
178
- if (cached === "1")
179
- return true;
180
- }
181
- catch {
182
- // ignore
183
- }
184
- return true;
185
- };
186
- const [brandingEnabled, setBrandingEnabled] = useState(readBrandingCache);
187
- const getCachedLoadingBgColor = useCallback(() => {
188
- const configured = (overlayBgColor || "").toString().trim();
189
- if (/^#[0-9a-fA-F]{6}$/.test(configured))
190
- return configured;
191
- try {
192
- const cached = localStorage.getItem(loadingBgStorageKey) || "";
193
- if (/^#[0-9a-fA-F]{6}$/.test(cached.trim()))
194
- return cached.trim();
195
- }
196
- catch {
197
- // ignore
198
- }
199
- // Why: default to the site's existing background to reduce a visible white flash on non-white themes.
200
- try {
201
- const bodyBg = window.getComputedStyle(document.body).backgroundColor;
202
- if (bodyBg && bodyBg !== "transparent" && bodyBg !== "rgba(0, 0, 0, 0)")
203
- return bodyBg;
204
- const htmlBg = window.getComputedStyle(document.documentElement).backgroundColor;
205
- if (htmlBg && htmlBg !== "transparent" && htmlBg !== "rgba(0, 0, 0, 0)")
206
- return htmlBg;
207
- }
208
- catch {
209
- // ignore
210
- }
211
- return "#ffffff";
212
- }, [loadingBgStorageKey, overlayBgColor]);
213
- const setCachedLoadingBgColor = useCallback((color) => {
214
- const next = (color || "").toString().trim();
215
- if (!/^#[0-9a-fA-F]{6}$/.test(next))
216
- return;
217
- try {
218
- localStorage.setItem(loadingBgStorageKey, next);
219
- }
220
- catch {
221
- // ignore
222
- }
223
- }, [loadingBgStorageKey]);
224
- useEffect(() => {
225
- // Why: make `overlayBgColor` the source of truth while keeping the existing cache key for backwards compatibility.
226
- const configured = (overlayBgColor || "").toString().trim();
227
- if (!/^#[0-9a-fA-F]{6}$/.test(configured))
228
- return;
229
- setCachedLoadingBgColor(configured);
230
- }, [overlayBgColor, setCachedLoadingBgColor]);
231
- const setCachedBrandingEnabled = useCallback((enabled) => {
232
- try {
233
- localStorage.setItem(brandingStorageKey, enabled === false ? "0" : "1");
234
- }
235
- catch {
236
- // ignore
237
- }
238
- }, [brandingStorageKey]);
239
- useEffect(() => {
240
- setBrandingEnabled(readBrandingCache());
241
- // eslint-disable-next-line react-hooks/exhaustive-deps
242
- }, [brandingStorageKey]);
107
+ const { brandingEnabled, setBrandingEnabled, setCachedBrandingEnabled, getCachedLoadingBgColor, setCachedLoadingBgColor, } = useProviderCache({ overlayBgColor, resolvedApiKey });
243
108
  const config = {
244
109
  apiKey: resolvedApiKey,
245
110
  publicAnonKey: resolvedApiKey,
@@ -258,19 +123,6 @@ navigateRef, // For path mode routing
258
123
  mode,
259
124
  autoApplyRules,
260
125
  };
261
- const setDocumentLocale = useCallback((nextLocale) => {
262
- try {
263
- const html = document.documentElement;
264
- if (!html)
265
- return;
266
- html.setAttribute("lang", nextLocale);
267
- const rtlLocales = new Set(["ar", "he", "fa", "ur"]);
268
- html.setAttribute("dir", rtlLocales.has(nextLocale) ? "rtl" : "ltr");
269
- }
270
- catch {
271
- // ignore
272
- }
273
- }, []);
274
126
  const isSeoActive = useCallback(() => {
275
127
  // Prop can force-disable SEO; server can also disable per project.
276
128
  const serverEnabled = entitlements?.seoEnabled;
@@ -284,37 +136,7 @@ navigateRef, // For path mode routing
284
136
  return () => stop();
285
137
  }, []);
286
138
  // Detect locale from URL or localStorage
287
- const detectLocale = useCallback(() => {
288
- // 1. Check URL first based on routing mode
289
- if (routing === 'path') {
290
- // Path mode: language is in path (/en/pricing, /fr/about)
291
- const pathLocale = window.location.pathname.split('/')[1];
292
- if (allLocales.includes(pathLocale)) {
293
- return pathLocale;
294
- }
295
- }
296
- else if (routing === 'query') {
297
- // Query mode: language is in query param (/pricing?t=fr)
298
- const params = new URLSearchParams(window.location.search);
299
- const queryLocale = params.get('t') || params.get('locale');
300
- if (queryLocale && allLocales.includes(queryLocale)) {
301
- return queryLocale;
302
- }
303
- }
304
- // 2. Check localStorage (fallback for all routing modes)
305
- try {
306
- const storedLocale = localStorage.getItem(LOCALE_STORAGE_KEY);
307
- if (storedLocale && allLocales.includes(storedLocale)) {
308
- return storedLocale;
309
- }
310
- }
311
- catch (e) {
312
- // localStorage might be unavailable (SSR, private browsing)
313
- warnDebug('localStorage not available:', e);
314
- }
315
- // 3. Default locale
316
- return defaultLocale;
317
- }, [allLocales, defaultLocale, routing]);
139
+ const detectLocale = useCallback(() => detectLocaleFromLocation({ routing, allLocales, defaultLocale }), [allLocales, defaultLocale, routing]);
318
140
  // Fetch entitlements early so SEO can be enabled even on default locale
319
141
  useEffect(() => {
320
142
  if (locale !== defaultLocale)
@@ -326,8 +148,9 @@ navigateRef, // For path mode routing
326
148
  const bootstrap = await apiRef.current.fetchBootstrap(locale, window.location.pathname + window.location.search);
327
149
  if (cancelled)
328
150
  return;
329
- if (bootstrap?.entitlements)
330
- setEntitlements(bootstrap.entitlements);
151
+ if (bootstrap?.entitlements) {
152
+ setEntitlements(mergeEntitlementsSeoEnabled(bootstrap.entitlements, bootstrap.seoEnabled));
153
+ }
331
154
  if (bootstrap?.loading_bg_color)
332
155
  setCachedLoadingBgColor(bootstrap.loading_bg_color);
333
156
  if (bootstrap?.entitlements?.brandingRequired) {
@@ -344,117 +167,7 @@ navigateRef, // For path mode routing
344
167
  };
345
168
  }, [defaultLocale, entitlements, locale, setCachedBrandingEnabled, setCachedLoadingBgColor]);
346
169
  const applySeoBundle = useCallback((bundle, hreflangEnabled) => {
347
- try {
348
- const head = document.head;
349
- if (!head)
350
- return;
351
- if (!bundle)
352
- return;
353
- const seo = (bundle?.seo && typeof bundle.seo === "object" ? bundle.seo : {});
354
- const alternates = (bundle?.alternates && typeof bundle.alternates === "object" ? bundle.alternates : {});
355
- const setOrCreateMeta = (attrs, content) => {
356
- const key = attrs.name ? `meta[name="${attrs.name}"]` : attrs.property ? `meta[property="${attrs.property}"]` : "";
357
- const selector = key || "meta";
358
- const existing = selector ? head.querySelector(selector) : null;
359
- const el = existing || document.createElement("meta");
360
- for (const [k, v] of Object.entries(attrs)) {
361
- el.setAttribute(k, v);
362
- }
363
- el.setAttribute("content", content);
364
- if (!existing)
365
- head.appendChild(el);
366
- };
367
- const setOrCreateTitle = (value) => {
368
- const existing = head.querySelector("title");
369
- if (existing) {
370
- existing.textContent = value;
371
- return;
372
- }
373
- const el = document.createElement("title");
374
- el.textContent = value;
375
- head.appendChild(el);
376
- };
377
- const getString = (value) => (typeof value === "string" && value.trim() ? value.trim() : "");
378
- const title = getString(seo.title);
379
- if (title)
380
- setOrCreateTitle(title);
381
- const description = getString(seo.description);
382
- if (description)
383
- setOrCreateMeta({ name: "description" }, description);
384
- const robots = getString(seo.robots);
385
- if (robots)
386
- setOrCreateMeta({ name: "robots" }, robots);
387
- const ogTitle = getString(seo.og_title);
388
- if (ogTitle)
389
- setOrCreateMeta({ property: "og:title" }, ogTitle);
390
- const ogDescription = getString(seo.og_description);
391
- if (ogDescription)
392
- setOrCreateMeta({ property: "og:description" }, ogDescription);
393
- const ogImage = getString(seo.og_image);
394
- if (ogImage)
395
- setOrCreateMeta({ property: "og:image" }, ogImage);
396
- const ogImageAlt = getString(seo.og_image_alt);
397
- if (ogImageAlt)
398
- setOrCreateMeta({ property: "og:image:alt" }, ogImageAlt);
399
- const twitterCard = getString(seo.twitter_card);
400
- if (twitterCard)
401
- setOrCreateMeta({ name: "twitter:card" }, twitterCard);
402
- const twitterTitle = getString(seo.twitter_title);
403
- if (twitterTitle)
404
- setOrCreateMeta({ name: "twitter:title" }, twitterTitle);
405
- const twitterDescription = getString(seo.twitter_description);
406
- if (twitterDescription)
407
- setOrCreateMeta({ name: "twitter:description" }, twitterDescription);
408
- const twitterImage = getString(seo.twitter_image);
409
- if (twitterImage)
410
- setOrCreateMeta({ name: "twitter:image" }, twitterImage);
411
- const twitterImageAlt = getString(seo.twitter_image_alt);
412
- if (twitterImageAlt)
413
- setOrCreateMeta({ name: "twitter:image:alt" }, twitterImageAlt);
414
- const canonicalHref = typeof seo.canonical_url === "string" && seo.canonical_url.trim()
415
- ? seo.canonical_url.trim()
416
- : typeof alternates.canonical === "string" && alternates.canonical.trim()
417
- ? alternates.canonical.trim()
418
- : "";
419
- const languages = alternates.languages && typeof alternates.languages === "object" ? alternates.languages : {};
420
- const hasAnyAlternate = Boolean(alternates.xDefault) || Object.values(languages).some(Boolean);
421
- if (!canonicalHref && !(hreflangEnabled && hasAnyAlternate))
422
- return;
423
- // Why: search engines may ignore hreflang/canonical when multiple conflicting tags exist (we want sitemap + head parity).
424
- head
425
- .querySelectorAll('link[rel="canonical"], link[rel="alternate"][hreflang], link[data-Lovalingo="hreflang"], link[data-Lovalingo="canonical"]')
426
- .forEach((el) => el.remove());
427
- if (canonicalHref) {
428
- const canonical = document.createElement("link");
429
- canonical.rel = "canonical";
430
- canonical.href = canonicalHref;
431
- canonical.setAttribute("data-Lovalingo", "canonical");
432
- head.appendChild(canonical);
433
- }
434
- if (!hreflangEnabled)
435
- return;
436
- for (const [lang, href] of Object.entries(languages)) {
437
- if (!href)
438
- continue;
439
- const link = document.createElement("link");
440
- link.rel = "alternate";
441
- link.hreflang = lang;
442
- link.href = href;
443
- link.setAttribute("data-Lovalingo", "hreflang");
444
- head.appendChild(link);
445
- }
446
- if (alternates.xDefault) {
447
- const xDefault = document.createElement("link");
448
- xDefault.rel = "alternate";
449
- xDefault.hreflang = "x-default";
450
- xDefault.href = alternates.xDefault;
451
- xDefault.setAttribute("data-Lovalingo", "hreflang");
452
- head.appendChild(xDefault);
453
- }
454
- }
455
- catch {
456
- // ignore SEO errors
457
- }
170
+ applySeoBundleInternal(bundle, hreflangEnabled);
458
171
  }, []);
459
172
  // Keep <html lang> in sync and apply default-locale SEO (non-default locales use the bootstrap payload).
460
173
  useEffect(() => {
@@ -477,6 +190,7 @@ navigateRef, // For path mode routing
477
190
  enhancedPathConfig,
478
191
  mode,
479
192
  autoApplyRules,
193
+ seoProp: seo,
480
194
  isSeoActive,
481
195
  applySeoBundle,
482
196
  setEntitlements,
@@ -508,49 +222,7 @@ navigateRef, // For path mode routing
508
222
  }
509
223
  };
510
224
  }, [defaultLocale, detectLocale, enhancedPathConfig, loadData, locale, mode, trackPageviewOnce]);
511
- // SPA router hook-in: patch History API once (prevents stacked wrappers → request storms).
512
- useEffect(() => {
513
- if (typeof window === "undefined")
514
- return;
515
- if (historyPatchedRef.current)
516
- return;
517
- historyPatchedRef.current = true;
518
- const historyObj = window.history;
519
- const originalPushState = historyObj.pushState.bind(historyObj);
520
- const originalReplaceState = historyObj.replaceState.bind(historyObj);
521
- originalHistoryRef.current = { pushState: originalPushState, replaceState: originalReplaceState };
522
- const safeOnNavigate = () => {
523
- try {
524
- onNavigateRef.current();
525
- }
526
- catch {
527
- // ignore
528
- }
529
- };
530
- historyObj.pushState = ((...args) => {
531
- const ret = originalPushState(...args);
532
- safeOnNavigate();
533
- return ret;
534
- });
535
- historyObj.replaceState = ((...args) => {
536
- const ret = originalReplaceState(...args);
537
- safeOnNavigate();
538
- return ret;
539
- });
540
- window.addEventListener("popstate", safeOnNavigate);
541
- window.addEventListener("hashchange", safeOnNavigate);
542
- return () => {
543
- const originals = originalHistoryRef.current;
544
- if (originals) {
545
- historyObj.pushState = originals.pushState;
546
- historyObj.replaceState = originals.replaceState;
547
- }
548
- window.removeEventListener("popstate", safeOnNavigate);
549
- window.removeEventListener("hashchange", safeOnNavigate);
550
- originalHistoryRef.current = null;
551
- historyPatchedRef.current = false;
552
- };
553
- }, []);
225
+ useHistoryNavigationPatch(onNavigateRef);
554
226
  // Change locale
555
227
  const setLocale = useCallback((newLocale) => {
556
228
  void (async () => {
@@ -711,134 +383,7 @@ navigateRef, // For path mode routing
711
383
  const exclusions = await apiRef.current.fetchExclusions();
712
384
  setMarkerEngineExclusions(exclusions);
713
385
  }, [editSecretKey, enhancedPathConfig]);
714
- useEffect(() => {
715
- if (typeof window === "undefined")
716
- return;
717
- const existingHighlight = document.getElementById(EDIT_HIGHLIGHT_ID);
718
- const existingHint = document.getElementById(EDIT_HINT_ID);
719
- if (!editMode) {
720
- existingHighlight?.remove();
721
- existingHint?.remove();
722
- return;
723
- }
724
- const highlight = existingHighlight ||
725
- (() => {
726
- const node = document.createElement("div");
727
- node.id = EDIT_HIGHLIGHT_ID;
728
- node.setAttribute(EDIT_UI_ATTR, "true");
729
- node.setAttribute("data-lovalingo-exclude", "true");
730
- node.style.position = "fixed";
731
- node.style.pointerEvents = "none";
732
- node.style.zIndex = "2147483646";
733
- node.style.border = "2px solid #22c55e";
734
- node.style.background = "rgba(34, 197, 94, 0.12)";
735
- node.style.borderRadius = "8px";
736
- node.style.boxSizing = "border-box";
737
- node.style.transition = "transform 80ms ease, width 80ms ease, height 80ms ease";
738
- node.style.display = "none";
739
- document.body.appendChild(node);
740
- return node;
741
- })();
742
- const hint = existingHint ||
743
- (() => {
744
- const node = document.createElement("div");
745
- node.id = EDIT_HINT_ID;
746
- node.setAttribute(EDIT_UI_ATTR, "true");
747
- node.setAttribute("data-lovalingo-exclude", "true");
748
- node.style.position = "fixed";
749
- node.style.left = "12px";
750
- node.style.bottom = "12px";
751
- node.style.zIndex = "2147483647";
752
- node.style.background = "rgba(10, 10, 10, 0.85)";
753
- node.style.color = "#ffffff";
754
- node.style.fontSize = "12px";
755
- node.style.lineHeight = "1.4";
756
- node.style.padding = "8px 10px";
757
- node.style.borderRadius = "8px";
758
- node.style.border = "1px solid rgba(255, 255, 255, 0.15)";
759
- node.style.pointerEvents = "none";
760
- node.style.maxWidth = "280px";
761
- node.textContent = "Edit Mode: click an element to exclude. Press Esc to exit.";
762
- document.body.appendChild(node);
763
- return node;
764
- })();
765
- let rafId = null;
766
- let pendingTarget = null;
767
- const previousCursor = document.body.style.cursor;
768
- document.body.style.cursor = "crosshair";
769
- const updateHighlight = () => {
770
- rafId = null;
771
- if (!pendingTarget) {
772
- highlight.style.display = "none";
773
- return;
774
- }
775
- const rect = pendingTarget.getBoundingClientRect();
776
- if (rect.width <= 0 || rect.height <= 0) {
777
- highlight.style.display = "none";
778
- return;
779
- }
780
- highlight.style.display = "block";
781
- highlight.style.width = `${rect.width}px`;
782
- highlight.style.height = `${rect.height}px`;
783
- highlight.style.transform = `translate(${rect.left}px, ${rect.top}px)`;
784
- };
785
- const onMove = (event) => {
786
- const rawTarget = event.target;
787
- const target = rawTarget instanceof HTMLElement ? rawTarget : rawTarget instanceof Node ? rawTarget.parentElement : null;
788
- if (!target || target.closest(`[${EDIT_UI_ATTR}="true"]`)) {
789
- pendingTarget = null;
790
- }
791
- else if (target === document.body || target === document.documentElement) {
792
- pendingTarget = null;
793
- }
794
- else {
795
- pendingTarget = target;
796
- }
797
- if (rafId !== null)
798
- return;
799
- rafId = window.requestAnimationFrame(updateHighlight);
800
- };
801
- const onClick = async (event) => {
802
- const rawTarget = event.target;
803
- const target = rawTarget instanceof HTMLElement ? rawTarget : rawTarget instanceof Node ? rawTarget.parentElement : null;
804
- if (!target || target.closest(`[${EDIT_UI_ATTR}="true"]`))
805
- return;
806
- event.preventDefault();
807
- event.stopPropagation();
808
- event.stopImmediatePropagation();
809
- const selector = buildCssSelector(target);
810
- if (!selector)
811
- return;
812
- if (editSavingRef.current)
813
- return;
814
- editSavingRef.current = true;
815
- try {
816
- await excludeElement(selector);
817
- }
818
- finally {
819
- editSavingRef.current = false;
820
- }
821
- };
822
- const onKeyDown = (event) => {
823
- if (event.key === "Escape") {
824
- event.preventDefault();
825
- setEditMode(false);
826
- }
827
- };
828
- document.addEventListener("mousemove", onMove, true);
829
- document.addEventListener("click", onClick, true);
830
- document.addEventListener("keydown", onKeyDown, true);
831
- return () => {
832
- document.removeEventListener("mousemove", onMove, true);
833
- document.removeEventListener("click", onClick, true);
834
- document.removeEventListener("keydown", onKeyDown, true);
835
- if (rafId !== null)
836
- window.cancelAnimationFrame(rafId);
837
- highlight.remove();
838
- hint.remove();
839
- document.body.style.cursor = previousCursor;
840
- };
841
- }, [editMode, excludeElement, setEditMode]);
386
+ useEditModeOverlay({ editMode, excludeElement, setEditMode });
842
387
  const contextValue = {
843
388
  locale,
844
389
  setLocale,
@@ -0,0 +1,13 @@
1
+ import { describe, expect, test } from "vitest";
2
+ import { resolveCanonicalHref } from "../seoUtils";
3
+ describe("resolveCanonicalHref", () => {
4
+ test("prefers alternates canonical when present", () => {
5
+ expect(resolveCanonicalHref({ canonical_url: "https://example.com/en" }, { canonical: "https://example.com/fr" })).toBe("https://example.com/fr");
6
+ });
7
+ test("falls back to seo.canonical_url", () => {
8
+ expect(resolveCanonicalHref({ canonical_url: "https://example.com/en" }, null)).toBe("https://example.com/en");
9
+ });
10
+ test("returns empty when none", () => {
11
+ expect(resolveCanonicalHref({}, null)).toBe("");
12
+ });
13
+ });
@@ -0,0 +1,6 @@
1
+ export declare function readEditParams(): {
2
+ enabled: boolean;
3
+ editKey: string | null;
4
+ };
5
+ export declare function cssEscape(value: string): string;
6
+ export declare function buildCssSelector(element: HTMLElement, maxDepth?: number): string | null;
@@ -0,0 +1,59 @@
1
+ import { EDIT_KEY_PARAM, EDIT_MODE_PARAM, EDIT_MODE_VALUES } from './providerConstants';
2
+ export function readEditParams() {
3
+ if (typeof window === 'undefined')
4
+ return { enabled: false, editKey: null };
5
+ const params = new URLSearchParams(window.location.search);
6
+ const rawFlag = (params.get(EDIT_MODE_PARAM) || params.get('editMode') || '').trim().toLowerCase();
7
+ const enabled = EDIT_MODE_VALUES.has(rawFlag);
8
+ const editKey = (params.get(EDIT_KEY_PARAM) || params.get('editKey') || '').trim() || null;
9
+ return { enabled, editKey };
10
+ }
11
+ export function cssEscape(value) {
12
+ const esc = typeof window !== 'undefined' && window?.CSS?.escape;
13
+ if (typeof esc === 'function')
14
+ return esc(value);
15
+ return value.replace(/[ !"#$%&'()*+,.\/:;<=>?@[\\\]^`{|}~]/g, '\\$&');
16
+ }
17
+ // Why: build stable, short selectors for exclusions without requiring IDs.
18
+ export function buildCssSelector(element, maxDepth = 5) {
19
+ if (!element || !element.tagName)
20
+ return null;
21
+ if (element.id)
22
+ return `#${cssEscape(element.id)}`;
23
+ const parts = [];
24
+ let node = element;
25
+ let depth = 0;
26
+ while (node && depth < maxDepth) {
27
+ const tag = node.tagName.toLowerCase();
28
+ if (!tag || tag === 'html')
29
+ break;
30
+ let part = tag;
31
+ const nodeTag = node.tagName;
32
+ const classes = Array.from(node.classList || [])
33
+ .filter(Boolean)
34
+ .filter((cls) => !cls.startsWith('lovalingo-'))
35
+ .slice(0, 2);
36
+ if (classes.length > 0) {
37
+ part += `.${classes.map(cssEscape).join('.')}`;
38
+ }
39
+ const parentEl = node.parentElement;
40
+ if (parentEl) {
41
+ const siblings = Array.from(parentEl.children).filter((child) => child instanceof HTMLElement && child.tagName === nodeTag);
42
+ if (siblings.length > 1) {
43
+ part += `:nth-of-type(${siblings.indexOf(node) + 1})`;
44
+ }
45
+ }
46
+ parts.unshift(part);
47
+ const selector = parts.join(' > ');
48
+ try {
49
+ if (document.querySelectorAll(selector).length === 1)
50
+ return selector;
51
+ }
52
+ catch {
53
+ // ignore invalid selector attempts
54
+ }
55
+ node = parentEl;
56
+ depth += 1;
57
+ }
58
+ return parts.join(' > ') || null;
59
+ }
@@ -0,0 +1,8 @@
1
+ type DetectLocaleArgs = {
2
+ routing: 'path' | 'query';
3
+ allLocales: string[];
4
+ defaultLocale: string;
5
+ };
6
+ export declare function detectLocaleFromLocation({ routing, allLocales, defaultLocale }: DetectLocaleArgs): string;
7
+ export declare function setDocumentLocale(nextLocale: string): void;
8
+ export {};