@lovalingo/lovalingo 0.5.1 → 0.5.3

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
@@ -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"
@@ -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
- import { applyActiveTranslations, restoreDom, setActiveTranslations, setMarkerEngineExclusions, startMarkerEngine } from '../utils/markerEngine';
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,8 +77,12 @@ 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("");
83
+ const lastPageviewFingerprintRef = useRef("");
84
+ const pageviewFingerprintTimeoutRef = useRef(null);
85
+ const pageviewFingerprintRetryTimeoutRef = useRef(null);
80
86
  const lastNormalizedPathRef = useRef("");
81
87
  const historyPatchedRef = useRef(false);
82
88
  const originalHistoryRef = useRef(null);
@@ -159,6 +165,7 @@ navigateRef, // For path mode routing
159
165
  }, [brandingStorageKey]);
160
166
  useEffect(() => {
161
167
  lastPageviewRef.current = "";
168
+ lastPageviewFingerprintRef.current = "";
162
169
  }, [resolvedApiKey]);
163
170
  const trackPageviewOnce = useCallback((path) => {
164
171
  const next = (path || "").toString();
@@ -168,6 +175,28 @@ navigateRef, // For path mode routing
168
175
  return;
169
176
  lastPageviewRef.current = next;
170
177
  apiRef.current.trackPageview(next);
178
+ const trySendFingerprint = () => {
179
+ if (typeof window === "undefined")
180
+ return;
181
+ const markersReady = window.__lovalingoMarkersReady === true;
182
+ if (!markersReady)
183
+ return;
184
+ const fp = getCriticalFingerprint();
185
+ if (!fp || fp.critical_count <= 0)
186
+ return;
187
+ const signature = `${next}|${fp.critical_hash}|${fp.critical_count}`;
188
+ if (lastPageviewFingerprintRef.current === signature)
189
+ return;
190
+ lastPageviewFingerprintRef.current = signature;
191
+ apiRef.current.trackPageview(next, fp);
192
+ };
193
+ if (pageviewFingerprintTimeoutRef.current != null)
194
+ window.clearTimeout(pageviewFingerprintTimeoutRef.current);
195
+ if (pageviewFingerprintRetryTimeoutRef.current != null)
196
+ window.clearTimeout(pageviewFingerprintRetryTimeoutRef.current);
197
+ // Why: wait briefly for markers/content to settle before computing a critical fingerprint for change detection.
198
+ pageviewFingerprintTimeoutRef.current = window.setTimeout(trySendFingerprint, 800);
199
+ pageviewFingerprintRetryTimeoutRef.current = window.setTimeout(trySendFingerprint, 2000);
171
200
  }, []);
172
201
  const enablePrehide = useCallback((bgColor) => {
173
202
  if (typeof document === "undefined")
@@ -528,6 +557,17 @@ navigateRef, // For path mode routing
528
557
  isNavigatingRef.current = false;
529
558
  return;
530
559
  }
560
+ if (routing === "path") {
561
+ const stripped = stripLocalePrefix(window.location.pathname, allLocales);
562
+ if (isNonLocalizedPath(stripped, routingConfig.nonLocalizedPaths)) {
563
+ // Why: auth/admin (non-localized) routes must never be blocked or mutated by the translation runtime.
564
+ disablePrehide();
565
+ setActiveTranslations(null);
566
+ restoreDom(document.body);
567
+ isNavigatingRef.current = false;
568
+ return;
569
+ }
570
+ }
531
571
  const currentPath = window.location.pathname + window.location.search;
532
572
  const normalizedPath = processPath(window.location.pathname, enhancedPathConfig);
533
573
  const cacheKey = `${targetLocale}:${normalizedPath}`;
@@ -811,6 +851,13 @@ navigateRef, // For path mode routing
811
851
  isNavigatingRef.current = true;
812
852
  // Update URL based on routing strategy
813
853
  if (routing === 'path') {
854
+ const stripped = stripLocalePrefix(window.location.pathname, allLocales);
855
+ if (isNonLocalizedPath(stripped, routingConfig.nonLocalizedPaths)) {
856
+ // Why: switching languages must not rewrite non-localized routes like "/auth" to "/de/auth".
857
+ setLocaleState(newLocale);
858
+ isNavigatingRef.current = false;
859
+ return;
860
+ }
814
861
  const pathParts = window.location.pathname.split('/').filter(Boolean);
815
862
  // Strip existing locale
816
863
  if (allLocales.includes(pathParts[0])) {
@@ -843,7 +890,7 @@ navigateRef, // For path mode routing
843
890
  })().finally(() => {
844
891
  isInternalNavigationRef.current = false;
845
892
  });
846
- }, [allLocales, locale, routing, loadData, navigateRef]);
893
+ }, [allLocales, locale, routing, loadData, navigateRef, routingConfig.nonLocalizedPaths]);
847
894
  // No string-level "misses" from the client: the pipeline (Browser Rendering) is source-of-truth for string discovery.
848
895
  // Initialize
849
896
  useEffect(() => {
@@ -903,13 +950,6 @@ navigateRef, // For path mode routing
903
950
  if (!autoPrefixLinks)
904
951
  return;
905
952
  const supportedLocales = allLocales;
906
- const isAssetPath = (pathname) => {
907
- if (pathname === '/robots.txt' || pathname === '/sitemap.xml')
908
- return true;
909
- if (pathname.startsWith('/.well-known/'))
910
- return true;
911
- 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);
912
- };
913
953
  const shouldProcessCurrentPath = () => {
914
954
  const parts = window.location.pathname.split('/').filter(Boolean);
915
955
  return parts.length > 0 && supportedLocales.includes(parts[0]);
@@ -937,7 +977,7 @@ navigateRef, // For path mode routing
937
977
  }
938
978
  if (url.origin !== window.location.origin)
939
979
  return null;
940
- if (isAssetPath(url.pathname))
980
+ if (isNonLocalizedPath(url.pathname, routingConfig.nonLocalizedPaths))
941
981
  return null;
942
982
  const parts = url.pathname.split('/').filter(Boolean);
943
983
  // Root ("/") should be locale-prefixed too (e.g. clicking a logo linking to "https://example.com")
@@ -1044,7 +1084,7 @@ navigateRef, // For path mode routing
1044
1084
  mo.disconnect();
1045
1085
  document.removeEventListener('click', onClickCapture, true);
1046
1086
  };
1047
- }, [routing, autoPrefixLinks, allLocales, locale, navigateRef]);
1087
+ }, [routing, autoPrefixLinks, allLocales, locale, navigateRef, routingConfig.nonLocalizedPaths]);
1048
1088
  // Navigation prefetch: warm the HTTP cache for bootstrap + page bundle before the user clicks (reduces EN→FR flash on SPA route changes).
1049
1089
  useEffect(() => {
1050
1090
  if (!resolvedApiKey)
@@ -1156,9 +1196,16 @@ navigateRef, // For path mode routing
1156
1196
  };
1157
1197
  return (React.createElement(LovalingoContext.Provider, { value: contextValue },
1158
1198
  children,
1159
- React.createElement(LanguageSwitcher, { locales: allLocales, currentLocale: locale, onLocaleChange: setLocale, position: switcherPosition, offsetY: switcherOffsetY, theme: switcherTheme, branding: {
1199
+ (() => {
1200
+ if (routing !== "path")
1201
+ return true;
1202
+ if (typeof window === "undefined")
1203
+ return true;
1204
+ const stripped = stripLocalePrefix(window.location.pathname, allLocales);
1205
+ return !isNonLocalizedPath(stripped, routingConfig.nonLocalizedPaths);
1206
+ })() && (React.createElement(LanguageSwitcher, { locales: allLocales, currentLocale: locale, onLocaleChange: setLocale, position: switcherPosition, offsetY: switcherOffsetY, theme: switcherTheme, branding: {
1160
1207
  required: Boolean(entitlements?.brandingRequired),
1161
1208
  enabled: brandingEnabled,
1162
1209
  href: "https://lovalingo.com",
1163
- } })));
1210
+ } }))));
1164
1211
  };
@@ -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,13 @@ 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) ? normalized : `/${lang}${normalized}`;
33
33
  })()
34
34
  : to;
35
35
  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';
2
- import { BrowserRouter, Routes, Route, Navigate, Outlet, useLocation, useNavigate } from 'react-router-dom';
1
+ import React, { useCallback, useEffect, useMemo, useState } from 'react';
2
+ import { BrowserRouter, Routes, Route, 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, 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,29 +19,42 @@ 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, }) {
31
23
  const location = useLocation();
24
+ const navigate = useNavigate();
32
25
  // If the URL is language-prefixed but the underlying route is non-localized (e.g. robots/sitemap),
33
26
  // redirect to the canonical non-localized path.
34
27
  const prefix = `/${lang}`;
35
28
  const restPath = location.pathname.startsWith(prefix) ? location.pathname.slice(prefix.length) || '/' : location.pathname;
36
- if (isNonLocalizedPath(restPath)) {
29
+ const shouldDePrefix = isNonLocalizedPath(restPath, nonLocalizedPaths);
30
+ useEffect(() => {
31
+ if (!shouldDePrefix)
32
+ return;
37
33
  const nextPath = `${restPath}${location.search}${location.hash}`;
38
- return React.createElement(Navigate, { to: nextPath, replace: true });
39
- }
34
+ navigate(nextPath, { replace: true });
35
+ }, [location.hash, location.search, navigate, restPath, shouldDePrefix]);
40
36
  // Valid language - render children (user's routes)
41
37
  return (React.createElement(LangContext.Provider, { value: lang },
42
38
  React.createElement(Outlet, { context: { lang } })));
43
39
  }
44
- function RedirectToDefaultLang({ defaultLang, children }) {
40
+ function RedirectToDefaultLang({ defaultLang, children, nonLocalizedPaths, routingStatus, }) {
45
41
  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 });
42
+ const navigate = useNavigate();
43
+ const shouldSkip = isNonLocalizedPath(location.pathname, nonLocalizedPaths);
44
+ useEffect(() => {
45
+ if (shouldSkip)
46
+ return;
47
+ if (routingStatus === "loading")
48
+ return;
49
+ const nextPath = location.pathname === "/" || location.pathname === ""
50
+ ? `/${defaultLang}${location.search}${location.hash}`
51
+ : `/${defaultLang}${location.pathname}${location.search}${location.hash}`;
52
+ const current = `${location.pathname}${location.search}${location.hash}`;
53
+ if (nextPath === current)
54
+ return;
55
+ navigate(nextPath, { replace: true });
56
+ }, [defaultLang, location.hash, location.pathname, location.search, navigate, routingStatus, shouldSkip]);
57
+ return React.createElement(React.Fragment, null, children);
53
58
  }
54
59
  /**
55
60
  * LangRouter - Drop-in replacement for BrowserRouter that automatically handles language routing
@@ -76,12 +81,92 @@ function RedirectToDefaultLang({ defaultLang, children }) {
76
81
  * - /fr/pricing
77
82
  * - etc.
78
83
  */
79
- export function LangRouter({ children, defaultLang, langs, navigateRef }) {
84
+ export function LangRouter({ children, defaultLang, langs, navigateRef, apiBase, apiKey, publicAnonKey }) {
85
+ const metaKey = typeof document !== "undefined"
86
+ ? document.querySelector('meta[name="lovalingo-public-anon-key"]')?.content?.trim() || ""
87
+ : "";
88
+ const globals = globalThis;
89
+ const resolvedApiKey = (typeof apiKey === "string" && apiKey.trim().length > 0
90
+ ? apiKey
91
+ : typeof publicAnonKey === "string" && publicAnonKey.trim().length > 0
92
+ ? publicAnonKey
93
+ : globals.__LOVALINGO_PUBLIC_ANON_KEY__ || globals.__LOVALINGO_API_KEY__ || metaKey || "").trim();
94
+ const resolvedApiBase = (typeof apiBase === "string" && apiBase.trim().length > 0
95
+ ? apiBase.trim()
96
+ : "https://cdn.lovalingo.com");
97
+ const nonLocalizedStorageKey = useMemo(() => `Lovalingo_non_localized_paths:${resolvedApiKey || "anonymous"}`, [resolvedApiKey]);
98
+ const [nonLocalizedPaths, setNonLocalizedPaths] = useState(() => {
99
+ if (typeof window === "undefined")
100
+ return [];
101
+ if (!resolvedApiKey)
102
+ return [];
103
+ try {
104
+ const raw = localStorage.getItem(nonLocalizedStorageKey);
105
+ if (!raw)
106
+ return [];
107
+ const parsed = JSON.parse(raw);
108
+ return parseBootstrapNonLocalizedPaths(parsed);
109
+ }
110
+ catch {
111
+ return [];
112
+ }
113
+ });
114
+ const [routingStatus, setRoutingStatus] = useState(() => {
115
+ if (!resolvedApiKey)
116
+ return "unknown";
117
+ return nonLocalizedPaths.length > 0 ? "ready" : "loading";
118
+ });
119
+ const fetchNonLocalizedPaths = useCallback(async () => {
120
+ if (typeof window === "undefined")
121
+ return;
122
+ if (!resolvedApiKey)
123
+ return;
124
+ const pathParam = window.location.pathname + window.location.search;
125
+ const requestUrl = `${resolvedApiBase}/functions/v1/bootstrap?key=${encodeURIComponent(resolvedApiKey)}&locale=${encodeURIComponent(defaultLang)}&path=${encodeURIComponent(pathParam)}`;
126
+ const response = await fetch(requestUrl);
127
+ const resolvedResponse = response.status === 304 ? await fetch(requestUrl, { cache: "force-cache" }) : response;
128
+ if (!resolvedResponse.ok)
129
+ throw new Error(`bootstrap HTTP ${resolvedResponse.status}`);
130
+ const data = (await resolvedResponse.json());
131
+ const record = (data || {});
132
+ return parseBootstrapNonLocalizedPaths(record["non_localized_paths"]);
133
+ }, [defaultLang, resolvedApiBase, resolvedApiKey]);
134
+ useEffect(() => {
135
+ let cancelled = false;
136
+ void (async () => {
137
+ if (!resolvedApiKey)
138
+ return;
139
+ setRoutingStatus((prev) => (prev === "ready" ? prev : "loading"));
140
+ try {
141
+ const next = await fetchNonLocalizedPaths();
142
+ if (cancelled || !next)
143
+ return;
144
+ setNonLocalizedPaths(next);
145
+ setRoutingStatus("ready");
146
+ try {
147
+ localStorage.setItem(nonLocalizedStorageKey, JSON.stringify(next));
148
+ }
149
+ catch {
150
+ // ignore
151
+ }
152
+ }
153
+ catch (err) {
154
+ if (cancelled)
155
+ return;
156
+ setRoutingStatus("error");
157
+ logDebug("[Lovalingo] Failed to fetch non-localized paths:", err);
158
+ }
159
+ })();
160
+ return () => {
161
+ cancelled = true;
162
+ };
163
+ }, [fetchNonLocalizedPaths, nonLocalizedStorageKey, resolvedApiKey]);
80
164
  return (React.createElement(BrowserRouter, null,
81
165
  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) }))));
166
+ React.createElement(LangRoutingContext.Provider, { value: { nonLocalizedPaths, status: routingStatus } },
167
+ React.createElement(Routes, null,
168
+ langs.map((lang) => (React.createElement(Route, { key: lang, path: `${lang}/*`, element: React.createElement(LangGuard, { lang: lang, nonLocalizedPaths: nonLocalizedPaths }) },
169
+ React.createElement(Route, { index: true, element: React.createElement(React.Fragment, null, children) }),
170
+ React.createElement(Route, { path: "*", element: React.createElement(React.Fragment, null, children) })))),
171
+ React.createElement(Route, { path: "*", element: React.createElement(RedirectToDefaultLang, { defaultLang: defaultLang, nonLocalizedPaths: nonLocalizedPaths, routingStatus: routingStatus }, children) })))));
87
172
  }
@@ -0,0 +1,6 @@
1
+ import type { NonLocalizedPathRule } from "../utils/nonLocalizedPaths";
2
+ export type LangRoutingContextValue = {
3
+ nonLocalizedPaths: NonLocalizedPathRule[];
4
+ status: "unknown" | "loading" | "ready" | "error";
5
+ };
6
+ export declare const LangRoutingContext: import("react").Context<LangRoutingContextValue>;
@@ -0,0 +1,5 @@
1
+ import { createContext } from "react";
2
+ export const LangRoutingContext = createContext({
3
+ nonLocalizedPaths: [],
4
+ status: "unknown",
5
+ });
@@ -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,13 @@ 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) ? normalized : `/${lang}${normalized}`;
38
36
  navigate(fullPath, options);
39
- }, [lang, navigate]);
37
+ }, [lang, navigate, routing.nonLocalizedPaths]);
40
38
  }
@@ -25,6 +25,11 @@ 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
+ }>;
28
33
  loading_bg_color?: string | null;
29
34
  branding_enabled?: boolean;
30
35
  seoEnabled?: boolean;
@@ -65,7 +70,10 @@ export declare class LovalingoAPI {
65
70
  getEntitlements(): ProjectEntitlements | null;
66
71
  fetchEntitlements(localeHint: string): Promise<ProjectEntitlements | null>;
67
72
  fetchSeoBundle(localeHint: string): Promise<SeoBundleResponse | null>;
68
- trackPageview(pathOrUrl: string): Promise<void>;
73
+ trackPageview(pathOrUrl: string, opts?: {
74
+ critical_count?: number;
75
+ critical_hash?: string;
76
+ }): Promise<void>;
69
77
  fetchTranslations(sourceLocale: string, targetLocale: string): Promise<Translation[]>;
70
78
  fetchBundle(localeHint: string, pathOrUrl?: string): Promise<{
71
79
  map: Record<string, string>;
package/dist/utils/api.js CHANGED
@@ -117,11 +117,23 @@ export class LovalingoAPI {
117
117
  return null;
118
118
  }
119
119
  }
120
- async trackPageview(pathOrUrl) {
120
+ async trackPageview(pathOrUrl, opts) {
121
121
  try {
122
122
  if (!this.hasApiKey())
123
123
  return;
124
- const response = await fetch(`${this.apiBase}/functions/v1/pageview?key=${encodeURIComponent(this.apiKey)}&path=${encodeURIComponent(pathOrUrl)}`, { method: "GET", keepalive: true });
124
+ const params = new URLSearchParams();
125
+ params.set("key", this.apiKey);
126
+ params.set("path", pathOrUrl);
127
+ const count = opts?.critical_count;
128
+ const hash = (opts?.critical_hash || "").toString().trim().toLowerCase();
129
+ if (typeof count === "number" && Number.isFinite(count) && count > 0 && count <= 5000 && /^[a-z0-9]{1,40}$/.test(hash)) {
130
+ params.set("critical_count", String(Math.floor(count)));
131
+ params.set("critical_hash", hash);
132
+ }
133
+ const response = await fetch(`${this.apiBase}/functions/v1/pageview?${params.toString()}`, {
134
+ method: "GET",
135
+ keepalive: true,
136
+ });
125
137
  if (response.status === 403) {
126
138
  // Tracking should never block app behavior; keep logging consistent.
127
139
  this.logActivationRequired("trackPageview", response);
@@ -39,6 +39,15 @@ export type DomScanResult = {
39
39
  };
40
40
  truncated: boolean;
41
41
  };
42
+ export type CriticalFingerprint = {
43
+ critical_count: number;
44
+ critical_hash: string;
45
+ viewport: {
46
+ width: number;
47
+ height: number;
48
+ };
49
+ };
50
+ export declare function getCriticalFingerprint(): CriticalFingerprint;
42
51
  export declare function startMarkerEngine(options?: MarkerEngineOptions): typeof stopMarkerEngine;
43
52
  export declare function stopMarkerEngine(): void;
44
53
  export declare function getMarkerStats(): MarkerStats;
@@ -50,6 +50,7 @@ function setGlobalStats(stats) {
50
50
  g.__lovalingo.dom = {};
51
51
  g.__lovalingo.dom.getStats = () => lastStats;
52
52
  g.__lovalingo.dom.scan = () => scanDom({ maxSegments: 20000, includeCritical: true });
53
+ g.__lovalingo.dom.getCriticalFingerprint = () => getCriticalFingerprint();
53
54
  g.__lovalingo.dom.apply = (bundle) => ({ applied: applyTranslationMap(bundle, document.body) });
54
55
  g.__lovalingo.dom.restore = () => restoreDom(document.body);
55
56
  }
@@ -314,6 +315,98 @@ function finalizeStats(stats) {
314
315
  stats.coverageRatio = eligibleNodes > 0 ? stats.markedNodes / eligibleNodes : 1;
315
316
  stats.coverageRatioChars = eligibleChars > 0 ? stats.markedChars / eligibleChars : 1;
316
317
  }
318
+ function scanCriticalTexts() {
319
+ const root = document.body;
320
+ const viewportHeight = Math.max(0, Math.floor(window.innerHeight || 0));
321
+ const viewportWidth = Math.max(0, Math.floor(window.innerWidth || 0));
322
+ const viewport = { width: viewportWidth, height: viewportHeight };
323
+ if (!root || viewportHeight <= 0)
324
+ return { texts: [], viewport };
325
+ const seen = new Set();
326
+ const texts = [];
327
+ const walker = document.createTreeWalker(root, NodeFilter.SHOW_TEXT);
328
+ let node = walker.nextNode();
329
+ while (node && texts.length < DEFAULT_CRITICAL_MAX) {
330
+ if (node.nodeType !== Node.TEXT_NODE) {
331
+ node = walker.nextNode();
332
+ continue;
333
+ }
334
+ const textNode = node;
335
+ const raw = textNode.nodeValue || "";
336
+ const trimmed = raw.trim();
337
+ if (!trimmed || !isTranslatableText(trimmed)) {
338
+ node = walker.nextNode();
339
+ continue;
340
+ }
341
+ const parent = textNode.parentElement;
342
+ if (!parent || isExcludedElement(parent) || findUnsafeContainer(parent)) {
343
+ node = walker.nextNode();
344
+ continue;
345
+ }
346
+ const original = getOrInitTextOriginal(textNode, parent);
347
+ const originalText = normalizeWhitespace(original.trimmed);
348
+ if (!originalText || seen.has(originalText)) {
349
+ node = walker.nextNode();
350
+ continue;
351
+ }
352
+ const rect = getTextNodeRect(textNode);
353
+ if (!isInViewport(rect, viewportHeight, DEFAULT_CRITICAL_BUFFER_PX)) {
354
+ node = walker.nextNode();
355
+ continue;
356
+ }
357
+ seen.add(originalText);
358
+ texts.push(originalText);
359
+ node = walker.nextNode();
360
+ }
361
+ if (texts.length < DEFAULT_CRITICAL_MAX) {
362
+ const nodes = root.querySelectorAll("[title],[aria-label],[placeholder]");
363
+ nodes.forEach((el) => {
364
+ if (texts.length >= DEFAULT_CRITICAL_MAX)
365
+ return;
366
+ if (isExcludedElement(el) || findUnsafeContainer(el))
367
+ return;
368
+ let rect = null;
369
+ try {
370
+ rect = el.getBoundingClientRect();
371
+ }
372
+ catch {
373
+ rect = null;
374
+ }
375
+ if (!isInViewport(rect, viewportHeight, DEFAULT_CRITICAL_BUFFER_PX))
376
+ return;
377
+ for (const { attr } of ATTRIBUTE_MARKS) {
378
+ if (texts.length >= DEFAULT_CRITICAL_MAX)
379
+ break;
380
+ const value = el.getAttribute(attr);
381
+ if (!value)
382
+ continue;
383
+ const trimmed = value.trim();
384
+ if (!trimmed || !isTranslatableText(trimmed))
385
+ continue;
386
+ const original = normalizeWhitespace(getOrInitAttrOriginal(el, attr));
387
+ if (!original || seen.has(original))
388
+ continue;
389
+ seen.add(original);
390
+ texts.push(original);
391
+ }
392
+ });
393
+ }
394
+ return { texts, viewport };
395
+ }
396
+ export function getCriticalFingerprint() {
397
+ if (typeof window === "undefined" || typeof document === "undefined") {
398
+ return { critical_count: 0, critical_hash: "0", viewport: { width: 0, height: 0 } };
399
+ }
400
+ const { texts, viewport } = scanCriticalTexts();
401
+ const normalized = texts.map((t) => normalizeWhitespace(t)).filter(Boolean);
402
+ // Why: sort to stay stable across minor DOM reordering without affecting the set of critical strings.
403
+ normalized.sort((a, b) => a.localeCompare(b));
404
+ return {
405
+ critical_count: normalized.length,
406
+ critical_hash: hashContent(normalized.join("\n")),
407
+ viewport,
408
+ };
409
+ }
317
410
  function scanDom(opts) {
318
411
  const root = document.body;
319
412
  if (!root) {
@@ -0,0 +1,9 @@
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[];
@@ -0,0 +1,78 @@
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.startsWith(pattern))
30
+ return true;
31
+ continue;
32
+ }
33
+ if (matchType === "regex") {
34
+ try {
35
+ if (new RegExp(pattern).test(input))
36
+ return true;
37
+ }
38
+ catch {
39
+ // ignore invalid regex rules
40
+ }
41
+ }
42
+ }
43
+ return false;
44
+ }
45
+ export function isNonLocalizedPath(pathname, rules) {
46
+ return isGlobalNonLocalizedPath(pathname) || matchesNonLocalizedRules(pathname, rules);
47
+ }
48
+ export function stripLocalePrefix(pathname, locales) {
49
+ const input = (pathname || "").toString();
50
+ if (!input.startsWith("/"))
51
+ return input;
52
+ const parts = input.split("/").filter(Boolean);
53
+ if (parts.length === 0)
54
+ return "/";
55
+ const first = parts[0] || "";
56
+ if (!first || !Array.isArray(locales) || !locales.includes(first))
57
+ return input;
58
+ const rest = `/${parts.slice(1).join("/")}`;
59
+ return rest === "" ? "/" : rest;
60
+ }
61
+ export function parseBootstrapNonLocalizedPaths(value) {
62
+ if (!Array.isArray(value))
63
+ return [];
64
+ const out = [];
65
+ for (const row of value) {
66
+ if (!row || typeof row !== "object")
67
+ continue;
68
+ const record = row;
69
+ const pattern = typeof record.pattern === "string" ? record.pattern.trim() : "";
70
+ const match_type = typeof record.match_type === "string" ? record.match_type.trim() : "";
71
+ if (!pattern)
72
+ continue;
73
+ if (match_type !== "exact" && match_type !== "prefix" && match_type !== "regex")
74
+ continue;
75
+ out.push({ pattern, match_type: match_type });
76
+ }
77
+ return out;
78
+ }
package/dist/version.d.ts CHANGED
@@ -1 +1 @@
1
- export declare const VERSION = "0.3.2";
1
+ export declare const VERSION = "0.5.3";
package/dist/version.js CHANGED
@@ -1 +1 @@
1
- export const VERSION = "0.3.2";
1
+ export const VERSION = "0.5.3";
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@lovalingo/lovalingo",
3
- "version": "0.5.1",
3
+ "version": "0.5.3",
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",