@lovalingo/lovalingo 0.5.0 → 0.5.1
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/README.md
CHANGED
|
@@ -205,6 +205,7 @@ Lovalingo includes a floating language switcher.
|
|
|
205
205
|
publicAnonKey="aix_xxx"
|
|
206
206
|
defaultLocale="en"
|
|
207
207
|
locales={["en", "de", "fr"]}
|
|
208
|
+
overlayBgColor="#ffffff" // optional: background during the no-flash prehide phase (default: #ffffff)
|
|
208
209
|
switcherPosition="bottom-right"
|
|
209
210
|
switcherOffsetY={20}
|
|
210
211
|
switcherTheme="light" // "dark" | "light" (default: "dark")
|
|
@@ -213,6 +214,16 @@ Lovalingo includes a floating language switcher.
|
|
|
213
214
|
</LovalingoProvider>
|
|
214
215
|
```
|
|
215
216
|
|
|
217
|
+
## Overlay Background (No-Flash UX)
|
|
218
|
+
|
|
219
|
+
Lovalingo may briefly hide the page while it loads translations to avoid a visible EN→DE flash.
|
|
220
|
+
|
|
221
|
+
Set `overlayBgColor` to match your app background so the fallback looks seamless:
|
|
222
|
+
|
|
223
|
+
```tsx
|
|
224
|
+
<LovalingoProvider overlayBgColor="#0b0b0b" ... />
|
|
225
|
+
```
|
|
226
|
+
|
|
216
227
|
## SEO (Canonical + hreflang)
|
|
217
228
|
|
|
218
229
|
Lovalingo can keep `<head>` SEO signals in sync with the active locale:
|
|
@@ -11,8 +11,10 @@ const LOCALE_STORAGE_KEY = 'Lovalingo_locale';
|
|
|
11
11
|
const LOADING_BG_STORAGE_PREFIX = "Lovalingo_loading_bg_color";
|
|
12
12
|
const BRANDING_STORAGE_PREFIX = "Lovalingo_branding_enabled";
|
|
13
13
|
const CRITICAL_CACHE_PREFIX = "Lovalingo_critical_v0_3";
|
|
14
|
+
// Why: avoid long blank screens on blocked/untranslated routes by always revealing the original page quickly.
|
|
15
|
+
const PREHIDE_FAILSAFE_MS = 1700;
|
|
14
16
|
export const LovalingoProvider = ({ children, apiKey: apiKeyProp, publicAnonKey, defaultLocale, locales, apiBase = 'https://cdn.lovalingo.com', routing = 'path', // Default to path mode (SEO-friendly, recommended)
|
|
15
|
-
autoPrefixLinks = true, autoApplyRules = true, switcherPosition = 'bottom-right', switcherOffsetY = 20, switcherTheme = 'dark', editMode: initialEditMode = false, editKey = 'KeyE', pathNormalization = { enabled: true }, // Enable by default
|
|
17
|
+
autoPrefixLinks = true, overlayBgColor, autoApplyRules = true, switcherPosition = 'bottom-right', switcherOffsetY = 20, switcherTheme = 'dark', editMode: initialEditMode = false, editKey = 'KeyE', pathNormalization = { enabled: true }, // Enable by default
|
|
16
18
|
mode = 'dom', // Default to legacy DOM mode for backward compatibility
|
|
17
19
|
sitemap = true, // Default: true - Auto-inject sitemap link tag
|
|
18
20
|
seo = true, // Default: true - Can be disabled per project entitlements
|
|
@@ -75,12 +77,15 @@ navigateRef, // For path mode routing
|
|
|
75
77
|
}, [apiBase, enhancedPathConfig, resolvedApiKey]);
|
|
76
78
|
const [entitlements, setEntitlements] = useState(() => apiRef.current.getEntitlements());
|
|
77
79
|
const lastPageviewRef = useRef("");
|
|
80
|
+
const lastNormalizedPathRef = useRef("");
|
|
78
81
|
const historyPatchedRef = useRef(false);
|
|
79
82
|
const originalHistoryRef = useRef(null);
|
|
80
83
|
const onNavigateRef = useRef(() => undefined);
|
|
81
84
|
const retryTimeoutRef = useRef(null);
|
|
85
|
+
const loadingFailsafeTimeoutRef = useRef(null);
|
|
82
86
|
const isNavigatingRef = useRef(false);
|
|
83
87
|
const isInternalNavigationRef = useRef(false);
|
|
88
|
+
const inFlightLoadKeyRef = useRef(null);
|
|
84
89
|
const translationCacheRef = useRef(new Map());
|
|
85
90
|
const exclusionsCacheRef = useRef(null);
|
|
86
91
|
const domRulesCacheRef = useRef(new Map());
|
|
@@ -109,6 +114,9 @@ navigateRef, // For path mode routing
|
|
|
109
114
|
prevBodyBg: "",
|
|
110
115
|
});
|
|
111
116
|
const getCachedLoadingBgColor = useCallback(() => {
|
|
117
|
+
const configured = (overlayBgColor || "").toString().trim();
|
|
118
|
+
if (/^#[0-9a-fA-F]{6}$/.test(configured))
|
|
119
|
+
return configured;
|
|
112
120
|
try {
|
|
113
121
|
const cached = localStorage.getItem(loadingBgStorageKey) || "";
|
|
114
122
|
if (/^#[0-9a-fA-F]{6}$/.test(cached.trim()))
|
|
@@ -118,7 +126,7 @@ navigateRef, // For path mode routing
|
|
|
118
126
|
// ignore
|
|
119
127
|
}
|
|
120
128
|
return "#ffffff";
|
|
121
|
-
}, [loadingBgStorageKey]);
|
|
129
|
+
}, [loadingBgStorageKey, overlayBgColor]);
|
|
122
130
|
const setCachedLoadingBgColor = useCallback((color) => {
|
|
123
131
|
const next = (color || "").toString().trim();
|
|
124
132
|
if (!/^#[0-9a-fA-F]{6}$/.test(next))
|
|
@@ -130,6 +138,13 @@ navigateRef, // For path mode routing
|
|
|
130
138
|
// ignore
|
|
131
139
|
}
|
|
132
140
|
}, [loadingBgStorageKey]);
|
|
141
|
+
useEffect(() => {
|
|
142
|
+
// Why: make `overlayBgColor` the source of truth while keeping the existing cache key for backwards compatibility.
|
|
143
|
+
const configured = (overlayBgColor || "").toString().trim();
|
|
144
|
+
if (!/^#[0-9a-fA-F]{6}$/.test(configured))
|
|
145
|
+
return;
|
|
146
|
+
setCachedLoadingBgColor(configured);
|
|
147
|
+
}, [overlayBgColor, setCachedLoadingBgColor]);
|
|
133
148
|
const setCachedBrandingEnabled = useCallback((enabled) => {
|
|
134
149
|
try {
|
|
135
150
|
localStorage.setItem(brandingStorageKey, enabled === false ? "0" : "1");
|
|
@@ -176,12 +191,10 @@ navigateRef, // For path mode routing
|
|
|
176
191
|
body.style.backgroundColor = bgColor;
|
|
177
192
|
}
|
|
178
193
|
if (state.timeoutId != null) {
|
|
179
|
-
|
|
194
|
+
return;
|
|
180
195
|
}
|
|
181
|
-
// Why: avoid
|
|
182
|
-
state.timeoutId = window.setTimeout(() =>
|
|
183
|
-
disablePrehide();
|
|
184
|
-
}, 2500);
|
|
196
|
+
// Why: avoid a "perma-hide" when navigation events repeatedly re-trigger prehide and keep extending the timeout.
|
|
197
|
+
state.timeoutId = window.setTimeout(() => disablePrehide(), PREHIDE_FAILSAFE_MS);
|
|
185
198
|
}, []);
|
|
186
199
|
const disablePrehide = useCallback(() => {
|
|
187
200
|
if (typeof document === "undefined")
|
|
@@ -266,6 +279,7 @@ navigateRef, // For path mode routing
|
|
|
266
279
|
apiBase,
|
|
267
280
|
routing,
|
|
268
281
|
autoPrefixLinks,
|
|
282
|
+
overlayBgColor,
|
|
269
283
|
switcherPosition,
|
|
270
284
|
switcherOffsetY,
|
|
271
285
|
switcherTheme,
|
|
@@ -501,6 +515,10 @@ navigateRef, // For path mode routing
|
|
|
501
515
|
clearTimeout(retryTimeoutRef.current);
|
|
502
516
|
retryTimeoutRef.current = null;
|
|
503
517
|
}
|
|
518
|
+
if (loadingFailsafeTimeoutRef.current != null) {
|
|
519
|
+
window.clearTimeout(loadingFailsafeTimeoutRef.current);
|
|
520
|
+
loadingFailsafeTimeoutRef.current = null;
|
|
521
|
+
}
|
|
504
522
|
// If switching to default locale, clear translations and translate with empty map
|
|
505
523
|
// This will show original text using stored data-Lovalingo-original-html
|
|
506
524
|
if (targetLocale === defaultLocale) {
|
|
@@ -513,6 +531,10 @@ navigateRef, // For path mode routing
|
|
|
513
531
|
const currentPath = window.location.pathname + window.location.search;
|
|
514
532
|
const normalizedPath = processPath(window.location.pathname, enhancedPathConfig);
|
|
515
533
|
const cacheKey = `${targetLocale}:${normalizedPath}`;
|
|
534
|
+
if (inFlightLoadKeyRef.current === cacheKey) {
|
|
535
|
+
return;
|
|
536
|
+
}
|
|
537
|
+
inFlightLoadKeyRef.current = cacheKey;
|
|
516
538
|
// Check if we have cached translations for this locale + path
|
|
517
539
|
const cachedEntry = translationCacheRef.current.get(cacheKey);
|
|
518
540
|
const cachedExclusions = exclusionsCacheRef.current;
|
|
@@ -553,12 +575,20 @@ navigateRef, // For path mode routing
|
|
|
553
575
|
}, 500);
|
|
554
576
|
disablePrehide();
|
|
555
577
|
isNavigatingRef.current = false;
|
|
578
|
+
if (inFlightLoadKeyRef.current === cacheKey) {
|
|
579
|
+
inFlightLoadKeyRef.current = null;
|
|
580
|
+
}
|
|
556
581
|
return;
|
|
557
582
|
}
|
|
558
583
|
// CACHE MISS - Fetch from API
|
|
559
584
|
logDebug(`[Lovalingo] Fetching translations for ${targetLocale} on ${normalizedPath}`);
|
|
560
585
|
setIsLoading(true);
|
|
561
586
|
enablePrehide(getCachedLoadingBgColor());
|
|
587
|
+
// Why: never keep the app hidden/blocked for longer than the UX budget; show the original content if translations aren't ready fast.
|
|
588
|
+
loadingFailsafeTimeoutRef.current = window.setTimeout(() => {
|
|
589
|
+
disablePrehide();
|
|
590
|
+
setIsLoading(false);
|
|
591
|
+
}, PREHIDE_FAILSAFE_MS);
|
|
562
592
|
try {
|
|
563
593
|
if (previousLocale && previousLocale !== defaultLocale) {
|
|
564
594
|
logDebug(`[Lovalingo] Switching from ${previousLocale} to ${targetLocale}`);
|
|
@@ -673,7 +703,14 @@ navigateRef, // For path mode routing
|
|
|
673
703
|
}
|
|
674
704
|
finally {
|
|
675
705
|
setIsLoading(false);
|
|
706
|
+
if (loadingFailsafeTimeoutRef.current != null) {
|
|
707
|
+
window.clearTimeout(loadingFailsafeTimeoutRef.current);
|
|
708
|
+
loadingFailsafeTimeoutRef.current = null;
|
|
709
|
+
}
|
|
676
710
|
isNavigatingRef.current = false;
|
|
711
|
+
if (inFlightLoadKeyRef.current === cacheKey) {
|
|
712
|
+
inFlightLoadKeyRef.current = null;
|
|
713
|
+
}
|
|
677
714
|
}
|
|
678
715
|
}, [
|
|
679
716
|
applySeoBundle,
|
|
@@ -694,15 +731,25 @@ navigateRef, // For path mode routing
|
|
|
694
731
|
onNavigateRef.current = () => {
|
|
695
732
|
trackPageviewOnce(window.location.pathname + window.location.search);
|
|
696
733
|
const nextLocale = detectLocale();
|
|
734
|
+
const normalizedPath = processPath(window.location.pathname, enhancedPathConfig);
|
|
735
|
+
const normalizedPathChanged = normalizedPath !== lastNormalizedPathRef.current;
|
|
736
|
+
lastNormalizedPathRef.current = normalizedPath;
|
|
737
|
+
// Why: bundles are path-scoped, so SPA navigations within the same locale must trigger a reload for the new route.
|
|
738
|
+
if (normalizedPathChanged && nextLocale !== defaultLocale && !isInternalNavigationRef.current) {
|
|
739
|
+
void loadData(nextLocale, locale);
|
|
740
|
+
return;
|
|
741
|
+
}
|
|
697
742
|
if (nextLocale !== locale) {
|
|
698
743
|
setLocaleState(nextLocale);
|
|
699
|
-
|
|
744
|
+
if (!isInternalNavigationRef.current) {
|
|
745
|
+
void loadData(nextLocale, locale);
|
|
746
|
+
}
|
|
700
747
|
}
|
|
701
748
|
else if (mode === "dom" && nextLocale !== defaultLocale) {
|
|
702
749
|
applyActiveTranslations(document.body);
|
|
703
750
|
}
|
|
704
751
|
};
|
|
705
|
-
}, [defaultLocale, detectLocale, loadData, locale, mode, trackPageviewOnce]);
|
|
752
|
+
}, [defaultLocale, detectLocale, enhancedPathConfig, loadData, locale, mode, trackPageviewOnce]);
|
|
706
753
|
// SPA router hook-in: patch History API once (prevents stacked wrappers → request storms).
|
|
707
754
|
useEffect(() => {
|
|
708
755
|
if (typeof window === "undefined")
|
|
@@ -801,6 +848,7 @@ navigateRef, // For path mode routing
|
|
|
801
848
|
// Initialize
|
|
802
849
|
useEffect(() => {
|
|
803
850
|
const initialLocale = detectLocale();
|
|
851
|
+
lastNormalizedPathRef.current = processPath(window.location.pathname, enhancedPathConfig);
|
|
804
852
|
// Track initial page (fallback discovery for pages not present in the routes feed).
|
|
805
853
|
trackPageviewOnce(window.location.pathname + window.location.search);
|
|
806
854
|
// Always prefetch artifacts for the initial locale (pipeline-produced translations + rules).
|
|
@@ -820,7 +868,7 @@ navigateRef, // For path mode routing
|
|
|
820
868
|
clearTimeout(retryTimeoutRef.current);
|
|
821
869
|
}
|
|
822
870
|
};
|
|
823
|
-
}, [detectLocale, loadData, editKey, trackPageviewOnce]);
|
|
871
|
+
}, [detectLocale, enhancedPathConfig, loadData, editKey, trackPageviewOnce]);
|
|
824
872
|
// Auto-inject sitemap link tag
|
|
825
873
|
useEffect(() => {
|
|
826
874
|
if (sitemap && resolvedApiKey && isSeoActive()) {
|
|
@@ -2,12 +2,6 @@ import React from 'react';
|
|
|
2
2
|
import { Link } from 'react-router-dom';
|
|
3
3
|
import { useLang } from '../hooks/useLang';
|
|
4
4
|
const NON_LOCALIZED_APP_PATHS = new Set([
|
|
5
|
-
'auth',
|
|
6
|
-
'login',
|
|
7
|
-
'signup',
|
|
8
|
-
'sign-in',
|
|
9
|
-
'sign-up',
|
|
10
|
-
'register',
|
|
11
5
|
'robots.txt',
|
|
12
6
|
'sitemap.xml',
|
|
13
7
|
]);
|
|
@@ -2,12 +2,6 @@ import React, { useEffect } from 'react';
|
|
|
2
2
|
import { BrowserRouter, Routes, Route, Navigate, Outlet, useLocation, useNavigate } from 'react-router-dom';
|
|
3
3
|
import { LangContext } from '../context/LangContext';
|
|
4
4
|
const NON_LOCALIZED_APP_PATHS = new Set([
|
|
5
|
-
'/auth',
|
|
6
|
-
'/login',
|
|
7
|
-
'/signup',
|
|
8
|
-
'/sign-in',
|
|
9
|
-
'/sign-up',
|
|
10
|
-
'/register',
|
|
11
5
|
'/robots.txt',
|
|
12
6
|
'/sitemap.xml',
|
|
13
7
|
]);
|
|
@@ -35,7 +29,7 @@ function NavigateExporter({ navigateRef }) {
|
|
|
35
29
|
*/
|
|
36
30
|
function LangGuard({ defaultLang, lang }) {
|
|
37
31
|
const location = useLocation();
|
|
38
|
-
// If the URL is language-prefixed but the underlying route is non-localized (
|
|
32
|
+
// If the URL is language-prefixed but the underlying route is non-localized (e.g. robots/sitemap),
|
|
39
33
|
// redirect to the canonical non-localized path.
|
|
40
34
|
const prefix = `/${lang}`;
|
|
41
35
|
const restPath = location.pathname.startsWith(prefix) ? location.pathname.slice(prefix.length) || '/' : location.pathname;
|
|
@@ -3,12 +3,6 @@ import { useCallback } from 'react';
|
|
|
3
3
|
import { useLang } from './useLang';
|
|
4
4
|
//Globally excluded paths.
|
|
5
5
|
const NON_LOCALIZED_APP_PATHS = new Set([
|
|
6
|
-
'auth',
|
|
7
|
-
'login',
|
|
8
|
-
'signup',
|
|
9
|
-
'sign-in',
|
|
10
|
-
'sign-up',
|
|
11
|
-
'register',
|
|
12
6
|
'robots.txt',
|
|
13
7
|
'sitemap.xml',
|
|
14
8
|
]);
|
package/dist/types.d.ts
CHANGED
|
@@ -10,6 +10,7 @@ export interface LovalingoConfig {
|
|
|
10
10
|
apiBase?: string;
|
|
11
11
|
routing?: 'query' | 'path';
|
|
12
12
|
autoPrefixLinks?: boolean;
|
|
13
|
+
overlayBgColor?: string;
|
|
13
14
|
switcherPosition?: 'bottom-right' | 'bottom-left' | 'top-right' | 'top-left';
|
|
14
15
|
switcherOffsetY?: number;
|
|
15
16
|
switcherTheme?: 'dark' | 'light';
|
package/package.json
CHANGED