@lovalingo/lovalingo 0.5.2 → 0.5.4

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 CHANGED
@@ -1,9 +1,9 @@
1
1
  LOVALINGO COMMERCIAL LICENSE
2
2
 
3
- Copyright (c) 2025 Mertens Advies. All rights reserved.
3
+ Copyright (c) 2025 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 Mertens Advies.
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 Mertens Advies.
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 Mertens Advies.
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 Mertens Advies.
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 Mertens Advies operates, without regard to its conflict
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 Mertens Advies.
142
+ please contact Lovalingo Swiss.
143
143
 
144
144
  MERTENS ADVIES
145
145
  Lovalingo Translation Platform
146
146
  © 2025 All Rights Reserved
147
147
 
148
- Website: [Contact Mertens Advies for licensing information]
148
+ Website: [Contact Lovalingo Swiss for licensing information]
package/README.md CHANGED
@@ -55,7 +55,7 @@ export function App() {
55
55
  const navigateRef = useRef();
56
56
 
57
57
  return (
58
- <LangRouter defaultLang="en" langs={["en", "de", "fr"]} navigateRef={navigateRef}>
58
+ <LangRouter publicAnonKey="aix_your_public_anon_key" defaultLang="en" langs={["en", "de", "fr"]} navigateRef={navigateRef}>
59
59
  <LovalingoProvider
60
60
  publicAnonKey="aix_your_public_anon_key"
61
61
  defaultLocale="en"
@@ -349,7 +349,7 @@ export async function GET() {
349
349
 
350
350
  **COMMERCIAL LICENSE - NOT OPEN SOURCE**
351
351
 
352
- Copyright (c) 2025 Mertens Advies. All rights reserved.
352
+ Copyright (c) 2025 Lovalingo Swiss. All rights reserved.
353
353
 
354
354
  ### For Agencies & Developers
355
355
 
@@ -374,6 +374,6 @@ applications containing Lovalingo, but may not modify, redistribute, or extract
374
374
 
375
375
  This software is licensed under the **Lovalingo Commercial License**.
376
376
  This is NOT open source software. All intellectual property rights remain the
377
- exclusive property of Mertens Advies.
377
+ exclusive property of Lovalingo Swiss.
378
378
 
379
379
  See LICENSE file for complete terms and conditions.
@@ -1,10 +1,12 @@
1
- import React, { useMemo, useState, useEffect, useCallback, useRef } from 'react';
1
+ import React, { useMemo, useState, useEffect, useCallback, useRef, useContext } from 'react';
2
2
  import { LovalingoContext } from '../context/LovalingoContext';
3
+ import { LangRoutingContext } from '../context/LangRoutingContext';
3
4
  import { LovalingoAPI } from '../utils/api';
4
5
  import { applyDomRules } from '../utils/domRules';
5
6
  import { hashContent } from '../utils/hash';
6
7
  import { applyActiveTranslations, getCriticalFingerprint, restoreDom, setActiveTranslations, setMarkerEngineExclusions, startMarkerEngine } from '../utils/markerEngine';
7
8
  import { logDebug, warnDebug, errorDebug } from '../utils/logger';
9
+ import { isNonLocalizedPath, stripLocalePrefix } from '../utils/nonLocalizedPaths';
8
10
  import { processPath } from '../utils/pathNormalizer';
9
11
  import { LanguageSwitcher } from './LanguageSwitcher';
10
12
  const LOCALE_STORAGE_KEY = 'Lovalingo_locale';
@@ -75,6 +77,7 @@ navigateRef, // For path mode routing
75
77
  useEffect(() => {
76
78
  apiRef.current = new LovalingoAPI(resolvedApiKey, apiBase, enhancedPathConfig);
77
79
  }, [apiBase, enhancedPathConfig, resolvedApiKey]);
80
+ const routingConfig = useContext(LangRoutingContext);
78
81
  const [entitlements, setEntitlements] = useState(() => apiRef.current.getEntitlements());
79
82
  const lastPageviewRef = useRef("");
80
83
  const lastPageviewFingerprintRef = useRef("");
@@ -111,6 +114,7 @@ navigateRef, // For path mode routing
111
114
  const prehideStateRef = useRef({
112
115
  active: false,
113
116
  timeoutId: null,
117
+ startedAtMs: null,
114
118
  prevHtmlVisibility: "",
115
119
  prevBodyVisibility: "",
116
120
  prevHtmlBg: "",
@@ -195,6 +199,27 @@ navigateRef, // For path mode routing
195
199
  pageviewFingerprintTimeoutRef.current = window.setTimeout(trySendFingerprint, 800);
196
200
  pageviewFingerprintRetryTimeoutRef.current = window.setTimeout(trySendFingerprint, 2000);
197
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
+ }, []);
198
223
  const enablePrehide = useCallback((bgColor) => {
199
224
  if (typeof document === "undefined")
200
225
  return;
@@ -203,8 +228,13 @@ navigateRef, // For path mode routing
203
228
  if (!html || !body)
204
229
  return;
205
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
+ }
206
235
  if (!state.active) {
207
236
  state.active = true;
237
+ state.startedAtMs = Date.now();
208
238
  state.prevHtmlVisibility = html.style.visibility || "";
209
239
  state.prevBodyVisibility = body.style.visibility || "";
210
240
  state.prevHtmlBg = html.style.backgroundColor || "";
@@ -220,28 +250,9 @@ navigateRef, // For path mode routing
220
250
  return;
221
251
  }
222
252
  // Why: avoid a "perma-hide" when navigation events repeatedly re-trigger prehide and keep extending the timeout.
223
- state.timeoutId = window.setTimeout(() => disablePrehide(), PREHIDE_FAILSAFE_MS);
224
- }, []);
225
- const disablePrehide = useCallback(() => {
226
- if (typeof document === "undefined")
227
- return;
228
- const html = document.documentElement;
229
- const body = document.body;
230
- if (!html || !body)
231
- return;
232
- const state = prehideStateRef.current;
233
- if (state.timeoutId != null) {
234
- window.clearTimeout(state.timeoutId);
235
- state.timeoutId = null;
236
- }
237
- if (!state.active)
238
- return;
239
- state.active = false;
240
- html.style.visibility = state.prevHtmlVisibility;
241
- body.style.visibility = state.prevBodyVisibility;
242
- html.style.backgroundColor = state.prevHtmlBg;
243
- body.style.backgroundColor = state.prevBodyBg;
244
- }, []);
253
+ state.timeoutId = window.setTimeout(() => forceDisablePrehide(), PREHIDE_FAILSAFE_MS);
254
+ }, [forceDisablePrehide]);
255
+ const disablePrehide = forceDisablePrehide;
245
256
  const buildCriticalCacheKey = useCallback((targetLocale, normalizedPath) => {
246
257
  const key = `${resolvedApiKey || "anonymous"}:${targetLocale}:${normalizedPath || "/"}`;
247
258
  return `${CRITICAL_CACHE_PREFIX}:${hashContent(key)}`;
@@ -554,6 +565,17 @@ navigateRef, // For path mode routing
554
565
  isNavigatingRef.current = false;
555
566
  return;
556
567
  }
568
+ if (routing === "path") {
569
+ const stripped = stripLocalePrefix(window.location.pathname, allLocales);
570
+ if (isNonLocalizedPath(stripped, routingConfig.nonLocalizedPaths)) {
571
+ // Why: auth/admin (non-localized) routes must never be blocked or mutated by the translation runtime.
572
+ disablePrehide();
573
+ setActiveTranslations(null);
574
+ restoreDom(document.body);
575
+ isNavigatingRef.current = false;
576
+ return;
577
+ }
578
+ }
557
579
  const currentPath = window.location.pathname + window.location.search;
558
580
  const normalizedPath = processPath(window.location.pathname, enhancedPathConfig);
559
581
  const cacheKey = `${targetLocale}:${normalizedPath}`;
@@ -740,6 +762,7 @@ navigateRef, // For path mode routing
740
762
  }
741
763
  }, [
742
764
  applySeoBundle,
765
+ allLocales,
743
766
  autoApplyRules,
744
767
  defaultLocale,
745
768
  disablePrehide,
@@ -749,6 +772,8 @@ navigateRef, // For path mode routing
749
772
  isSeoActive,
750
773
  mode,
751
774
  readCriticalCache,
775
+ routing,
776
+ routingConfig.nonLocalizedPaths,
752
777
  setCachedLoadingBgColor,
753
778
  toTranslations,
754
779
  writeCriticalCache,
@@ -837,6 +862,13 @@ navigateRef, // For path mode routing
837
862
  isNavigatingRef.current = true;
838
863
  // Update URL based on routing strategy
839
864
  if (routing === 'path') {
865
+ const stripped = stripLocalePrefix(window.location.pathname, allLocales);
866
+ if (isNonLocalizedPath(stripped, routingConfig.nonLocalizedPaths)) {
867
+ // Why: switching languages must not rewrite non-localized routes like "/auth" to "/de/auth".
868
+ setLocaleState(newLocale);
869
+ isNavigatingRef.current = false;
870
+ return;
871
+ }
840
872
  const pathParts = window.location.pathname.split('/').filter(Boolean);
841
873
  // Strip existing locale
842
874
  if (allLocales.includes(pathParts[0])) {
@@ -869,7 +901,7 @@ navigateRef, // For path mode routing
869
901
  })().finally(() => {
870
902
  isInternalNavigationRef.current = false;
871
903
  });
872
- }, [allLocales, locale, routing, loadData, navigateRef]);
904
+ }, [allLocales, defaultLocale, locale, routing, loadData, navigateRef, routingConfig.nonLocalizedPaths]);
873
905
  // No string-level "misses" from the client: the pipeline (Browser Rendering) is source-of-truth for string discovery.
874
906
  // Initialize
875
907
  useEffect(() => {
@@ -929,13 +961,6 @@ navigateRef, // For path mode routing
929
961
  if (!autoPrefixLinks)
930
962
  return;
931
963
  const supportedLocales = allLocales;
932
- const isAssetPath = (pathname) => {
933
- if (pathname === '/robots.txt' || pathname === '/sitemap.xml')
934
- return true;
935
- if (pathname.startsWith('/.well-known/'))
936
- return true;
937
- return /\.(?:png|jpg|jpeg|gif|svg|webp|avif|ico|css|js|map|json|xml|txt|pdf|zip|gz|br|woff2?|ttf|eot)$/i.test(pathname);
938
- };
939
964
  const shouldProcessCurrentPath = () => {
940
965
  const parts = window.location.pathname.split('/').filter(Boolean);
941
966
  return parts.length > 0 && supportedLocales.includes(parts[0]);
@@ -963,7 +988,7 @@ navigateRef, // For path mode routing
963
988
  }
964
989
  if (url.origin !== window.location.origin)
965
990
  return null;
966
- if (isAssetPath(url.pathname))
991
+ if (isNonLocalizedPath(url.pathname, routingConfig.nonLocalizedPaths))
967
992
  return null;
968
993
  const parts = url.pathname.split('/').filter(Boolean);
969
994
  // Root ("/") should be locale-prefixed too (e.g. clicking a logo linking to "https://example.com")
@@ -1070,7 +1095,7 @@ navigateRef, // For path mode routing
1070
1095
  mo.disconnect();
1071
1096
  document.removeEventListener('click', onClickCapture, true);
1072
1097
  };
1073
- }, [routing, autoPrefixLinks, allLocales, locale, navigateRef]);
1098
+ }, [routing, autoPrefixLinks, allLocales, locale, navigateRef, routingConfig.nonLocalizedPaths]);
1074
1099
  // Navigation prefetch: warm the HTTP cache for bootstrap + page bundle before the user clicks (reduces EN→FR flash on SPA route changes).
1075
1100
  useEffect(() => {
1076
1101
  if (!resolvedApiKey)
@@ -1182,9 +1207,16 @@ navigateRef, // For path mode routing
1182
1207
  };
1183
1208
  return (React.createElement(LovalingoContext.Provider, { value: contextValue },
1184
1209
  children,
1185
- React.createElement(LanguageSwitcher, { locales: allLocales, currentLocale: locale, onLocaleChange: setLocale, position: switcherPosition, offsetY: switcherOffsetY, theme: switcherTheme, branding: {
1210
+ (() => {
1211
+ if (routing !== "path")
1212
+ return true;
1213
+ if (typeof window === "undefined")
1214
+ return true;
1215
+ const stripped = stripLocalePrefix(window.location.pathname, allLocales);
1216
+ return !isNonLocalizedPath(stripped, routingConfig.nonLocalizedPaths);
1217
+ })() && (React.createElement(LanguageSwitcher, { locales: allLocales, currentLocale: locale, onLocaleChange: setLocale, position: switcherPosition, offsetY: switcherOffsetY, theme: switcherTheme, branding: {
1186
1218
  required: Boolean(entitlements?.brandingRequired),
1187
1219
  enabled: brandingEnabled,
1188
1220
  href: "https://lovalingo.com",
1189
- } })));
1221
+ } }))));
1190
1222
  };
@@ -1,10 +1,9 @@
1
1
  import React from 'react';
2
2
  import { Link } from 'react-router-dom';
3
3
  import { useLang } from '../hooks/useLang';
4
- const NON_LOCALIZED_APP_PATHS = new Set([
5
- 'robots.txt',
6
- 'sitemap.xml',
7
- ]);
4
+ import { useContext } from 'react';
5
+ import { LangRoutingContext } from '../context/LangRoutingContext';
6
+ import { isNonLocalizedPath } from '../utils/nonLocalizedPaths';
8
7
  /**
9
8
  * LangLink - Language-aware Link component
10
9
  *
@@ -24,12 +23,15 @@ const NON_LOCALIZED_APP_PATHS = new Set([
24
23
  */
25
24
  export function LangLink({ to, ...props }) {
26
25
  const lang = useLang();
26
+ const routing = useContext(LangRoutingContext);
27
27
  // If 'to' is a string, prepend language
28
28
  const langTo = typeof to === 'string'
29
29
  ? (() => {
30
- const trimmed = to.replace(/^\//, '');
31
- const firstSegment = trimmed.split('/')[0] || '';
32
- return NON_LOCALIZED_APP_PATHS.has(firstSegment) ? `/${trimmed}` : `/${lang}/${trimmed}`;
30
+ const trimmed = (to || '').toString().trim();
31
+ const normalized = trimmed.startsWith('/') ? trimmed : `/${trimmed}`;
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 });
@@ -4,6 +4,9 @@ interface LangRouterProps {
4
4
  defaultLang: string;
5
5
  langs: string[];
6
6
  navigateRef?: React.MutableRefObject<((path: string) => void) | undefined>;
7
+ apiBase?: string;
8
+ apiKey?: string;
9
+ publicAnonKey?: string;
7
10
  }
8
11
  /**
9
12
  * LangRouter - Drop-in replacement for BrowserRouter that automatically handles language routing
@@ -30,5 +33,5 @@ interface LangRouterProps {
30
33
  * - /fr/pricing
31
34
  * - etc.
32
35
  */
33
- export declare function LangRouter({ children, defaultLang, langs, navigateRef }: LangRouterProps): React.JSX.Element;
36
+ export declare function LangRouter({ children, defaultLang, langs, navigateRef, apiBase, apiKey, publicAnonKey }: LangRouterProps): React.JSX.Element;
34
37
  export {};
@@ -1,17 +1,9 @@
1
- import React, { useEffect } from 'react';
1
+ import React, { useCallback, useEffect, useMemo, useState } from 'react';
2
2
  import { BrowserRouter, Routes, Route, Navigate, Outlet, useLocation, useNavigate } from 'react-router-dom';
3
3
  import { LangContext } from '../context/LangContext';
4
- const NON_LOCALIZED_APP_PATHS = new Set([
5
- '/robots.txt',
6
- '/sitemap.xml',
7
- ]);
8
- function isNonLocalizedPath(pathname) {
9
- if (NON_LOCALIZED_APP_PATHS.has(pathname))
10
- return true;
11
- if (pathname.startsWith('/.well-known/'))
12
- return true;
13
- return /\.(?:png|jpg|jpeg|gif|svg|webp|avif|ico|css|js|map|json|xml|txt|pdf|zip|gz|br|woff2?|ttf|eot)$/i.test(pathname);
14
- }
4
+ import { LangRoutingContext } from '../context/LangRoutingContext';
5
+ import { isNonLocalizedPath, parseBootstrapInactivePages, parseBootstrapNonLocalizedPaths } from '../utils/nonLocalizedPaths';
6
+ import { logDebug } from '../utils/logger';
15
7
  /**
16
8
  * NavigateExporter - Internal component that exports navigate function via ref
17
9
  */
@@ -27,13 +19,14 @@ function NavigateExporter({ navigateRef }) {
27
19
  /**
28
20
  * LangGuard - Internal component that validates language and provides it to children
29
21
  */
30
- function LangGuard({ defaultLang, lang }) {
22
+ function LangGuard({ lang, nonLocalizedPaths, defaultLang, }) {
31
23
  const location = useLocation();
32
24
  // If the URL is language-prefixed but the underlying route is non-localized (e.g. robots/sitemap),
33
25
  // redirect to the canonical non-localized path.
34
26
  const prefix = `/${lang}`;
35
27
  const restPath = location.pathname.startsWith(prefix) ? location.pathname.slice(prefix.length) || '/' : location.pathname;
36
- if (isNonLocalizedPath(restPath)) {
28
+ // Why: only explicit non-localized rules may strip the locale prefix; inactive pages must never affect client routing.
29
+ if (isNonLocalizedPath(restPath, nonLocalizedPaths)) {
37
30
  const nextPath = `${restPath}${location.search}${location.hash}`;
38
31
  return React.createElement(Navigate, { to: nextPath, replace: true });
39
32
  }
@@ -41,15 +34,24 @@ function LangGuard({ defaultLang, lang }) {
41
34
  return (React.createElement(LangContext.Provider, { value: lang },
42
35
  React.createElement(Outlet, { context: { lang } })));
43
36
  }
44
- function RedirectToDefaultLang({ defaultLang, children }) {
37
+ function RedirectToDefaultLang({ defaultLang, children, nonLocalizedPaths, routingStatus, }) {
45
38
  const location = useLocation();
46
- if (isNonLocalizedPath(location.pathname)) {
47
- return React.createElement(React.Fragment, null, children);
48
- }
49
- const nextPath = location.pathname === '/' || location.pathname === ''
50
- ? `/${defaultLang}${location.search}${location.hash}`
51
- : `/${defaultLang}${location.pathname}${location.search}${location.hash}`;
52
- return React.createElement(Navigate, { to: nextPath, replace: true });
39
+ const navigate = useNavigate();
40
+ const shouldSkip = isNonLocalizedPath(location.pathname, nonLocalizedPaths);
41
+ useEffect(() => {
42
+ if (shouldSkip)
43
+ return;
44
+ if (routingStatus === "loading")
45
+ return;
46
+ const nextPath = location.pathname === "/" || location.pathname === ""
47
+ ? `/${defaultLang}${location.search}${location.hash}`
48
+ : `/${defaultLang}${location.pathname}${location.search}${location.hash}`;
49
+ const current = `${location.pathname}${location.search}${location.hash}`;
50
+ if (nextPath === current)
51
+ return;
52
+ navigate(nextPath, { replace: true });
53
+ }, [defaultLang, location.hash, location.pathname, location.search, navigate, routingStatus, shouldSkip]);
54
+ return React.createElement(React.Fragment, null, children);
53
55
  }
54
56
  /**
55
57
  * LangRouter - Drop-in replacement for BrowserRouter that automatically handles language routing
@@ -76,12 +78,114 @@ function RedirectToDefaultLang({ defaultLang, children }) {
76
78
  * - /fr/pricing
77
79
  * - etc.
78
80
  */
79
- export function LangRouter({ children, defaultLang, langs, navigateRef }) {
81
+ export function LangRouter({ children, defaultLang, langs, navigateRef, apiBase, apiKey, publicAnonKey }) {
82
+ const metaKey = typeof document !== "undefined"
83
+ ? document.querySelector('meta[name="lovalingo-public-anon-key"]')?.content?.trim() || ""
84
+ : "";
85
+ const globals = globalThis;
86
+ const resolvedApiKey = (typeof apiKey === "string" && apiKey.trim().length > 0
87
+ ? apiKey
88
+ : typeof publicAnonKey === "string" && publicAnonKey.trim().length > 0
89
+ ? publicAnonKey
90
+ : globals.__LOVALINGO_PUBLIC_ANON_KEY__ || globals.__LOVALINGO_API_KEY__ || metaKey || "").trim();
91
+ const resolvedApiBase = (typeof apiBase === "string" && apiBase.trim().length > 0
92
+ ? apiBase.trim()
93
+ : "https://cdn.lovalingo.com");
94
+ const nonLocalizedStorageKey = useMemo(() => `Lovalingo_non_localized_paths:${resolvedApiKey || "anonymous"}`, [resolvedApiKey]);
95
+ const inactivePagesStorageKey = useMemo(() => `Lovalingo_inactive_pages:${resolvedApiKey || "anonymous"}`, [resolvedApiKey]);
96
+ const [nonLocalizedPaths, setNonLocalizedPaths] = useState(() => {
97
+ if (typeof window === "undefined")
98
+ return [];
99
+ if (!resolvedApiKey)
100
+ return [];
101
+ try {
102
+ const raw = localStorage.getItem(nonLocalizedStorageKey);
103
+ if (!raw)
104
+ return [];
105
+ const parsed = JSON.parse(raw);
106
+ return parseBootstrapNonLocalizedPaths(parsed);
107
+ }
108
+ catch {
109
+ return [];
110
+ }
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
+ });
128
+ const [routingStatus, setRoutingStatus] = useState(() => {
129
+ if (!resolvedApiKey)
130
+ return "unknown";
131
+ return nonLocalizedPaths.length > 0 || inactivePages.length > 0 ? "ready" : "loading";
132
+ });
133
+ const fetchRoutingConfig = useCallback(async () => {
134
+ if (typeof window === "undefined")
135
+ return;
136
+ if (!resolvedApiKey)
137
+ return;
138
+ const pathParam = window.location.pathname + window.location.search;
139
+ const requestUrl = `${resolvedApiBase}/functions/v1/bootstrap?key=${encodeURIComponent(resolvedApiKey)}&locale=${encodeURIComponent(defaultLang)}&path=${encodeURIComponent(pathParam)}`;
140
+ const response = await fetch(requestUrl);
141
+ const resolvedResponse = response.status === 304 ? await fetch(requestUrl, { cache: "force-cache" }) : response;
142
+ if (!resolvedResponse.ok)
143
+ throw new Error(`bootstrap HTTP ${resolvedResponse.status}`);
144
+ const data = (await resolvedResponse.json());
145
+ const record = (data || {});
146
+ return {
147
+ nonLocalizedPaths: parseBootstrapNonLocalizedPaths(record["non_localized_paths"]),
148
+ inactivePages: parseBootstrapInactivePages(record["inactive_pages"]),
149
+ };
150
+ }, [defaultLang, resolvedApiBase, resolvedApiKey]);
151
+ useEffect(() => {
152
+ let cancelled = false;
153
+ void (async () => {
154
+ if (!resolvedApiKey)
155
+ return;
156
+ setRoutingStatus((prev) => (prev === "ready" ? prev : "loading"));
157
+ try {
158
+ const next = await fetchRoutingConfig();
159
+ if (cancelled || !next)
160
+ return;
161
+ setNonLocalizedPaths(next.nonLocalizedPaths);
162
+ setInactivePages(next.inactivePages);
163
+ setRoutingStatus("ready");
164
+ try {
165
+ localStorage.setItem(nonLocalizedStorageKey, JSON.stringify(next.nonLocalizedPaths));
166
+ localStorage.setItem(inactivePagesStorageKey, JSON.stringify(next.inactivePages));
167
+ }
168
+ catch {
169
+ // ignore
170
+ }
171
+ }
172
+ catch (err) {
173
+ if (cancelled)
174
+ return;
175
+ setRoutingStatus("error");
176
+ logDebug("[Lovalingo] Failed to fetch routing config:", err);
177
+ }
178
+ })();
179
+ return () => {
180
+ cancelled = true;
181
+ };
182
+ }, [fetchRoutingConfig, inactivePagesStorageKey, nonLocalizedStorageKey, resolvedApiKey]);
80
183
  return (React.createElement(BrowserRouter, null,
81
184
  React.createElement(NavigateExporter, { navigateRef: navigateRef }),
82
- React.createElement(Routes, null,
83
- langs.map((lang) => (React.createElement(Route, { key: lang, path: `${lang}/*`, element: React.createElement(LangGuard, { defaultLang: defaultLang, lang: lang }) },
84
- React.createElement(Route, { index: true, element: React.createElement(React.Fragment, null, children) }),
85
- React.createElement(Route, { path: "*", element: React.createElement(React.Fragment, null, children) })))),
86
- React.createElement(Route, { path: "*", element: React.createElement(RedirectToDefaultLang, { defaultLang: defaultLang }, children) }))));
185
+ React.createElement(LangRoutingContext.Provider, { value: { defaultLang, nonLocalizedPaths, inactivePages, status: routingStatus } },
186
+ React.createElement(Routes, null,
187
+ langs.map((lang) => (React.createElement(Route, { key: lang, path: `${lang}/*`, element: React.createElement(LangGuard, { lang: lang, nonLocalizedPaths: nonLocalizedPaths, defaultLang: defaultLang }) },
188
+ React.createElement(Route, { index: true, element: React.createElement(React.Fragment, null, children) }),
189
+ React.createElement(Route, { path: "*", element: React.createElement(React.Fragment, null, children) })))),
190
+ React.createElement(Route, { path: "*", element: React.createElement(RedirectToDefaultLang, { defaultLang: defaultLang, nonLocalizedPaths: nonLocalizedPaths, routingStatus: routingStatus }, children) })))));
87
191
  }
@@ -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: '5px',
214
- background: '#6BD63D',
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: "10", height: "10", viewBox: "0 0 24 24", fill: "none", "aria-hidden": "true" },
222
- React.createElement("path", { d: "M9 5a2 2 0 0 1 2 2v10h8a2 2 0 1 1 0 4H9a2 2 0 0 1-2-2V7a2 2 0 0 1 2-2Z", fill: "#0D0D0D" }))),
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
  " ",
@@ -0,0 +1,8 @@
1
+ import type { NonLocalizedPathRule } from "../utils/nonLocalizedPaths";
2
+ export type LangRoutingContextValue = {
3
+ defaultLang: string;
4
+ nonLocalizedPaths: NonLocalizedPathRule[];
5
+ inactivePages: string[];
6
+ status: "unknown" | "loading" | "ready" | "error";
7
+ };
8
+ export declare const LangRoutingContext: import("react").Context<LangRoutingContextValue>;
@@ -0,0 +1,7 @@
1
+ import { createContext } from "react";
2
+ export const LangRoutingContext = createContext({
3
+ defaultLang: "",
4
+ nonLocalizedPaths: [],
5
+ inactivePages: [],
6
+ status: "unknown",
7
+ });
@@ -1,11 +1,8 @@
1
1
  import { useNavigate } from 'react-router-dom';
2
- import { useCallback } from 'react';
2
+ import { useCallback, useContext } from 'react';
3
3
  import { useLang } from './useLang';
4
- //Globally excluded paths.
5
- const NON_LOCALIZED_APP_PATHS = new Set([
6
- 'robots.txt',
7
- 'sitemap.xml',
8
- ]);
4
+ import { LangRoutingContext } from '../context/LangRoutingContext';
5
+ import { isNonLocalizedPath } from '../utils/nonLocalizedPaths';
9
6
  /**
10
7
  * useLangNavigate - Get a language-aware navigate function
11
8
  *
@@ -29,12 +26,15 @@ const NON_LOCALIZED_APP_PATHS = new Set([
29
26
  export function useLangNavigate() {
30
27
  const navigate = useNavigate();
31
28
  const lang = useLang();
29
+ const routing = useContext(LangRoutingContext);
32
30
  return useCallback((path, options) => {
33
- const trimmed = path.replace(/^\//, '');
34
- const firstSegment = trimmed.split('/')[0] || '';
35
- const fullPath = NON_LOCALIZED_APP_PATHS.has(firstSegment)
36
- ? `/${trimmed}`
37
- : `/${lang}/${trimmed}`;
31
+ const trimmed = (path || "").toString().trim();
32
+ if (!trimmed)
33
+ return;
34
+ const normalized = trimmed.startsWith("/") ? trimmed : `/${trimmed}`;
35
+ const fullPath = isNonLocalizedPath(normalized, routing.nonLocalizedPaths)
36
+ ? normalized
37
+ : `/${lang}${normalized}`;
38
38
  navigate(fullPath, options);
39
- }, [lang, navigate]);
39
+ }, [lang, navigate, routing.nonLocalizedPaths]);
40
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) 2025 Mertens Advies. All rights reserved.
4
+ * Copyright (c) 2025 Lovalingo Swiss. All rights reserved.
5
5
  *
6
- * This software is the intellectual property of Mertens Advies.
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) 2025 Mertens Advies. All rights reserved.
4
+ * Copyright (c) 2025 Lovalingo Swiss. All rights reserved.
5
5
  *
6
- * This software is the intellectual property of Mertens Advies.
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
@@ -25,6 +25,15 @@ export type BootstrapResponse = {
25
25
  locale?: string;
26
26
  normalized_path?: string;
27
27
  routing_strategy?: string;
28
+ non_localized_paths?: Array<{
29
+ pattern?: string;
30
+ match_type?: string;
31
+ updated_at?: string | null;
32
+ }>;
33
+ inactive_pages?: Array<{
34
+ page_path?: string;
35
+ updated_at?: string | null;
36
+ }>;
28
37
  loading_bg_color?: string | null;
29
38
  branding_enabled?: boolean;
30
39
  seoEnabled?: boolean;
@@ -0,0 +1,12 @@
1
+ export type NonLocalizedPathRule = {
2
+ pattern: string;
3
+ match_type: "exact" | "prefix" | "regex";
4
+ };
5
+ export declare function isGlobalNonLocalizedPath(pathname: string): boolean;
6
+ export declare function matchesNonLocalizedRules(pathname: string, rules: NonLocalizedPathRule[]): boolean;
7
+ export declare function isNonLocalizedPath(pathname: string, rules: NonLocalizedPathRule[]): boolean;
8
+ export declare function stripLocalePrefix(pathname: string, locales: string[]): string;
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;
@@ -0,0 +1,136 @@
1
+ const GLOBAL_NON_LOCALIZED_APP_PATHS = new Set(["/robots.txt", "/sitemap.xml"]);
2
+ export function isGlobalNonLocalizedPath(pathname) {
3
+ const input = (pathname || "").toString();
4
+ if (!input.startsWith("/"))
5
+ return false;
6
+ if (GLOBAL_NON_LOCALIZED_APP_PATHS.has(input))
7
+ return true;
8
+ if (input.startsWith("/.well-known/"))
9
+ return true;
10
+ return /\.(?:png|jpg|jpeg|gif|svg|webp|avif|ico|css|js|map|json|xml|txt|pdf|zip|gz|br|woff2?|ttf|eot)$/i.test(input);
11
+ }
12
+ export function matchesNonLocalizedRules(pathname, rules) {
13
+ const input = (pathname || "").toString();
14
+ if (!input.startsWith("/"))
15
+ return false;
16
+ if (!Array.isArray(rules) || rules.length === 0)
17
+ return false;
18
+ for (const rule of rules) {
19
+ const pattern = typeof rule?.pattern === "string" ? rule.pattern : "";
20
+ const matchType = rule?.match_type;
21
+ if (!pattern)
22
+ continue;
23
+ if (matchType === "exact") {
24
+ if (input === pattern)
25
+ return true;
26
+ continue;
27
+ }
28
+ if (matchType === "prefix") {
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}/`))
35
+ return true;
36
+ continue;
37
+ }
38
+ if (matchType === "regex") {
39
+ try {
40
+ if (new RegExp(pattern).test(input))
41
+ return true;
42
+ }
43
+ catch {
44
+ // ignore invalid regex rules
45
+ }
46
+ }
47
+ }
48
+ return false;
49
+ }
50
+ export function isNonLocalizedPath(pathname, rules) {
51
+ return isGlobalNonLocalizedPath(pathname) || matchesNonLocalizedRules(pathname, rules);
52
+ }
53
+ export function stripLocalePrefix(pathname, locales) {
54
+ const input = (pathname || "").toString();
55
+ if (!input.startsWith("/"))
56
+ return input;
57
+ const parts = input.split("/").filter(Boolean);
58
+ if (parts.length === 0)
59
+ return "/";
60
+ const first = parts[0] || "";
61
+ if (!first || !Array.isArray(locales) || !locales.includes(first))
62
+ return input;
63
+ const rest = `/${parts.slice(1).join("/")}`;
64
+ return rest === "" ? "/" : rest;
65
+ }
66
+ export function parseBootstrapNonLocalizedPaths(value) {
67
+ if (!Array.isArray(value))
68
+ return [];
69
+ const out = [];
70
+ for (const row of value) {
71
+ if (!row || typeof row !== "object")
72
+ continue;
73
+ const record = row;
74
+ const pattern = typeof record.pattern === "string" ? record.pattern.trim() : "";
75
+ const match_type = typeof record.match_type === "string" ? record.match_type.trim() : "";
76
+ if (!pattern)
77
+ continue;
78
+ if (match_type !== "exact" && match_type !== "prefix" && match_type !== "regex")
79
+ continue;
80
+ out.push({ pattern, match_type: match_type });
81
+ }
82
+ return out;
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.3.2";
1
+ export declare const VERSION = "0.5.4";
package/dist/version.js CHANGED
@@ -1 +1 @@
1
- export const VERSION = "0.3.2";
1
+ export const VERSION = "0.5.4";
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@lovalingo/lovalingo",
3
- "version": "0.5.2",
3
+ "version": "0.5.4",
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": "Mertens Advies",
31
+ "author": "Lovalingo Swiss",
32
32
  "license": "UNLICENSED",
33
33
  "private": false,
34
34
  "peerDependencies": {