@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
@@ -0,0 +1,46 @@
1
+ import { warnDebug } from '../../utils/logger';
2
+ import { LOCALE_STORAGE_KEY } from './providerConstants';
3
+ export function detectLocaleFromLocation({ routing, allLocales, defaultLocale }) {
4
+ // 1. Check URL first based on routing mode
5
+ if (routing === 'path') {
6
+ // Path mode: language is in path (/en/pricing, /fr/about)
7
+ const pathLocale = window.location.pathname.split('/')[1];
8
+ if (pathLocale && allLocales.includes(pathLocale)) {
9
+ return pathLocale;
10
+ }
11
+ }
12
+ else if (routing === 'query') {
13
+ // Query mode: language is in query param (/pricing?t=fr)
14
+ const params = new URLSearchParams(window.location.search);
15
+ const queryLocale = params.get('t') || params.get('locale');
16
+ if (queryLocale && allLocales.includes(queryLocale)) {
17
+ return queryLocale;
18
+ }
19
+ }
20
+ // 2. Check localStorage (fallback for all routing modes)
21
+ try {
22
+ const storedLocale = localStorage.getItem(LOCALE_STORAGE_KEY);
23
+ if (storedLocale && allLocales.includes(storedLocale)) {
24
+ return storedLocale;
25
+ }
26
+ }
27
+ catch (e) {
28
+ // localStorage might be unavailable (SSR, private browsing)
29
+ warnDebug('localStorage not available:', e);
30
+ }
31
+ // 3. Default locale
32
+ return defaultLocale;
33
+ }
34
+ export function setDocumentLocale(nextLocale) {
35
+ try {
36
+ const html = document.documentElement;
37
+ if (!html)
38
+ return;
39
+ html.setAttribute('lang', nextLocale);
40
+ const rtlLocales = new Set(['ar', 'he', 'fa', 'ur']);
41
+ html.setAttribute('dir', rtlLocales.has(nextLocale) ? 'rtl' : 'ltr');
42
+ }
43
+ catch {
44
+ // ignore
45
+ }
46
+ }
@@ -0,0 +1,12 @@
1
+ import type { PathNormalizationConfig } from '../../utils/pathNormalizer';
2
+ export declare const LOCALE_STORAGE_KEY = "Lovalingo_locale";
3
+ export declare const LOADING_BG_STORAGE_PREFIX = "Lovalingo_loading_bg_color";
4
+ export declare const BRANDING_STORAGE_PREFIX = "Lovalingo_branding_enabled";
5
+ export declare const EDIT_MODE_PARAM = "edit_mode";
6
+ export declare const EDIT_KEY_PARAM = "edit_key";
7
+ export declare const LIVE_MISSES_QUERY_PARAM = "lovalingo_live_misses";
8
+ export declare const DEFAULT_PATH_NORMALIZATION: PathNormalizationConfig;
9
+ export declare const EDIT_MODE_VALUES: Set<string>;
10
+ export declare const EDIT_UI_ATTR = "data-lovalingo-edit-ui";
11
+ export declare const EDIT_HIGHLIGHT_ID = "lovalingo-edit-highlight";
12
+ export declare const EDIT_HINT_ID = "lovalingo-edit-hint";
@@ -0,0 +1,11 @@
1
+ export const LOCALE_STORAGE_KEY = 'Lovalingo_locale';
2
+ export const LOADING_BG_STORAGE_PREFIX = 'Lovalingo_loading_bg_color';
3
+ export const BRANDING_STORAGE_PREFIX = 'Lovalingo_branding_enabled';
4
+ export const EDIT_MODE_PARAM = 'edit_mode';
5
+ export const EDIT_KEY_PARAM = 'edit_key';
6
+ export const LIVE_MISSES_QUERY_PARAM = 'lovalingo_live_misses';
7
+ export const DEFAULT_PATH_NORMALIZATION = { enabled: true };
8
+ export const EDIT_MODE_VALUES = new Set(['1', 'true', 'yes', 'on']);
9
+ export const EDIT_UI_ATTR = 'data-lovalingo-edit-ui';
10
+ export const EDIT_HIGHLIGHT_ID = 'lovalingo-edit-highlight';
11
+ export const EDIT_HINT_ID = 'lovalingo-edit-hint';
@@ -0,0 +1,8 @@
1
+ export declare function applySeoBundle(bundle: {
2
+ seo?: Record<string, unknown>;
3
+ alternates?: any;
4
+ jsonld?: any;
5
+ } | null, hreflangEnabled: boolean): void;
6
+ export declare function resolveCanonicalHref(seo: Record<string, unknown>, alternates: {
7
+ canonical?: string;
8
+ } | null | undefined): string;
@@ -0,0 +1,118 @@
1
+ export function applySeoBundle(bundle, hreflangEnabled) {
2
+ try {
3
+ const head = document.head;
4
+ if (!head)
5
+ return;
6
+ if (!bundle)
7
+ return;
8
+ const seo = (bundle?.seo && typeof bundle.seo === 'object' ? bundle.seo : {});
9
+ const alternates = (bundle?.alternates && typeof bundle.alternates === 'object' ? bundle.alternates : {});
10
+ const setOrCreateMeta = (attrs, content) => {
11
+ const key = attrs.name ? `meta[name="${attrs.name}"]` : attrs.property ? `meta[property="${attrs.property}"]` : '';
12
+ const selector = key || 'meta';
13
+ const existing = selector ? head.querySelector(selector) : null;
14
+ const el = existing || document.createElement('meta');
15
+ for (const [k, v] of Object.entries(attrs)) {
16
+ el.setAttribute(k, v);
17
+ }
18
+ el.setAttribute('content', content);
19
+ if (!existing)
20
+ head.appendChild(el);
21
+ };
22
+ const setOrCreateTitle = (value) => {
23
+ const existing = head.querySelector('title');
24
+ if (existing) {
25
+ existing.textContent = value;
26
+ return;
27
+ }
28
+ const el = document.createElement('title');
29
+ el.textContent = value;
30
+ head.appendChild(el);
31
+ };
32
+ const getString = (value) => (typeof value === 'string' && value.trim() ? value.trim() : '');
33
+ const title = getString(seo.title);
34
+ if (title)
35
+ setOrCreateTitle(title);
36
+ const description = getString(seo.description);
37
+ if (description)
38
+ setOrCreateMeta({ name: 'description' }, description);
39
+ const robots = getString(seo.robots);
40
+ if (robots)
41
+ setOrCreateMeta({ name: 'robots' }, robots);
42
+ const ogTitle = getString(seo.og_title);
43
+ if (ogTitle)
44
+ setOrCreateMeta({ property: 'og:title' }, ogTitle);
45
+ const ogDescription = getString(seo.og_description);
46
+ if (ogDescription)
47
+ setOrCreateMeta({ property: 'og:description' }, ogDescription);
48
+ const ogImage = getString(seo.og_image);
49
+ if (ogImage)
50
+ setOrCreateMeta({ property: 'og:image' }, ogImage);
51
+ const ogImageAlt = getString(seo.og_image_alt);
52
+ if (ogImageAlt)
53
+ setOrCreateMeta({ property: 'og:image:alt' }, ogImageAlt);
54
+ const twitterCard = getString(seo.twitter_card);
55
+ if (twitterCard)
56
+ setOrCreateMeta({ name: 'twitter:card' }, twitterCard);
57
+ const twitterTitle = getString(seo.twitter_title);
58
+ if (twitterTitle)
59
+ setOrCreateMeta({ name: 'twitter:title' }, twitterTitle);
60
+ const twitterDescription = getString(seo.twitter_description);
61
+ if (twitterDescription)
62
+ setOrCreateMeta({ name: 'twitter:description' }, twitterDescription);
63
+ const twitterImage = getString(seo.twitter_image);
64
+ if (twitterImage)
65
+ setOrCreateMeta({ name: 'twitter:image' }, twitterImage);
66
+ const twitterImageAlt = getString(seo.twitter_image_alt);
67
+ if (twitterImageAlt)
68
+ setOrCreateMeta({ name: 'twitter:image:alt' }, twitterImageAlt);
69
+ const canonicalHref = resolveCanonicalHref(seo, alternates);
70
+ const languages = alternates.languages && typeof alternates.languages === 'object' ? alternates.languages : {};
71
+ const hasAnyAlternate = Boolean(alternates.xDefault) || Object.values(languages).some(Boolean);
72
+ if (!canonicalHref && !(hreflangEnabled && hasAnyAlternate))
73
+ return;
74
+ // Why: search engines may ignore hreflang/canonical when multiple conflicting tags exist (we want sitemap + head parity).
75
+ head
76
+ .querySelectorAll('link[rel="canonical"], link[rel="alternate"][hreflang], link[data-Lovalingo="hreflang"], link[data-Lovalingo="canonical"]')
77
+ .forEach((el) => el.remove());
78
+ if (canonicalHref) {
79
+ const canonical = document.createElement('link');
80
+ canonical.rel = 'canonical';
81
+ canonical.href = canonicalHref;
82
+ canonical.setAttribute('data-Lovalingo', 'canonical');
83
+ head.appendChild(canonical);
84
+ }
85
+ if (!hreflangEnabled)
86
+ return;
87
+ for (const [lang, href] of Object.entries(languages)) {
88
+ if (!href)
89
+ continue;
90
+ const link = document.createElement('link');
91
+ link.rel = 'alternate';
92
+ link.hreflang = lang;
93
+ link.href = href;
94
+ link.setAttribute('data-Lovalingo', 'hreflang');
95
+ head.appendChild(link);
96
+ }
97
+ if (alternates.xDefault) {
98
+ const xDefault = document.createElement('link');
99
+ xDefault.rel = 'alternate';
100
+ xDefault.hreflang = 'x-default';
101
+ xDefault.href = alternates.xDefault;
102
+ xDefault.setAttribute('data-Lovalingo', 'hreflang');
103
+ head.appendChild(xDefault);
104
+ }
105
+ }
106
+ catch {
107
+ // ignore SEO errors
108
+ }
109
+ }
110
+ export function resolveCanonicalHref(seo, alternates) {
111
+ const alt = alternates && typeof alternates === "object" ? alternates : null;
112
+ // Why: when alternates exist, canonical must be self-referencing for the active locale.
113
+ if (alt && typeof alt.canonical === "string" && alt.canonical.trim())
114
+ return alt.canonical.trim();
115
+ if (typeof seo?.canonical_url === "string" && seo.canonical_url.trim())
116
+ return seo.canonical_url.trim();
117
+ return "";
118
+ }
@@ -0,0 +1,7 @@
1
+ type UseEditModeOverlayArgs = {
2
+ editMode: boolean;
3
+ excludeElement: (selector: string) => Promise<void>;
4
+ setEditMode: React.Dispatch<React.SetStateAction<boolean>>;
5
+ };
6
+ export declare function useEditModeOverlay({ editMode, excludeElement, setEditMode }: UseEditModeOverlayArgs): void;
7
+ export {};
@@ -0,0 +1,134 @@
1
+ import { useEffect, useRef } from 'react';
2
+ import { buildCssSelector } from './editModeUtils';
3
+ import { EDIT_HINT_ID, EDIT_HIGHLIGHT_ID, EDIT_UI_ATTR } from './providerConstants';
4
+ export function useEditModeOverlay({ editMode, excludeElement, setEditMode }) {
5
+ const editSavingRef = useRef(false);
6
+ useEffect(() => {
7
+ if (typeof window === 'undefined')
8
+ return;
9
+ const existingHighlight = document.getElementById(EDIT_HIGHLIGHT_ID);
10
+ const existingHint = document.getElementById(EDIT_HINT_ID);
11
+ if (!editMode) {
12
+ existingHighlight?.remove();
13
+ existingHint?.remove();
14
+ return;
15
+ }
16
+ const highlight = existingHighlight ||
17
+ (() => {
18
+ const node = document.createElement('div');
19
+ node.id = EDIT_HIGHLIGHT_ID;
20
+ node.setAttribute(EDIT_UI_ATTR, 'true');
21
+ node.setAttribute('data-lovalingo-exclude', 'true');
22
+ node.style.position = 'fixed';
23
+ node.style.pointerEvents = 'none';
24
+ node.style.zIndex = '2147483646';
25
+ node.style.border = '2px solid #22c55e';
26
+ node.style.background = 'rgba(34, 197, 94, 0.12)';
27
+ node.style.borderRadius = '8px';
28
+ node.style.boxSizing = 'border-box';
29
+ node.style.transition = 'transform 80ms ease, width 80ms ease, height 80ms ease';
30
+ node.style.display = 'none';
31
+ document.body.appendChild(node);
32
+ return node;
33
+ })();
34
+ const hint = existingHint ||
35
+ (() => {
36
+ const node = document.createElement('div');
37
+ node.id = EDIT_HINT_ID;
38
+ node.setAttribute(EDIT_UI_ATTR, 'true');
39
+ node.setAttribute('data-lovalingo-exclude', 'true');
40
+ node.style.position = 'fixed';
41
+ node.style.left = '12px';
42
+ node.style.bottom = '12px';
43
+ node.style.zIndex = '2147483647';
44
+ node.style.background = 'rgba(10, 10, 10, 0.85)';
45
+ node.style.color = '#ffffff';
46
+ node.style.fontSize = '12px';
47
+ node.style.lineHeight = '1.4';
48
+ node.style.padding = '8px 10px';
49
+ node.style.borderRadius = '8px';
50
+ node.style.border = '1px solid rgba(255, 255, 255, 0.15)';
51
+ node.style.pointerEvents = 'none';
52
+ node.style.maxWidth = '280px';
53
+ node.textContent = 'Edit Mode: click an element to exclude. Press Esc to exit.';
54
+ document.body.appendChild(node);
55
+ return node;
56
+ })();
57
+ let rafId = null;
58
+ let pendingTarget = null;
59
+ const previousCursor = document.body.style.cursor;
60
+ document.body.style.cursor = 'crosshair';
61
+ const updateHighlight = () => {
62
+ rafId = null;
63
+ if (!pendingTarget) {
64
+ highlight.style.display = 'none';
65
+ return;
66
+ }
67
+ const rect = pendingTarget.getBoundingClientRect();
68
+ if (rect.width <= 0 || rect.height <= 0) {
69
+ highlight.style.display = 'none';
70
+ return;
71
+ }
72
+ highlight.style.display = 'block';
73
+ highlight.style.width = `${rect.width}px`;
74
+ highlight.style.height = `${rect.height}px`;
75
+ highlight.style.transform = `translate(${rect.left}px, ${rect.top}px)`;
76
+ };
77
+ const onMove = (event) => {
78
+ const rawTarget = event.target;
79
+ const target = rawTarget instanceof HTMLElement ? rawTarget : rawTarget instanceof Node ? rawTarget.parentElement : null;
80
+ if (!target || target.closest(`[${EDIT_UI_ATTR}="true"]`)) {
81
+ pendingTarget = null;
82
+ }
83
+ else if (target === document.body || target === document.documentElement) {
84
+ pendingTarget = null;
85
+ }
86
+ else {
87
+ pendingTarget = target;
88
+ }
89
+ if (rafId !== null)
90
+ return;
91
+ rafId = window.requestAnimationFrame(updateHighlight);
92
+ };
93
+ const onClick = async (event) => {
94
+ const rawTarget = event.target;
95
+ const target = rawTarget instanceof HTMLElement ? rawTarget : rawTarget instanceof Node ? rawTarget.parentElement : null;
96
+ if (!target || target.closest(`[${EDIT_UI_ATTR}="true"]`))
97
+ return;
98
+ event.preventDefault();
99
+ event.stopPropagation();
100
+ event.stopImmediatePropagation();
101
+ const selector = buildCssSelector(target);
102
+ if (!selector)
103
+ return;
104
+ if (editSavingRef.current)
105
+ return;
106
+ editSavingRef.current = true;
107
+ try {
108
+ await excludeElement(selector);
109
+ }
110
+ finally {
111
+ editSavingRef.current = false;
112
+ }
113
+ };
114
+ const onKeyDown = (event) => {
115
+ if (event.key === 'Escape') {
116
+ event.preventDefault();
117
+ setEditMode(false);
118
+ }
119
+ };
120
+ document.addEventListener('mousemove', onMove, true);
121
+ document.addEventListener('click', onClick, true);
122
+ document.addEventListener('keydown', onKeyDown, true);
123
+ return () => {
124
+ document.removeEventListener('mousemove', onMove, true);
125
+ document.removeEventListener('click', onClick, true);
126
+ document.removeEventListener('keydown', onKeyDown, true);
127
+ if (rafId !== null)
128
+ window.cancelAnimationFrame(rafId);
129
+ highlight.remove();
130
+ hint.remove();
131
+ document.body.style.cursor = previousCursor;
132
+ };
133
+ }, [editMode, excludeElement, setEditMode]);
134
+ }
@@ -0,0 +1,3 @@
1
+ type OnNavigateRef = React.MutableRefObject<() => void>;
2
+ export declare function useHistoryNavigationPatch(onNavigateRef: OnNavigateRef): void;
3
+ export {};
@@ -0,0 +1,47 @@
1
+ import { useEffect, useRef } from 'react';
2
+ export function useHistoryNavigationPatch(onNavigateRef) {
3
+ const historyPatchedRef = useRef(false);
4
+ const originalHistoryRef = useRef(null);
5
+ useEffect(() => {
6
+ if (typeof window === 'undefined')
7
+ return;
8
+ if (historyPatchedRef.current)
9
+ return;
10
+ historyPatchedRef.current = true;
11
+ const historyObj = window.history;
12
+ const originalPushState = historyObj.pushState.bind(historyObj);
13
+ const originalReplaceState = historyObj.replaceState.bind(historyObj);
14
+ originalHistoryRef.current = { pushState: originalPushState, replaceState: originalReplaceState };
15
+ const safeOnNavigate = () => {
16
+ try {
17
+ onNavigateRef.current();
18
+ }
19
+ catch {
20
+ // ignore
21
+ }
22
+ };
23
+ historyObj.pushState = ((...args) => {
24
+ const ret = originalPushState(...args);
25
+ safeOnNavigate();
26
+ return ret;
27
+ });
28
+ historyObj.replaceState = ((...args) => {
29
+ const ret = originalReplaceState(...args);
30
+ safeOnNavigate();
31
+ return ret;
32
+ });
33
+ window.addEventListener('popstate', safeOnNavigate);
34
+ window.addEventListener('hashchange', safeOnNavigate);
35
+ return () => {
36
+ const originals = originalHistoryRef.current;
37
+ if (originals) {
38
+ historyObj.pushState = originals.pushState;
39
+ historyObj.replaceState = originals.replaceState;
40
+ }
41
+ window.removeEventListener('popstate', safeOnNavigate);
42
+ window.removeEventListener('hashchange', safeOnNavigate);
43
+ originalHistoryRef.current = null;
44
+ historyPatchedRef.current = false;
45
+ };
46
+ }, [onNavigateRef]);
47
+ }
@@ -0,0 +1,12 @@
1
+ type UseProviderCacheArgs = {
2
+ overlayBgColor?: string | null;
3
+ resolvedApiKey: string;
4
+ };
5
+ export declare function useProviderCache({ overlayBgColor, resolvedApiKey }: UseProviderCacheArgs): {
6
+ readonly brandingEnabled: boolean;
7
+ readonly setBrandingEnabled: import("react").Dispatch<import("react").SetStateAction<boolean>>;
8
+ readonly setCachedBrandingEnabled: (enabled: boolean | null | undefined) => void;
9
+ readonly getCachedLoadingBgColor: () => string;
10
+ readonly setCachedLoadingBgColor: (color: string | null | undefined) => void;
11
+ };
12
+ export {};
@@ -0,0 +1,82 @@
1
+ import { useCallback, useEffect, useState } from 'react';
2
+ import { BRANDING_STORAGE_PREFIX, LOADING_BG_STORAGE_PREFIX } from './providerConstants';
3
+ export function useProviderCache({ overlayBgColor, resolvedApiKey }) {
4
+ const loadingBgStorageKey = `${LOADING_BG_STORAGE_PREFIX}:${resolvedApiKey || 'anonymous'}`;
5
+ const brandingStorageKey = `${BRANDING_STORAGE_PREFIX}:${resolvedApiKey || 'anonymous'}`;
6
+ const readBrandingCache = useCallback(() => {
7
+ try {
8
+ const cached = (localStorage.getItem(brandingStorageKey) || '').trim();
9
+ if (cached === '0')
10
+ return false;
11
+ if (cached === '1')
12
+ return true;
13
+ }
14
+ catch {
15
+ // ignore
16
+ }
17
+ return true;
18
+ }, [brandingStorageKey]);
19
+ const [brandingEnabled, setBrandingEnabled] = useState(readBrandingCache);
20
+ const getCachedLoadingBgColor = useCallback(() => {
21
+ const configured = (overlayBgColor || '').toString().trim();
22
+ if (/^#[0-9a-fA-F]{6}$/.test(configured))
23
+ return configured;
24
+ try {
25
+ const cached = localStorage.getItem(loadingBgStorageKey) || '';
26
+ if (/^#[0-9a-fA-F]{6}$/.test(cached.trim()))
27
+ return cached.trim();
28
+ }
29
+ catch {
30
+ // ignore
31
+ }
32
+ // Why: default to the site's existing background to reduce a visible white flash on non-white themes.
33
+ try {
34
+ const bodyBg = window.getComputedStyle(document.body).backgroundColor;
35
+ if (bodyBg && bodyBg !== 'transparent' && bodyBg !== 'rgba(0, 0, 0, 0)')
36
+ return bodyBg;
37
+ const htmlBg = window.getComputedStyle(document.documentElement).backgroundColor;
38
+ if (htmlBg && htmlBg !== 'transparent' && htmlBg !== 'rgba(0, 0, 0, 0)')
39
+ return htmlBg;
40
+ }
41
+ catch {
42
+ // ignore
43
+ }
44
+ return '#ffffff';
45
+ }, [loadingBgStorageKey, overlayBgColor]);
46
+ const setCachedLoadingBgColor = useCallback((color) => {
47
+ const next = (color || '').toString().trim();
48
+ if (!/^#[0-9a-fA-F]{6}$/.test(next))
49
+ return;
50
+ try {
51
+ localStorage.setItem(loadingBgStorageKey, next);
52
+ }
53
+ catch {
54
+ // ignore
55
+ }
56
+ }, [loadingBgStorageKey]);
57
+ useEffect(() => {
58
+ // Why: make `overlayBgColor` the source of truth while keeping the existing cache key for backwards compatibility.
59
+ const configured = (overlayBgColor || '').toString().trim();
60
+ if (!/^#[0-9a-fA-F]{6}$/.test(configured))
61
+ return;
62
+ setCachedLoadingBgColor(configured);
63
+ }, [overlayBgColor, setCachedLoadingBgColor]);
64
+ const setCachedBrandingEnabled = useCallback((enabled) => {
65
+ try {
66
+ localStorage.setItem(brandingStorageKey, enabled === false ? '0' : '1');
67
+ }
68
+ catch {
69
+ // ignore
70
+ }
71
+ }, [brandingStorageKey]);
72
+ useEffect(() => {
73
+ setBrandingEnabled(readBrandingCache());
74
+ }, [readBrandingCache]);
75
+ return {
76
+ brandingEnabled,
77
+ setBrandingEnabled,
78
+ setCachedBrandingEnabled,
79
+ getCachedLoadingBgColor,
80
+ setCachedLoadingBgColor,
81
+ };
82
+ }
@@ -12,6 +12,7 @@ type UseBundleLoadingOptions = {
12
12
  enhancedPathConfig: PathNormalizationConfig;
13
13
  mode: "dom" | undefined;
14
14
  autoApplyRules: boolean;
15
+ seoProp: boolean;
15
16
  isSeoActive: () => boolean;
16
17
  applySeoBundle: (bundle: {
17
18
  seo?: Record<string, unknown>;
@@ -24,7 +25,7 @@ type UseBundleLoadingOptions = {
24
25
  setCachedLoadingBgColor: (color: string | null | undefined) => void;
25
26
  getCachedLoadingBgColor: () => string;
26
27
  };
27
- export declare function useBundleLoading({ apiRef, resolvedApiKey, defaultLocale, routing, allLocales, nonLocalizedPaths, enhancedPathConfig, mode, autoApplyRules, isSeoActive, applySeoBundle, setEntitlements, setBrandingEnabled, setCachedBrandingEnabled, setCachedLoadingBgColor, getCachedLoadingBgColor, }: UseBundleLoadingOptions): {
28
+ export declare function useBundleLoading({ apiRef, resolvedApiKey, defaultLocale, routing, allLocales, nonLocalizedPaths, enhancedPathConfig, mode, autoApplyRules, seoProp, isSeoActive, applySeoBundle, setEntitlements, setBrandingEnabled, setCachedBrandingEnabled, setCachedLoadingBgColor, getCachedLoadingBgColor, }: UseBundleLoadingOptions): {
28
29
  isLoading: boolean;
29
30
  isNavigatingRef: React.MutableRefObject<boolean>;
30
31
  loadData: (targetLocale: string, previousLocale?: string) => Promise<void>;
@@ -4,10 +4,11 @@ import { errorDebug, logDebug } from "../../utils/logger";
4
4
  import { isNonLocalizedPath, stripLocalePrefix } from "../../utils/nonLocalizedPaths";
5
5
  import { processPath } from "../../utils/pathNormalizer";
6
6
  import { applyActiveTranslations, restoreDom, setActiveTranslations, setMarkerEngineExclusions } from "../../utils/markerEngine";
7
+ import { mergeEntitlementsSeoEnabled } from "../../utils/mergeEntitlements";
7
8
  import { useDomRules } from "./useDomRules";
8
9
  import { PREHIDE_FAILSAFE_MS, usePrehide } from "./usePrehide";
9
10
  const CRITICAL_CACHE_PREFIX = "Lovalingo_critical_v0_3";
10
- export function useBundleLoading({ apiRef, resolvedApiKey, defaultLocale, routing, allLocales, nonLocalizedPaths, enhancedPathConfig, mode, autoApplyRules, isSeoActive, applySeoBundle, setEntitlements, setBrandingEnabled, setCachedBrandingEnabled, setCachedLoadingBgColor, getCachedLoadingBgColor, }) {
11
+ export function useBundleLoading({ apiRef, resolvedApiKey, defaultLocale, routing, allLocales, nonLocalizedPaths, enhancedPathConfig, mode, autoApplyRules, seoProp, isSeoActive, applySeoBundle, setEntitlements, setBrandingEnabled, setCachedBrandingEnabled, setCachedLoadingBgColor, getCachedLoadingBgColor, }) {
11
12
  const [isLoading, setIsLoading] = useState(false);
12
13
  const retryTimeoutRef = useRef(null);
13
14
  const loadingFailsafeTimeoutRef = useRef(null);
@@ -208,7 +209,8 @@ export function useBundleLoading({ apiRef, resolvedApiKey, defaultLocale, routin
208
209
  revealedViaCachedCritical = true;
209
210
  }
210
211
  const bootstrap = await apiRef.current.fetchBootstrap(targetLocale, currentPath);
211
- const nextEntitlements = bootstrap?.entitlements || apiRef.current.getEntitlements();
212
+ const entitlementsBase = bootstrap?.entitlements || apiRef.current.getEntitlements();
213
+ const nextEntitlements = mergeEntitlementsSeoEnabled(entitlementsBase, bootstrap?.seoEnabled);
212
214
  if (nextEntitlements)
213
215
  setEntitlements(nextEntitlements);
214
216
  if (bootstrap?.loading_bg_color) {
@@ -260,7 +262,17 @@ export function useBundleLoading({ apiRef, resolvedApiKey, defaultLocale, routin
260
262
  : await apiRef.current.fetchDomRules(targetLocale);
261
263
  setAndApplyDomRules(cacheKey, domRules);
262
264
  }
263
- if (isSeoActive() && bootstrap) {
265
+ let seoActiveForBootstrap = isSeoActive();
266
+ if (seoProp === false) {
267
+ seoActiveForBootstrap = false;
268
+ }
269
+ else if (typeof bootstrap?.seoEnabled === "boolean") {
270
+ seoActiveForBootstrap = bootstrap.seoEnabled !== false;
271
+ }
272
+ else if (typeof nextEntitlements?.seoEnabled === "boolean") {
273
+ seoActiveForBootstrap = nextEntitlements.seoEnabled !== false;
274
+ }
275
+ if (seoActiveForBootstrap && bootstrap) {
264
276
  const hreflangEnabled = Boolean((bootstrap.entitlements || nextEntitlements)?.hreflangEnabled);
265
277
  applySeoBundle({ seo: bootstrap.seo, alternates: bootstrap.alternates, jsonld: bootstrap.jsonld }, hreflangEnabled);
266
278
  }
@@ -1,82 +1,7 @@
1
- import { Translation, Exclusion, DomRule } from '../types';
1
+ import type { Translation, Exclusion, DomRule } from '../types';
2
+ import type { BootstrapResponse, MissReportItem, MissReportOptions, MissReportResponse, ProjectEntitlements, SeoBundleResponse } from './apiTypes';
2
3
  import { PathNormalizationConfig } from './pathNormalizer';
3
- export interface ProjectEntitlements {
4
- tier: 'starter' | 'startup' | 'global';
5
- maxTargetLocales: number;
6
- allowedTargetLocales: string[];
7
- brandingRequired: boolean;
8
- hreflangEnabled: boolean;
9
- seoEnabled?: boolean;
10
- }
11
- export type SeoBundleResponse = {
12
- locale?: string;
13
- normalized_path?: string;
14
- routing_strategy?: string;
15
- seo?: Record<string, unknown>;
16
- alternates?: {
17
- canonical?: string;
18
- xDefault?: string;
19
- languages?: Record<string, string>;
20
- };
21
- seoEnabled?: boolean;
22
- entitlements?: ProjectEntitlements;
23
- };
24
- export type BootstrapResponse = {
25
- locale?: string;
26
- normalized_path?: string;
27
- routing_strategy?: string;
28
- non_localized_paths?: Array<{
29
- pattern?: string;
30
- match_type?: string;
31
- updated_at?: string | null;
32
- }>;
33
- inactive_pages?: Array<{
34
- page_path?: string;
35
- updated_at?: string | null;
36
- }>;
37
- loading_bg_color?: string | null;
38
- branding_enabled?: boolean;
39
- seoEnabled?: boolean;
40
- entitlements?: ProjectEntitlements;
41
- alternates?: {
42
- canonical?: string;
43
- xDefault?: string;
44
- languages?: Record<string, string>;
45
- } | null;
46
- seo?: Record<string, unknown>;
47
- jsonld?: Array<{
48
- type: string;
49
- json: unknown;
50
- hash?: string;
51
- }>;
52
- dom_rules?: DomRule[];
53
- exclusions?: unknown[];
54
- critical?: {
55
- map?: Record<string, string>;
56
- keys?: number;
57
- viewport?: unknown;
58
- etag?: string;
59
- };
60
- etag?: string;
61
- };
62
- export type MissReportItem = {
63
- source_text: string;
64
- semantic_context?: string | null;
65
- };
66
- export type MissReportResponse = {
67
- translations?: Array<{
68
- source_text: string;
69
- translated_text: string;
70
- }>;
71
- pii?: string[];
72
- ignored?: boolean;
73
- reason?: string;
74
- };
75
- export type MissReportOptions = {
76
- pathOrUrl?: string;
77
- sourceLocale?: string;
78
- locales?: string[];
79
- };
4
+ export type { ProjectEntitlements, SeoBundleResponse, BootstrapResponse, MissReportItem, MissReportResponse, MissReportOptions } from './apiTypes';
80
5
  export declare class LovalingoAPI {
81
6
  private apiKey;
82
7
  private apiBase;