@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.
- package/dist/__tests__/languageFlags.test.d.ts +1 -0
- package/dist/__tests__/languageFlags.test.js +42 -0
- package/dist/__tests__/mergeEntitlements.test.d.ts +1 -0
- package/dist/__tests__/mergeEntitlements.test.js +27 -0
- package/dist/components/LanguageSwitcher.js +80 -53
- package/dist/components/LovalingoProvider.js +18 -473
- package/dist/components/provider/__tests__/seoUtils.test.d.ts +1 -0
- package/dist/components/provider/__tests__/seoUtils.test.js +13 -0
- package/dist/components/provider/editModeUtils.d.ts +6 -0
- package/dist/components/provider/editModeUtils.js +59 -0
- package/dist/components/provider/localeUtils.d.ts +8 -0
- package/dist/components/provider/localeUtils.js +46 -0
- package/dist/components/provider/providerConstants.d.ts +12 -0
- package/dist/components/provider/providerConstants.js +11 -0
- package/dist/components/provider/seoUtils.d.ts +8 -0
- package/dist/components/provider/seoUtils.js +118 -0
- package/dist/components/provider/useEditModeOverlay.d.ts +7 -0
- package/dist/components/provider/useEditModeOverlay.js +134 -0
- package/dist/components/provider/useHistoryNavigationPatch.d.ts +3 -0
- package/dist/components/provider/useHistoryNavigationPatch.js +47 -0
- package/dist/components/provider/useProviderCache.d.ts +12 -0
- package/dist/components/provider/useProviderCache.js +82 -0
- package/dist/hooks/provider/useBundleLoading.d.ts +2 -1
- package/dist/hooks/provider/useBundleLoading.js +15 -3
- package/dist/utils/api.d.ts +3 -78
- package/dist/utils/api.js +1 -53
- package/dist/utils/apiTypes.d.ts +78 -0
- package/dist/utils/apiTypes.js +1 -0
- package/dist/utils/apiUtils.d.ts +4 -0
- package/dist/utils/apiUtils.js +54 -0
- package/dist/utils/languageFlags.d.ts +7 -0
- package/dist/utils/languageFlags.js +90 -0
- package/dist/utils/markerEngine.d.ts +8 -66
- package/dist/utils/markerEngine.js +19 -703
- package/dist/utils/markerEngineApply.d.ts +3 -0
- package/dist/utils/markerEngineApply.js +136 -0
- package/dist/utils/markerEngineConstants.d.ts +10 -0
- package/dist/utils/markerEngineConstants.js +12 -0
- package/dist/utils/markerEngineCritical.d.ts +2 -0
- package/dist/utils/markerEngineCritical.js +98 -0
- package/dist/utils/markerEngineDomUtils.d.ts +8 -0
- package/dist/utils/markerEngineDomUtils.js +74 -0
- package/dist/utils/markerEngineFilters.d.ts +2 -0
- package/dist/utils/markerEngineFilters.js +26 -0
- package/dist/utils/markerEngineMisses.d.ts +5 -0
- package/dist/utils/markerEngineMisses.js +81 -0
- package/dist/utils/markerEngineOriginals.d.ts +5 -0
- package/dist/utils/markerEngineOriginals.js +29 -0
- package/dist/utils/markerEngineScan.d.ts +5 -0
- package/dist/utils/markerEngineScan.js +162 -0
- package/dist/utils/markerEngineState.d.ts +4 -0
- package/dist/utils/markerEngineState.js +14 -0
- package/dist/utils/markerEngineStats.d.ts +3 -0
- package/dist/utils/markerEngineStats.js +28 -0
- package/dist/utils/markerEngineTranslations.d.ts +3 -0
- package/dist/utils/markerEngineTranslations.js +49 -0
- package/dist/utils/markerEngineTypes.d.ts +62 -0
- package/dist/utils/markerEngineTypes.js +1 -0
- package/dist/utils/markerEngineViewport.d.ts +2 -0
- package/dist/utils/markerEngineViewport.js +27 -0
- package/dist/utils/mergeEntitlements.d.ts +2 -0
- package/dist/utils/mergeEntitlements.js +7 -0
- package/dist/version.d.ts +1 -1
- package/dist/version.js +1 -1
- package/package.json +1 -1
- package/dist/utils/translator.d.ts +0 -80
- 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
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
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
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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 @@
|
|
|
1
|
+
export {};
|
|
@@ -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,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 {};
|