@lovalingo/lovalingo 0.5.3 → 0.5.5
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 +9 -9
- package/README.md +7 -4
- package/dist/components/AixsterProvider.js +80 -40
- package/dist/components/LangLink.js +3 -1
- package/dist/components/LangRouter.js +39 -20
- package/dist/components/LanguageSwitcher.js +6 -4
- package/dist/context/LangRoutingContext.d.ts +2 -0
- package/dist/context/LangRoutingContext.js +2 -0
- package/dist/hooks/useLangNavigate.js +3 -1
- package/dist/index.d.ts +2 -2
- package/dist/index.js +2 -2
- package/dist/utils/api.d.ts +4 -0
- package/dist/utils/logger.js +17 -0
- package/dist/utils/nonLocalizedPaths.d.ts +3 -0
- package/dist/utils/nonLocalizedPaths.js +59 -1
- package/dist/version.d.ts +1 -1
- package/dist/version.js +1 -1
- package/package.json +2 -2
package/LICENSE
CHANGED
|
@@ -1,9 +1,9 @@
|
|
|
1
1
|
LOVALINGO COMMERCIAL LICENSE
|
|
2
2
|
|
|
3
|
-
Copyright (c)
|
|
3
|
+
Copyright (c) 2026 Lovalingo Swiss. All rights reserved.
|
|
4
4
|
|
|
5
5
|
NOTICE: This software and associated documentation files (the "Software") are the
|
|
6
|
-
proprietary and confidential information of
|
|
6
|
+
proprietary and confidential information of Lovalingo Swiss.
|
|
7
7
|
|
|
8
8
|
The Software is licensed, not sold. This license grants you the following rights:
|
|
9
9
|
|
|
@@ -74,17 +74,17 @@ FOR END CLIENTS:
|
|
|
74
74
|
4. API KEY REQUIREMENT
|
|
75
75
|
====================================================================================
|
|
76
76
|
|
|
77
|
-
Production use of the Software requires a valid API key obtained from
|
|
77
|
+
Production use of the Software requires a valid API key obtained from Lovalingo Swiss.
|
|
78
78
|
Usage is subject to the terms of service associated with your API key subscription.
|
|
79
79
|
|
|
80
80
|
====================================================================================
|
|
81
81
|
5. INTELLECTUAL PROPERTY
|
|
82
82
|
====================================================================================
|
|
83
83
|
|
|
84
|
-
This Software is the exclusive intellectual property of
|
|
84
|
+
This Software is the exclusive intellectual property of Lovalingo Swiss.
|
|
85
85
|
All rights, title, and interest in and to the Software, including all
|
|
86
86
|
intellectual property rights, patents, trademarks, copyrights, and trade secrets,
|
|
87
|
-
remain the sole property of
|
|
87
|
+
remain the sole property of Lovalingo Swiss.
|
|
88
88
|
|
|
89
89
|
====================================================================================
|
|
90
90
|
6. NOT OPEN SOURCE
|
|
@@ -133,16 +133,16 @@ SUCH DAMAGE.
|
|
|
133
133
|
====================================================================================
|
|
134
134
|
|
|
135
135
|
This license shall be governed by and construed in accordance with the laws of
|
|
136
|
-
the jurisdiction in which
|
|
136
|
+
the jurisdiction in which Lovalingo Swiss operates, without regard to its conflict
|
|
137
137
|
of law provisions.
|
|
138
138
|
|
|
139
139
|
====================================================================================
|
|
140
140
|
|
|
141
141
|
For licensing inquiries, enterprise licensing, or questions about permitted use,
|
|
142
|
-
please contact
|
|
142
|
+
please contact Lovalingo Swiss.
|
|
143
143
|
|
|
144
144
|
MERTENS ADVIES
|
|
145
145
|
Lovalingo Translation Platform
|
|
146
|
-
©
|
|
146
|
+
© 2026 All Rights Reserved
|
|
147
147
|
|
|
148
|
-
Website: [Contact
|
|
148
|
+
Website: [Contact Lovalingo Swiss for licensing information]
|
package/README.md
CHANGED
|
@@ -21,7 +21,10 @@ The runtime now marks translatable text nodes deterministically and exposes mark
|
|
|
21
21
|
- Coverage is enforced server-side (jobs fail if marker coverage is below the threshold).
|
|
22
22
|
- This is a breaking change: older runtimes will be rejected by the pipeline.
|
|
23
23
|
|
|
24
|
-
Debug (runtime logs):
|
|
24
|
+
Debug (runtime logs):
|
|
25
|
+
- append `?lovalingoDebug=1` to the URL (works across reloads)
|
|
26
|
+
- or run `localStorage.setItem("Lovalingo_debug","1")` and reload
|
|
27
|
+
- or set `window.__lovalingoDebug = true` (same-tab only; not persistent)
|
|
25
28
|
|
|
26
29
|
## Installation
|
|
27
30
|
|
|
@@ -137,7 +140,7 @@ You still need to serve `/sitemap.xml` on your own domain (recommended: reverse-
|
|
|
137
140
|
|
|
138
141
|
## License
|
|
139
142
|
|
|
140
|
-
Commercial license (not open source). See `
|
|
143
|
+
Commercial license (not open source). See `LICENSE`.
|
|
141
144
|
|
|
142
145
|
Manual translation control:
|
|
143
146
|
|
|
@@ -349,7 +352,7 @@ export async function GET() {
|
|
|
349
352
|
|
|
350
353
|
**COMMERCIAL LICENSE - NOT OPEN SOURCE**
|
|
351
354
|
|
|
352
|
-
Copyright (c)
|
|
355
|
+
Copyright (c) 2026 Lovalingo Swiss. All rights reserved.
|
|
353
356
|
|
|
354
357
|
### For Agencies & Developers
|
|
355
358
|
|
|
@@ -374,6 +377,6 @@ applications containing Lovalingo, but may not modify, redistribute, or extract
|
|
|
374
377
|
|
|
375
378
|
This software is licensed under the **Lovalingo Commercial License**.
|
|
376
379
|
This is NOT open source software. All intellectual property rights remain the
|
|
377
|
-
exclusive property of
|
|
380
|
+
exclusive property of Lovalingo Swiss.
|
|
378
381
|
|
|
379
382
|
See LICENSE file for complete terms and conditions.
|
|
@@ -114,6 +114,7 @@ navigateRef, // For path mode routing
|
|
|
114
114
|
const prehideStateRef = useRef({
|
|
115
115
|
active: false,
|
|
116
116
|
timeoutId: null,
|
|
117
|
+
startedAtMs: null,
|
|
117
118
|
prevHtmlVisibility: "",
|
|
118
119
|
prevBodyVisibility: "",
|
|
119
120
|
prevHtmlBg: "",
|
|
@@ -198,6 +199,27 @@ navigateRef, // For path mode routing
|
|
|
198
199
|
pageviewFingerprintTimeoutRef.current = window.setTimeout(trySendFingerprint, 800);
|
|
199
200
|
pageviewFingerprintRetryTimeoutRef.current = window.setTimeout(trySendFingerprint, 2000);
|
|
200
201
|
}, []);
|
|
202
|
+
const forceDisablePrehide = useCallback(() => {
|
|
203
|
+
if (typeof document === "undefined")
|
|
204
|
+
return;
|
|
205
|
+
const html = document.documentElement;
|
|
206
|
+
const body = document.body;
|
|
207
|
+
if (!html || !body)
|
|
208
|
+
return;
|
|
209
|
+
const state = prehideStateRef.current;
|
|
210
|
+
if (state.timeoutId != null) {
|
|
211
|
+
window.clearTimeout(state.timeoutId);
|
|
212
|
+
state.timeoutId = null;
|
|
213
|
+
}
|
|
214
|
+
if (!state.active)
|
|
215
|
+
return;
|
|
216
|
+
state.active = false;
|
|
217
|
+
state.startedAtMs = null;
|
|
218
|
+
html.style.visibility = state.prevHtmlVisibility;
|
|
219
|
+
body.style.visibility = state.prevBodyVisibility;
|
|
220
|
+
html.style.backgroundColor = state.prevHtmlBg;
|
|
221
|
+
body.style.backgroundColor = state.prevBodyBg;
|
|
222
|
+
}, []);
|
|
201
223
|
const enablePrehide = useCallback((bgColor) => {
|
|
202
224
|
if (typeof document === "undefined")
|
|
203
225
|
return;
|
|
@@ -206,8 +228,13 @@ navigateRef, // For path mode routing
|
|
|
206
228
|
if (!html || !body)
|
|
207
229
|
return;
|
|
208
230
|
const state = prehideStateRef.current;
|
|
231
|
+
// Why: avoid "perma-hidden" pages when repeated navigation/errors keep prehide active; always hard-stop after a few seconds.
|
|
232
|
+
if (state.active && state.startedAtMs != null && Date.now() - state.startedAtMs > PREHIDE_FAILSAFE_MS * 3) {
|
|
233
|
+
forceDisablePrehide();
|
|
234
|
+
}
|
|
209
235
|
if (!state.active) {
|
|
210
236
|
state.active = true;
|
|
237
|
+
state.startedAtMs = Date.now();
|
|
211
238
|
state.prevHtmlVisibility = html.style.visibility || "";
|
|
212
239
|
state.prevBodyVisibility = body.style.visibility || "";
|
|
213
240
|
state.prevHtmlBg = html.style.backgroundColor || "";
|
|
@@ -223,28 +250,9 @@ navigateRef, // For path mode routing
|
|
|
223
250
|
return;
|
|
224
251
|
}
|
|
225
252
|
// Why: avoid a "perma-hide" when navigation events repeatedly re-trigger prehide and keep extending the timeout.
|
|
226
|
-
state.timeoutId = window.setTimeout(() =>
|
|
227
|
-
}, []);
|
|
228
|
-
const disablePrehide =
|
|
229
|
-
if (typeof document === "undefined")
|
|
230
|
-
return;
|
|
231
|
-
const html = document.documentElement;
|
|
232
|
-
const body = document.body;
|
|
233
|
-
if (!html || !body)
|
|
234
|
-
return;
|
|
235
|
-
const state = prehideStateRef.current;
|
|
236
|
-
if (state.timeoutId != null) {
|
|
237
|
-
window.clearTimeout(state.timeoutId);
|
|
238
|
-
state.timeoutId = null;
|
|
239
|
-
}
|
|
240
|
-
if (!state.active)
|
|
241
|
-
return;
|
|
242
|
-
state.active = false;
|
|
243
|
-
html.style.visibility = state.prevHtmlVisibility;
|
|
244
|
-
body.style.visibility = state.prevBodyVisibility;
|
|
245
|
-
html.style.backgroundColor = state.prevHtmlBg;
|
|
246
|
-
body.style.backgroundColor = state.prevBodyBg;
|
|
247
|
-
}, []);
|
|
253
|
+
state.timeoutId = window.setTimeout(() => forceDisablePrehide(), PREHIDE_FAILSAFE_MS);
|
|
254
|
+
}, [forceDisablePrehide]);
|
|
255
|
+
const disablePrehide = forceDisablePrehide;
|
|
248
256
|
const buildCriticalCacheKey = useCallback((targetLocale, normalizedPath) => {
|
|
249
257
|
const key = `${resolvedApiKey || "anonymous"}:${targetLocale}:${normalizedPath || "/"}`;
|
|
250
258
|
return `${CRITICAL_CACHE_PREFIX}:${hashContent(key)}`;
|
|
@@ -633,6 +641,7 @@ navigateRef, // For path mode routing
|
|
|
633
641
|
if (previousLocale && previousLocale !== defaultLocale) {
|
|
634
642
|
logDebug(`[Lovalingo] Switching from ${previousLocale} to ${targetLocale}`);
|
|
635
643
|
}
|
|
644
|
+
let revealedViaCachedCritical = false;
|
|
636
645
|
const cachedCritical = readCriticalCache(targetLocale, normalizedPath);
|
|
637
646
|
if (cachedCritical?.loading_bg_color) {
|
|
638
647
|
setCachedLoadingBgColor(cachedCritical.loading_bg_color);
|
|
@@ -648,6 +657,7 @@ navigateRef, // For path mode routing
|
|
|
648
657
|
applyActiveTranslations(document.body);
|
|
649
658
|
}
|
|
650
659
|
disablePrehide();
|
|
660
|
+
revealedViaCachedCritical = true;
|
|
651
661
|
}
|
|
652
662
|
const bootstrap = await apiRef.current.fetchBootstrap(targetLocale, currentPath);
|
|
653
663
|
const nextEntitlements = bootstrap?.entitlements || apiRef.current.getEntitlements();
|
|
@@ -686,6 +696,7 @@ navigateRef, // For path mode routing
|
|
|
686
696
|
const criticalMap = bootstrap?.critical?.map && typeof bootstrap.critical.map === "object" && !Array.isArray(bootstrap.critical.map)
|
|
687
697
|
? bootstrap.critical.map
|
|
688
698
|
: {};
|
|
699
|
+
const hasBootstrapCritical = Object.keys(criticalMap).length > 0;
|
|
689
700
|
if (Object.keys(criticalMap).length > 0) {
|
|
690
701
|
setActiveTranslations(toTranslations(criticalMap, targetLocale));
|
|
691
702
|
if (mode === "dom") {
|
|
@@ -706,6 +717,37 @@ navigateRef, // For path mode routing
|
|
|
706
717
|
exclusions,
|
|
707
718
|
loading_bg_color: bootstrap?.loading_bg_color && /^#[0-9a-fA-F]{6}$/.test(bootstrap.loading_bg_color) ? bootstrap.loading_bg_color : null,
|
|
708
719
|
});
|
|
720
|
+
const shouldWaitForBundle = !revealedViaCachedCritical && !hasBootstrapCritical;
|
|
721
|
+
if (shouldWaitForBundle) {
|
|
722
|
+
// Why: if there's no critical slice for first paint, wait for the bundle (within the prehide failsafe) to avoid a visible flash.
|
|
723
|
+
const bundle = await apiRef.current.fetchBundle(targetLocale, currentPath);
|
|
724
|
+
if (bundle?.map && typeof bundle.map === "object") {
|
|
725
|
+
const translations = toTranslations(bundle.map, targetLocale);
|
|
726
|
+
if (translations.length > 0) {
|
|
727
|
+
translationCacheRef.current.set(cacheKey, { translations });
|
|
728
|
+
setActiveTranslations(translations);
|
|
729
|
+
if (mode === "dom") {
|
|
730
|
+
applyActiveTranslations(document.body);
|
|
731
|
+
}
|
|
732
|
+
}
|
|
733
|
+
}
|
|
734
|
+
}
|
|
735
|
+
else {
|
|
736
|
+
// Lazy-load the full page bundle after first paint.
|
|
737
|
+
void (async () => {
|
|
738
|
+
const bundle = await apiRef.current.fetchBundle(targetLocale, currentPath);
|
|
739
|
+
if (!bundle || !bundle.map)
|
|
740
|
+
return;
|
|
741
|
+
const translations = toTranslations(bundle.map, targetLocale);
|
|
742
|
+
if (translations.length === 0)
|
|
743
|
+
return;
|
|
744
|
+
translationCacheRef.current.set(cacheKey, { translations });
|
|
745
|
+
setActiveTranslations(translations);
|
|
746
|
+
if (mode === "dom") {
|
|
747
|
+
applyActiveTranslations(document.body);
|
|
748
|
+
}
|
|
749
|
+
})();
|
|
750
|
+
}
|
|
709
751
|
disablePrehide();
|
|
710
752
|
// Delayed retry scan to catch late-rendering content
|
|
711
753
|
retryTimeoutRef.current = setTimeout(() => {
|
|
@@ -722,20 +764,6 @@ navigateRef, // For path mode routing
|
|
|
722
764
|
applyDomRules(rules);
|
|
723
765
|
}
|
|
724
766
|
}, 500);
|
|
725
|
-
// Lazy-load the full page bundle after first paint.
|
|
726
|
-
void (async () => {
|
|
727
|
-
const bundle = await apiRef.current.fetchBundle(targetLocale, currentPath);
|
|
728
|
-
if (!bundle || !bundle.map)
|
|
729
|
-
return;
|
|
730
|
-
const translations = toTranslations(bundle.map, targetLocale);
|
|
731
|
-
if (translations.length === 0)
|
|
732
|
-
return;
|
|
733
|
-
translationCacheRef.current.set(cacheKey, { translations });
|
|
734
|
-
setActiveTranslations(translations);
|
|
735
|
-
if (mode === "dom") {
|
|
736
|
-
applyActiveTranslations(document.body);
|
|
737
|
-
}
|
|
738
|
-
})();
|
|
739
767
|
}
|
|
740
768
|
catch (error) {
|
|
741
769
|
errorDebug('Error loading translations:', error);
|
|
@@ -754,6 +782,7 @@ navigateRef, // For path mode routing
|
|
|
754
782
|
}
|
|
755
783
|
}, [
|
|
756
784
|
applySeoBundle,
|
|
785
|
+
allLocales,
|
|
757
786
|
autoApplyRules,
|
|
758
787
|
defaultLocale,
|
|
759
788
|
disablePrehide,
|
|
@@ -763,6 +792,8 @@ navigateRef, // For path mode routing
|
|
|
763
792
|
isSeoActive,
|
|
764
793
|
mode,
|
|
765
794
|
readCriticalCache,
|
|
795
|
+
routing,
|
|
796
|
+
routingConfig.nonLocalizedPaths,
|
|
766
797
|
setCachedLoadingBgColor,
|
|
767
798
|
toTranslations,
|
|
768
799
|
writeCriticalCache,
|
|
@@ -890,16 +921,25 @@ navigateRef, // For path mode routing
|
|
|
890
921
|
})().finally(() => {
|
|
891
922
|
isInternalNavigationRef.current = false;
|
|
892
923
|
});
|
|
893
|
-
}, [allLocales, locale, routing, loadData, navigateRef, routingConfig.nonLocalizedPaths]);
|
|
924
|
+
}, [allLocales, defaultLocale, locale, routing, loadData, navigateRef, routingConfig.nonLocalizedPaths]);
|
|
894
925
|
// No string-level "misses" from the client: the pipeline (Browser Rendering) is source-of-truth for string discovery.
|
|
926
|
+
// Why: prevent init/load effects from re-running (and calling bootstrap/bundle again) when loadData changes due to state updates.
|
|
927
|
+
const loadDataRef = useRef(loadData);
|
|
928
|
+
useEffect(() => {
|
|
929
|
+
loadDataRef.current = loadData;
|
|
930
|
+
}, [loadData]);
|
|
931
|
+
const detectLocaleRef = useRef(detectLocale);
|
|
932
|
+
useEffect(() => {
|
|
933
|
+
detectLocaleRef.current = detectLocale;
|
|
934
|
+
}, [detectLocale]);
|
|
895
935
|
// Initialize
|
|
896
936
|
useEffect(() => {
|
|
897
|
-
const initialLocale =
|
|
937
|
+
const initialLocale = detectLocaleRef.current();
|
|
898
938
|
lastNormalizedPathRef.current = processPath(window.location.pathname, enhancedPathConfig);
|
|
899
939
|
// Track initial page (fallback discovery for pages not present in the routes feed).
|
|
900
940
|
trackPageviewOnce(window.location.pathname + window.location.search);
|
|
901
941
|
// Always prefetch artifacts for the initial locale (pipeline-produced translations + rules).
|
|
902
|
-
|
|
942
|
+
loadDataRef.current(initialLocale);
|
|
903
943
|
// Set up keyboard shortcut for edit mode
|
|
904
944
|
const handleKeyPress = (e) => {
|
|
905
945
|
if (e.code === editKey && (e.ctrlKey || e.metaKey)) {
|
|
@@ -915,7 +955,7 @@ navigateRef, // For path mode routing
|
|
|
915
955
|
clearTimeout(retryTimeoutRef.current);
|
|
916
956
|
}
|
|
917
957
|
};
|
|
918
|
-
}, [
|
|
958
|
+
}, [editKey, enhancedPathConfig, trackPageviewOnce]);
|
|
919
959
|
// Auto-inject sitemap link tag
|
|
920
960
|
useEffect(() => {
|
|
921
961
|
if (sitemap && resolvedApiKey && isSeoActive()) {
|
|
@@ -29,7 +29,9 @@ export function LangLink({ to, ...props }) {
|
|
|
29
29
|
? (() => {
|
|
30
30
|
const trimmed = (to || '').toString().trim();
|
|
31
31
|
const normalized = trimmed.startsWith('/') ? trimmed : `/${trimmed}`;
|
|
32
|
-
return isNonLocalizedPath(normalized, routing.nonLocalizedPaths)
|
|
32
|
+
return isNonLocalizedPath(normalized, routing.nonLocalizedPaths)
|
|
33
|
+
? normalized
|
|
34
|
+
: `/${lang}${normalized}`;
|
|
33
35
|
})()
|
|
34
36
|
: to;
|
|
35
37
|
return React.createElement(Link, { ...props, to: langTo });
|
|
@@ -1,8 +1,8 @@
|
|
|
1
1
|
import React, { useCallback, useEffect, useMemo, useState } from 'react';
|
|
2
|
-
import { BrowserRouter, Routes, Route, Outlet, useLocation, useNavigate } from 'react-router-dom';
|
|
2
|
+
import { BrowserRouter, Routes, Route, Navigate, Outlet, useLocation, useNavigate } from 'react-router-dom';
|
|
3
3
|
import { LangContext } from '../context/LangContext';
|
|
4
4
|
import { LangRoutingContext } from '../context/LangRoutingContext';
|
|
5
|
-
import { isNonLocalizedPath, parseBootstrapNonLocalizedPaths } from '../utils/nonLocalizedPaths';
|
|
5
|
+
import { isNonLocalizedPath, parseBootstrapInactivePages, parseBootstrapNonLocalizedPaths } from '../utils/nonLocalizedPaths';
|
|
6
6
|
import { logDebug } from '../utils/logger';
|
|
7
7
|
/**
|
|
8
8
|
* NavigateExporter - Internal component that exports navigate function via ref
|
|
@@ -19,20 +19,17 @@ function NavigateExporter({ navigateRef }) {
|
|
|
19
19
|
/**
|
|
20
20
|
* LangGuard - Internal component that validates language and provides it to children
|
|
21
21
|
*/
|
|
22
|
-
function LangGuard({ lang, nonLocalizedPaths, }) {
|
|
22
|
+
function LangGuard({ lang, nonLocalizedPaths, defaultLang, }) {
|
|
23
23
|
const location = useLocation();
|
|
24
|
-
const navigate = useNavigate();
|
|
25
24
|
// If the URL is language-prefixed but the underlying route is non-localized (e.g. robots/sitemap),
|
|
26
25
|
// redirect to the canonical non-localized path.
|
|
27
26
|
const prefix = `/${lang}`;
|
|
28
27
|
const restPath = location.pathname.startsWith(prefix) ? location.pathname.slice(prefix.length) || '/' : location.pathname;
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
if (!shouldDePrefix)
|
|
32
|
-
return;
|
|
28
|
+
// Why: only explicit non-localized rules may strip the locale prefix; inactive pages must never affect client routing.
|
|
29
|
+
if (isNonLocalizedPath(restPath, nonLocalizedPaths)) {
|
|
33
30
|
const nextPath = `${restPath}${location.search}${location.hash}`;
|
|
34
|
-
|
|
35
|
-
}
|
|
31
|
+
return React.createElement(Navigate, { to: nextPath, replace: true });
|
|
32
|
+
}
|
|
36
33
|
// Valid language - render children (user's routes)
|
|
37
34
|
return (React.createElement(LangContext.Provider, { value: lang },
|
|
38
35
|
React.createElement(Outlet, { context: { lang } })));
|
|
@@ -95,6 +92,7 @@ export function LangRouter({ children, defaultLang, langs, navigateRef, apiBase,
|
|
|
95
92
|
? apiBase.trim()
|
|
96
93
|
: "https://cdn.lovalingo.com");
|
|
97
94
|
const nonLocalizedStorageKey = useMemo(() => `Lovalingo_non_localized_paths:${resolvedApiKey || "anonymous"}`, [resolvedApiKey]);
|
|
95
|
+
const inactivePagesStorageKey = useMemo(() => `Lovalingo_inactive_pages:${resolvedApiKey || "anonymous"}`, [resolvedApiKey]);
|
|
98
96
|
const [nonLocalizedPaths, setNonLocalizedPaths] = useState(() => {
|
|
99
97
|
if (typeof window === "undefined")
|
|
100
98
|
return [];
|
|
@@ -111,12 +109,28 @@ export function LangRouter({ children, defaultLang, langs, navigateRef, apiBase,
|
|
|
111
109
|
return [];
|
|
112
110
|
}
|
|
113
111
|
});
|
|
112
|
+
const [inactivePages, setInactivePages] = useState(() => {
|
|
113
|
+
if (typeof window === "undefined")
|
|
114
|
+
return [];
|
|
115
|
+
if (!resolvedApiKey)
|
|
116
|
+
return [];
|
|
117
|
+
try {
|
|
118
|
+
const raw = localStorage.getItem(inactivePagesStorageKey);
|
|
119
|
+
if (!raw)
|
|
120
|
+
return [];
|
|
121
|
+
const parsed = JSON.parse(raw);
|
|
122
|
+
return parseBootstrapInactivePages(parsed);
|
|
123
|
+
}
|
|
124
|
+
catch {
|
|
125
|
+
return [];
|
|
126
|
+
}
|
|
127
|
+
});
|
|
114
128
|
const [routingStatus, setRoutingStatus] = useState(() => {
|
|
115
129
|
if (!resolvedApiKey)
|
|
116
130
|
return "unknown";
|
|
117
|
-
return nonLocalizedPaths.length > 0 ? "ready" : "loading";
|
|
131
|
+
return nonLocalizedPaths.length > 0 || inactivePages.length > 0 ? "ready" : "loading";
|
|
118
132
|
});
|
|
119
|
-
const
|
|
133
|
+
const fetchRoutingConfig = useCallback(async () => {
|
|
120
134
|
if (typeof window === "undefined")
|
|
121
135
|
return;
|
|
122
136
|
if (!resolvedApiKey)
|
|
@@ -129,7 +143,10 @@ export function LangRouter({ children, defaultLang, langs, navigateRef, apiBase,
|
|
|
129
143
|
throw new Error(`bootstrap HTTP ${resolvedResponse.status}`);
|
|
130
144
|
const data = (await resolvedResponse.json());
|
|
131
145
|
const record = (data || {});
|
|
132
|
-
return
|
|
146
|
+
return {
|
|
147
|
+
nonLocalizedPaths: parseBootstrapNonLocalizedPaths(record["non_localized_paths"]),
|
|
148
|
+
inactivePages: parseBootstrapInactivePages(record["inactive_pages"]),
|
|
149
|
+
};
|
|
133
150
|
}, [defaultLang, resolvedApiBase, resolvedApiKey]);
|
|
134
151
|
useEffect(() => {
|
|
135
152
|
let cancelled = false;
|
|
@@ -138,13 +155,15 @@ export function LangRouter({ children, defaultLang, langs, navigateRef, apiBase,
|
|
|
138
155
|
return;
|
|
139
156
|
setRoutingStatus((prev) => (prev === "ready" ? prev : "loading"));
|
|
140
157
|
try {
|
|
141
|
-
const next = await
|
|
158
|
+
const next = await fetchRoutingConfig();
|
|
142
159
|
if (cancelled || !next)
|
|
143
160
|
return;
|
|
144
|
-
setNonLocalizedPaths(next);
|
|
161
|
+
setNonLocalizedPaths(next.nonLocalizedPaths);
|
|
162
|
+
setInactivePages(next.inactivePages);
|
|
145
163
|
setRoutingStatus("ready");
|
|
146
164
|
try {
|
|
147
|
-
localStorage.setItem(nonLocalizedStorageKey, JSON.stringify(next));
|
|
165
|
+
localStorage.setItem(nonLocalizedStorageKey, JSON.stringify(next.nonLocalizedPaths));
|
|
166
|
+
localStorage.setItem(inactivePagesStorageKey, JSON.stringify(next.inactivePages));
|
|
148
167
|
}
|
|
149
168
|
catch {
|
|
150
169
|
// ignore
|
|
@@ -154,18 +173,18 @@ export function LangRouter({ children, defaultLang, langs, navigateRef, apiBase,
|
|
|
154
173
|
if (cancelled)
|
|
155
174
|
return;
|
|
156
175
|
setRoutingStatus("error");
|
|
157
|
-
logDebug("[Lovalingo] Failed to fetch
|
|
176
|
+
logDebug("[Lovalingo] Failed to fetch routing config:", err);
|
|
158
177
|
}
|
|
159
178
|
})();
|
|
160
179
|
return () => {
|
|
161
180
|
cancelled = true;
|
|
162
181
|
};
|
|
163
|
-
}, [
|
|
182
|
+
}, [fetchRoutingConfig, inactivePagesStorageKey, nonLocalizedStorageKey, resolvedApiKey]);
|
|
164
183
|
return (React.createElement(BrowserRouter, null,
|
|
165
184
|
React.createElement(NavigateExporter, { navigateRef: navigateRef }),
|
|
166
|
-
React.createElement(LangRoutingContext.Provider, { value: { nonLocalizedPaths, status: routingStatus } },
|
|
185
|
+
React.createElement(LangRoutingContext.Provider, { value: { defaultLang, nonLocalizedPaths, inactivePages, status: routingStatus } },
|
|
167
186
|
React.createElement(Routes, null,
|
|
168
|
-
langs.map((lang) => (React.createElement(Route, { key: lang, path: `${lang}/*`, element: React.createElement(LangGuard, { lang: lang, nonLocalizedPaths: nonLocalizedPaths }) },
|
|
187
|
+
langs.map((lang) => (React.createElement(Route, { key: lang, path: `${lang}/*`, element: React.createElement(LangGuard, { lang: lang, nonLocalizedPaths: nonLocalizedPaths, defaultLang: defaultLang }) },
|
|
169
188
|
React.createElement(Route, { index: true, element: React.createElement(React.Fragment, null, children) }),
|
|
170
189
|
React.createElement(Route, { path: "*", element: React.createElement(React.Fragment, null, children) })))),
|
|
171
190
|
React.createElement(Route, { path: "*", element: React.createElement(RedirectToDefaultLang, { defaultLang: defaultLang, nonLocalizedPaths: nonLocalizedPaths, routingStatus: routingStatus }, children) })))));
|
|
@@ -210,16 +210,18 @@ export const LanguageSwitcher = ({ locales, currentLocale, onLocaleChange, posit
|
|
|
210
210
|
React.createElement("span", { style: {
|
|
211
211
|
width: '16px',
|
|
212
212
|
height: '16px',
|
|
213
|
-
borderRadius: '
|
|
214
|
-
|
|
213
|
+
borderRadius: '999px',
|
|
214
|
+
overflow: 'hidden',
|
|
215
|
+
background: '#DA2576',
|
|
215
216
|
display: 'inline-flex',
|
|
216
217
|
alignItems: 'center',
|
|
217
218
|
justifyContent: 'center',
|
|
218
219
|
boxShadow: 'inset 0 0 0 1px rgba(0,0,0,0.25)',
|
|
219
220
|
flexShrink: 0,
|
|
220
221
|
} },
|
|
221
|
-
React.createElement("svg", { width: "
|
|
222
|
-
React.createElement("path", { d: "
|
|
222
|
+
React.createElement("svg", { width: "16", height: "16", viewBox: "0 0 512 512", fill: "none", "aria-hidden": "true" },
|
|
223
|
+
React.createElement("path", { d: "M215.657 429.489C270.707 422.73 289.644 339.333 278 244.5C266.356 149.667 228.54 79.3089 173.49 86.0682C118.44 92.8275 83.253 175.184 94.8971 270.017C106.541 364.85 160.607 436.248 215.657 429.489Z", stroke: "#FFFFFF", strokeWidth: "44", strokeLinecap: "round", strokeLinejoin: "round" }),
|
|
224
|
+
React.createElement("path", { d: "M168.218 408.885C188.661 447.333 263.959 447.277 336.399 408.759C408.84 370.242 450.992 307.849 430.549 269.401C410.106 230.953 334.808 231.009 262.368 269.526C189.927 308.044 147.775 370.437 168.218 408.885Z", stroke: "#FFFFFF", strokeWidth: "44", strokeLinecap: "round", strokeLinejoin: "round" }))),
|
|
223
225
|
React.createElement("span", null,
|
|
224
226
|
branding.label || 'Localized by',
|
|
225
227
|
" ",
|
|
@@ -1,6 +1,8 @@
|
|
|
1
1
|
import type { NonLocalizedPathRule } from "../utils/nonLocalizedPaths";
|
|
2
2
|
export type LangRoutingContextValue = {
|
|
3
|
+
defaultLang: string;
|
|
3
4
|
nonLocalizedPaths: NonLocalizedPathRule[];
|
|
5
|
+
inactivePages: string[];
|
|
4
6
|
status: "unknown" | "loading" | "ready" | "error";
|
|
5
7
|
};
|
|
6
8
|
export declare const LangRoutingContext: import("react").Context<LangRoutingContextValue>;
|
|
@@ -32,7 +32,9 @@ export function useLangNavigate() {
|
|
|
32
32
|
if (!trimmed)
|
|
33
33
|
return;
|
|
34
34
|
const normalized = trimmed.startsWith("/") ? trimmed : `/${trimmed}`;
|
|
35
|
-
const fullPath = isNonLocalizedPath(normalized, routing.nonLocalizedPaths)
|
|
35
|
+
const fullPath = isNonLocalizedPath(normalized, routing.nonLocalizedPaths)
|
|
36
|
+
? normalized
|
|
37
|
+
: `/${lang}${normalized}`;
|
|
36
38
|
navigate(fullPath, options);
|
|
37
39
|
}, [lang, navigate, routing.nonLocalizedPaths]);
|
|
38
40
|
}
|
package/dist/index.d.ts
CHANGED
|
@@ -1,9 +1,9 @@
|
|
|
1
1
|
/**
|
|
2
2
|
* @Lovalingo/Lovalingo - Proprietary Translation Library
|
|
3
3
|
*
|
|
4
|
-
* Copyright (c)
|
|
4
|
+
* Copyright (c) 2026 Lovalingo Swiss. All rights reserved.
|
|
5
5
|
*
|
|
6
|
-
* This software is the intellectual property of
|
|
6
|
+
* This software is the intellectual property of Lovalingo Swiss.
|
|
7
7
|
* NOT OPEN SOURCE - All rights reserved.
|
|
8
8
|
*
|
|
9
9
|
* Unauthorized copying, modification, distribution, or use of this software
|
package/dist/index.js
CHANGED
|
@@ -1,9 +1,9 @@
|
|
|
1
1
|
/**
|
|
2
2
|
* @Lovalingo/Lovalingo - Proprietary Translation Library
|
|
3
3
|
*
|
|
4
|
-
* Copyright (c)
|
|
4
|
+
* Copyright (c) 2026 Lovalingo Swiss. All rights reserved.
|
|
5
5
|
*
|
|
6
|
-
* This software is the intellectual property of
|
|
6
|
+
* This software is the intellectual property of Lovalingo Swiss.
|
|
7
7
|
* NOT OPEN SOURCE - All rights reserved.
|
|
8
8
|
*
|
|
9
9
|
* Unauthorized copying, modification, distribution, or use of this software
|
package/dist/utils/api.d.ts
CHANGED
|
@@ -30,6 +30,10 @@ export type BootstrapResponse = {
|
|
|
30
30
|
match_type?: string;
|
|
31
31
|
updated_at?: string | null;
|
|
32
32
|
}>;
|
|
33
|
+
inactive_pages?: Array<{
|
|
34
|
+
page_path?: string;
|
|
35
|
+
updated_at?: string | null;
|
|
36
|
+
}>;
|
|
33
37
|
loading_bg_color?: string | null;
|
|
34
38
|
branding_enabled?: boolean;
|
|
35
39
|
seoEnabled?: boolean;
|
package/dist/utils/logger.js
CHANGED
|
@@ -4,6 +4,23 @@ function isDebugEnabled() {
|
|
|
4
4
|
const value = globalThis.__lovalingoDebug;
|
|
5
5
|
if (value === true || value === "true" || value === 1)
|
|
6
6
|
return true;
|
|
7
|
+
try {
|
|
8
|
+
const params = typeof window !== "undefined" ? new URLSearchParams(window.location.search) : null;
|
|
9
|
+
const query = params?.get("lovalingoDebug") || params?.get("lovalingo_debug") || "";
|
|
10
|
+
if (query === "1" || query === "true")
|
|
11
|
+
return true;
|
|
12
|
+
}
|
|
13
|
+
catch {
|
|
14
|
+
// ignore
|
|
15
|
+
}
|
|
16
|
+
try {
|
|
17
|
+
const stored = typeof window !== "undefined" ? window.localStorage?.getItem("Lovalingo_debug") : null;
|
|
18
|
+
if (stored === "1" || stored === "true")
|
|
19
|
+
return true;
|
|
20
|
+
}
|
|
21
|
+
catch {
|
|
22
|
+
// ignore
|
|
23
|
+
}
|
|
7
24
|
return false;
|
|
8
25
|
}
|
|
9
26
|
export function logDebug(...args) {
|
|
@@ -7,3 +7,6 @@ export declare function matchesNonLocalizedRules(pathname: string, rules: NonLoc
|
|
|
7
7
|
export declare function isNonLocalizedPath(pathname: string, rules: NonLocalizedPathRule[]): boolean;
|
|
8
8
|
export declare function stripLocalePrefix(pathname: string, locales: string[]): string;
|
|
9
9
|
export declare function parseBootstrapNonLocalizedPaths(value: unknown): NonLocalizedPathRule[];
|
|
10
|
+
export declare function parseBootstrapInactivePages(value: unknown): string[];
|
|
11
|
+
export declare function matchesRouteTemplate(pathname: string, template: string): boolean;
|
|
12
|
+
export declare function isInactivePagePath(pathname: string, inactivePages: string[]): boolean;
|
|
@@ -26,7 +26,12 @@ export function matchesNonLocalizedRules(pathname, rules) {
|
|
|
26
26
|
continue;
|
|
27
27
|
}
|
|
28
28
|
if (matchType === "prefix") {
|
|
29
|
-
if (input
|
|
29
|
+
if (input === pattern)
|
|
30
|
+
return true;
|
|
31
|
+
const normalizedPrefix = pattern.endsWith("/") ? pattern.slice(0, -1) : pattern;
|
|
32
|
+
if (!normalizedPrefix || normalizedPrefix === "/")
|
|
33
|
+
return true;
|
|
34
|
+
if (input.startsWith(`${normalizedPrefix}/`))
|
|
30
35
|
return true;
|
|
31
36
|
continue;
|
|
32
37
|
}
|
|
@@ -76,3 +81,56 @@ export function parseBootstrapNonLocalizedPaths(value) {
|
|
|
76
81
|
}
|
|
77
82
|
return out;
|
|
78
83
|
}
|
|
84
|
+
export function parseBootstrapInactivePages(value) {
|
|
85
|
+
if (!Array.isArray(value))
|
|
86
|
+
return [];
|
|
87
|
+
const out = [];
|
|
88
|
+
for (const row of value) {
|
|
89
|
+
if (typeof row === "string") {
|
|
90
|
+
const pagePath = row.trim();
|
|
91
|
+
if (pagePath && pagePath.startsWith("/"))
|
|
92
|
+
out.push(pagePath);
|
|
93
|
+
continue;
|
|
94
|
+
}
|
|
95
|
+
if (!row || typeof row !== "object")
|
|
96
|
+
continue;
|
|
97
|
+
const record = row;
|
|
98
|
+
const pagePath = typeof record.page_path === "string" ? record.page_path.trim() : "";
|
|
99
|
+
if (!pagePath || !pagePath.startsWith("/"))
|
|
100
|
+
continue;
|
|
101
|
+
out.push(pagePath);
|
|
102
|
+
}
|
|
103
|
+
return out;
|
|
104
|
+
}
|
|
105
|
+
function escapeRegexLiteral(value) {
|
|
106
|
+
return value.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
|
|
107
|
+
}
|
|
108
|
+
export function matchesRouteTemplate(pathname, template) {
|
|
109
|
+
const input = (pathname || "").toString();
|
|
110
|
+
const pattern = (template || "").toString();
|
|
111
|
+
if (!input.startsWith("/") || !pattern.startsWith("/"))
|
|
112
|
+
return false;
|
|
113
|
+
if (input === pattern)
|
|
114
|
+
return true;
|
|
115
|
+
// Convert "/dashboard/projects/:id/pages" or "/use-cases/[slug]" into a safe regex.
|
|
116
|
+
const parts = pattern.split("/").filter(Boolean);
|
|
117
|
+
const regexParts = parts.map((part) => {
|
|
118
|
+
if (part === "*")
|
|
119
|
+
return ".*";
|
|
120
|
+
if (part.startsWith(":"))
|
|
121
|
+
return "[^/]+";
|
|
122
|
+
if (part.startsWith("[") && part.endsWith("]"))
|
|
123
|
+
return "[^/]+";
|
|
124
|
+
return escapeRegexLiteral(part);
|
|
125
|
+
});
|
|
126
|
+
const regex = new RegExp(`^/${regexParts.join("/")}$`);
|
|
127
|
+
return regex.test(input);
|
|
128
|
+
}
|
|
129
|
+
export function isInactivePagePath(pathname, inactivePages) {
|
|
130
|
+
const input = (pathname || "").toString();
|
|
131
|
+
if (!input.startsWith("/"))
|
|
132
|
+
return false;
|
|
133
|
+
if (!Array.isArray(inactivePages) || inactivePages.length === 0)
|
|
134
|
+
return false;
|
|
135
|
+
return inactivePages.some((pattern) => matchesRouteTemplate(input, pattern));
|
|
136
|
+
}
|
package/dist/version.d.ts
CHANGED
|
@@ -1 +1 @@
|
|
|
1
|
-
export declare const VERSION = "0.5.
|
|
1
|
+
export declare const VERSION = "0.5.5";
|
package/dist/version.js
CHANGED
|
@@ -1 +1 @@
|
|
|
1
|
-
export const VERSION = "0.5.
|
|
1
|
+
export const VERSION = "0.5.5";
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@lovalingo/lovalingo",
|
|
3
|
-
"version": "0.5.
|
|
3
|
+
"version": "0.5.5",
|
|
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",
|
|
@@ -28,7 +28,7 @@
|
|
|
28
28
|
"lovable",
|
|
29
29
|
"vibe-coded"
|
|
30
30
|
],
|
|
31
|
-
"author": "
|
|
31
|
+
"author": "Lovalingo Swiss",
|
|
32
32
|
"license": "UNLICENSED",
|
|
33
33
|
"private": false,
|
|
34
34
|
"peerDependencies": {
|