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