@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
- window.clearTimeout(state.timeoutId);
194
+ return;
180
195
  }
181
- // Why: avoid leaving the page hidden forever if the network is blocked or the project has no translations yet.
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
- void loadData(nextLocale, locale);
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 (auth/login/signup),
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
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@lovalingo/lovalingo",
3
- "version": "0.5.0",
3
+ "version": "0.5.1",
4
4
  "description": "React translation runtime with i18n routing, deterministic bundles + DOM rules, and zero-flash rendering.",
5
5
  "main": "dist/index.js",
6
6
  "types": "dist/index.d.ts",