@lovalingo/lovalingo 0.5.5 → 0.5.7

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.
@@ -0,0 +1,32 @@
1
+ import type React from "react";
2
+ import type { LovalingoAPI, ProjectEntitlements } from "../../utils/api";
3
+ import type { NonLocalizedPathRule } from "../../utils/nonLocalizedPaths";
4
+ import type { PathNormalizationConfig } from "../../utils/pathNormalizer";
5
+ type UseBundleLoadingOptions = {
6
+ apiRef: React.MutableRefObject<LovalingoAPI>;
7
+ resolvedApiKey: string;
8
+ defaultLocale: string;
9
+ routing: "path" | "query";
10
+ allLocales: string[];
11
+ nonLocalizedPaths: NonLocalizedPathRule[];
12
+ enhancedPathConfig: PathNormalizationConfig;
13
+ mode: "dom" | undefined;
14
+ autoApplyRules: boolean;
15
+ isSeoActive: () => boolean;
16
+ applySeoBundle: (bundle: {
17
+ seo?: Record<string, unknown>;
18
+ alternates?: any;
19
+ jsonld?: any;
20
+ } | null, hreflangEnabled: boolean) => void;
21
+ setEntitlements: React.Dispatch<React.SetStateAction<ProjectEntitlements | null>>;
22
+ setBrandingEnabled: React.Dispatch<React.SetStateAction<boolean>>;
23
+ setCachedBrandingEnabled: (enabled: boolean | null | undefined) => void;
24
+ setCachedLoadingBgColor: (color: string | null | undefined) => void;
25
+ getCachedLoadingBgColor: () => string;
26
+ };
27
+ export declare function useBundleLoading({ apiRef, resolvedApiKey, defaultLocale, routing, allLocales, nonLocalizedPaths, enhancedPathConfig, mode, autoApplyRules, isSeoActive, applySeoBundle, setEntitlements, setBrandingEnabled, setCachedBrandingEnabled, setCachedLoadingBgColor, getCachedLoadingBgColor, }: UseBundleLoadingOptions): {
28
+ isLoading: boolean;
29
+ isNavigatingRef: React.MutableRefObject<boolean>;
30
+ loadData: (targetLocale: string, previousLocale?: string) => Promise<void>;
31
+ };
32
+ export {};
@@ -0,0 +1,354 @@
1
+ import { useCallback, useEffect, useRef, useState } from "react";
2
+ import { hashContent } from "../../utils/hash";
3
+ import { errorDebug, logDebug } from "../../utils/logger";
4
+ import { isNonLocalizedPath, stripLocalePrefix } from "../../utils/nonLocalizedPaths";
5
+ import { processPath } from "../../utils/pathNormalizer";
6
+ import { applyActiveTranslations, restoreDom, setActiveTranslations, setMarkerEngineExclusions } from "../../utils/markerEngine";
7
+ import { useDomRules } from "./useDomRules";
8
+ import { PREHIDE_FAILSAFE_MS, usePrehide } from "./usePrehide";
9
+ const CRITICAL_CACHE_PREFIX = "Lovalingo_critical_v0_3";
10
+ export function useBundleLoading({ apiRef, resolvedApiKey, defaultLocale, routing, allLocales, nonLocalizedPaths, enhancedPathConfig, mode, autoApplyRules, isSeoActive, applySeoBundle, setEntitlements, setBrandingEnabled, setCachedBrandingEnabled, setCachedLoadingBgColor, getCachedLoadingBgColor, }) {
11
+ const [isLoading, setIsLoading] = useState(false);
12
+ const retryTimeoutRef = useRef(null);
13
+ const loadingFailsafeTimeoutRef = useRef(null);
14
+ const isNavigatingRef = useRef(false);
15
+ const inFlightLoadKeyRef = useRef(null);
16
+ const translationCacheRef = useRef(new Map());
17
+ const exclusionsCacheRef = useRef(null);
18
+ const { enablePrehide, disablePrehide } = usePrehide();
19
+ const { applyCachedDomRules, fetchAndApplyDomRules, getCachedDomRules, setAndApplyDomRules } = useDomRules({
20
+ apiRef,
21
+ autoApplyRules,
22
+ });
23
+ const buildCriticalCacheKey = useCallback((targetLocale, normalizedPath) => {
24
+ const key = `${resolvedApiKey || "anonymous"}:${targetLocale}:${normalizedPath || "/"}`;
25
+ return `${CRITICAL_CACHE_PREFIX}:${hashContent(key)}`;
26
+ }, [resolvedApiKey]);
27
+ const readCriticalCache = useCallback((targetLocale, normalizedPath) => {
28
+ const key = buildCriticalCacheKey(targetLocale, normalizedPath);
29
+ try {
30
+ const raw = localStorage.getItem(key);
31
+ if (!raw)
32
+ return null;
33
+ const parsed = JSON.parse(raw);
34
+ if (!parsed || typeof parsed !== "object")
35
+ return null;
36
+ const record = parsed;
37
+ const map = record.map && typeof record.map === "object" && !Array.isArray(record.map) ? record.map : null;
38
+ const exclusionsRaw = Array.isArray(record.exclusions) ? record.exclusions : [];
39
+ const exclusions = exclusionsRaw
40
+ .map((row) => {
41
+ if (!row || typeof row !== "object")
42
+ return null;
43
+ const r = row;
44
+ const selector = typeof r.selector === "string" ? r.selector.trim() : "";
45
+ const type = typeof r.type === "string" ? r.type.trim() : "";
46
+ if (!selector)
47
+ return null;
48
+ if (type !== "css" && type !== "xpath")
49
+ return null;
50
+ return { selector, type: type };
51
+ })
52
+ .filter(Boolean);
53
+ const bg = typeof record.loading_bg_color === "string" ? record.loading_bg_color.trim() : "";
54
+ return {
55
+ map: map || {},
56
+ exclusions,
57
+ loading_bg_color: /^#[0-9a-fA-F]{6}$/.test(bg) ? bg : null,
58
+ };
59
+ }
60
+ catch {
61
+ return null;
62
+ }
63
+ }, [buildCriticalCacheKey]);
64
+ const writeCriticalCache = useCallback((targetLocale, normalizedPath, entry) => {
65
+ const key = buildCriticalCacheKey(targetLocale, normalizedPath);
66
+ try {
67
+ localStorage.setItem(key, JSON.stringify({
68
+ stored_at: Date.now(),
69
+ map: entry.map || {},
70
+ exclusions: entry.exclusions || [],
71
+ loading_bg_color: entry.loading_bg_color,
72
+ }));
73
+ }
74
+ catch {
75
+ // ignore
76
+ }
77
+ }, [buildCriticalCacheKey]);
78
+ const toTranslations = useCallback((map, targetLocale) => {
79
+ const out = [];
80
+ for (const [source_text, translated_text] of Object.entries(map || {})) {
81
+ if (!source_text || !translated_text)
82
+ continue;
83
+ out.push({
84
+ source_text,
85
+ translated_text,
86
+ source_locale: defaultLocale,
87
+ target_locale: targetLocale,
88
+ });
89
+ }
90
+ return out;
91
+ }, [defaultLocale]);
92
+ const loadData = useCallback(async (targetLocale, previousLocale) => {
93
+ // Cancel any pending retry scan to prevent race conditions
94
+ if (retryTimeoutRef.current) {
95
+ clearTimeout(retryTimeoutRef.current);
96
+ retryTimeoutRef.current = null;
97
+ }
98
+ if (loadingFailsafeTimeoutRef.current != null) {
99
+ window.clearTimeout(loadingFailsafeTimeoutRef.current);
100
+ loadingFailsafeTimeoutRef.current = null;
101
+ }
102
+ // If switching to default locale, clear translations and translate with empty map
103
+ // This will show original text using stored data-Lovalingo-original-html
104
+ if (targetLocale === defaultLocale) {
105
+ disablePrehide();
106
+ setActiveTranslations(null);
107
+ restoreDom(document.body); // React-safe: only text/attrs, no DOM structure mutation
108
+ isNavigatingRef.current = false;
109
+ return;
110
+ }
111
+ if (routing === "path") {
112
+ const stripped = stripLocalePrefix(window.location.pathname, allLocales);
113
+ if (isNonLocalizedPath(stripped, nonLocalizedPaths)) {
114
+ // Why: auth/admin (non-localized) routes must never be blocked or mutated by the translation runtime.
115
+ disablePrehide();
116
+ setActiveTranslations(null);
117
+ restoreDom(document.body);
118
+ isNavigatingRef.current = false;
119
+ return;
120
+ }
121
+ }
122
+ const currentPath = window.location.pathname + window.location.search;
123
+ const normalizedPath = processPath(window.location.pathname, enhancedPathConfig);
124
+ const cacheKey = `${targetLocale}:${normalizedPath}`;
125
+ if (inFlightLoadKeyRef.current === cacheKey) {
126
+ return;
127
+ }
128
+ inFlightLoadKeyRef.current = cacheKey;
129
+ // Check if we have cached translations for this locale + path
130
+ const cachedEntry = translationCacheRef.current.get(cacheKey);
131
+ const cachedExclusions = exclusionsCacheRef.current;
132
+ const cachedDomRules = getCachedDomRules(cacheKey);
133
+ if (cachedEntry && cachedExclusions) {
134
+ // CACHE HIT - Use cached data immediately (FAST!)
135
+ logDebug(`[Lovalingo] Using cached translations for ${targetLocale} on ${normalizedPath}`);
136
+ enablePrehide(getCachedLoadingBgColor());
137
+ setActiveTranslations(cachedEntry.translations);
138
+ setMarkerEngineExclusions(cachedExclusions);
139
+ if (mode === "dom") {
140
+ applyActiveTranslations(document.body);
141
+ }
142
+ if (autoApplyRules) {
143
+ applyCachedDomRules(cacheKey, cachedDomRules);
144
+ void fetchAndApplyDomRules(cacheKey, targetLocale);
145
+ }
146
+ // Delayed retry scan to catch late-rendering content
147
+ retryTimeoutRef.current = setTimeout(() => {
148
+ // Don't scan if we're navigating (prevents React conflicts)
149
+ if (isNavigatingRef.current) {
150
+ return;
151
+ }
152
+ logDebug(`[Lovalingo] 🔄 Retry scan for late-rendering content`);
153
+ if (mode === "dom") {
154
+ applyActiveTranslations(document.body);
155
+ }
156
+ if (autoApplyRules) {
157
+ applyCachedDomRules(cacheKey, cachedDomRules);
158
+ }
159
+ }, 500);
160
+ disablePrehide();
161
+ isNavigatingRef.current = false;
162
+ if (inFlightLoadKeyRef.current === cacheKey) {
163
+ inFlightLoadKeyRef.current = null;
164
+ }
165
+ return;
166
+ }
167
+ // CACHE MISS - Fetch from API
168
+ logDebug(`[Lovalingo] Fetching translations for ${targetLocale} on ${normalizedPath}`);
169
+ setIsLoading(true);
170
+ enablePrehide(getCachedLoadingBgColor());
171
+ // Why: never keep the app hidden/blocked for longer than the UX budget; show the original content if translations aren't ready fast.
172
+ loadingFailsafeTimeoutRef.current = window.setTimeout(() => {
173
+ disablePrehide();
174
+ setIsLoading(false);
175
+ }, PREHIDE_FAILSAFE_MS);
176
+ try {
177
+ if (previousLocale && previousLocale !== defaultLocale) {
178
+ logDebug(`[Lovalingo] Switching from ${previousLocale} to ${targetLocale}`);
179
+ }
180
+ let revealedViaCachedCritical = false;
181
+ const cachedCritical = readCriticalCache(targetLocale, normalizedPath);
182
+ if (cachedCritical?.loading_bg_color) {
183
+ setCachedLoadingBgColor(cachedCritical.loading_bg_color);
184
+ enablePrehide(cachedCritical.loading_bg_color);
185
+ }
186
+ if (cachedCritical?.exclusions && cachedCritical.exclusions.length > 0) {
187
+ exclusionsCacheRef.current = cachedCritical.exclusions;
188
+ setMarkerEngineExclusions(cachedCritical.exclusions);
189
+ }
190
+ if (cachedCritical?.map && Object.keys(cachedCritical.map).length > 0) {
191
+ setActiveTranslations(toTranslations(cachedCritical.map, targetLocale));
192
+ if (mode === "dom") {
193
+ applyActiveTranslations(document.body);
194
+ }
195
+ disablePrehide();
196
+ revealedViaCachedCritical = true;
197
+ }
198
+ const bootstrap = await apiRef.current.fetchBootstrap(targetLocale, currentPath);
199
+ const nextEntitlements = bootstrap?.entitlements || apiRef.current.getEntitlements();
200
+ if (nextEntitlements)
201
+ setEntitlements(nextEntitlements);
202
+ if (bootstrap?.loading_bg_color) {
203
+ setCachedLoadingBgColor(bootstrap.loading_bg_color);
204
+ enablePrehide(bootstrap.loading_bg_color);
205
+ }
206
+ if ((bootstrap?.entitlements || nextEntitlements)?.brandingRequired) {
207
+ setBrandingEnabled(true);
208
+ setCachedBrandingEnabled(true);
209
+ }
210
+ else if (typeof bootstrap?.branding_enabled === "boolean") {
211
+ setBrandingEnabled(bootstrap.branding_enabled);
212
+ setCachedBrandingEnabled(bootstrap.branding_enabled);
213
+ }
214
+ const exclusions = Array.isArray(bootstrap?.exclusions)
215
+ ? bootstrap.exclusions
216
+ .map((row) => {
217
+ if (!row || typeof row !== "object")
218
+ return null;
219
+ const r = row;
220
+ const selector = typeof r.selector === "string" ? r.selector.trim() : "";
221
+ const type = typeof r.type === "string" ? r.type.trim() : "";
222
+ if (!selector)
223
+ return null;
224
+ if (type !== "css" && type !== "xpath")
225
+ return null;
226
+ return { selector, type: type };
227
+ })
228
+ .filter(Boolean)
229
+ : await apiRef.current.fetchExclusions();
230
+ exclusionsCacheRef.current = exclusions;
231
+ setMarkerEngineExclusions(exclusions);
232
+ const criticalMap = bootstrap?.critical?.map && typeof bootstrap.critical.map === "object" && !Array.isArray(bootstrap.critical.map)
233
+ ? bootstrap.critical.map
234
+ : {};
235
+ const hasBootstrapCritical = Object.keys(criticalMap).length > 0;
236
+ if (Object.keys(criticalMap).length > 0) {
237
+ setActiveTranslations(toTranslations(criticalMap, targetLocale));
238
+ if (mode === "dom") {
239
+ applyActiveTranslations(document.body);
240
+ }
241
+ }
242
+ if (autoApplyRules) {
243
+ const bootstrapDomRules = bootstrap?.dom_rules;
244
+ const domRules = Array.isArray(bootstrapDomRules)
245
+ ? bootstrapDomRules
246
+ : await apiRef.current.fetchDomRules(targetLocale);
247
+ setAndApplyDomRules(cacheKey, domRules);
248
+ }
249
+ if (isSeoActive() && bootstrap) {
250
+ const hreflangEnabled = Boolean((bootstrap.entitlements || nextEntitlements)?.hreflangEnabled);
251
+ applySeoBundle({ seo: bootstrap.seo, alternates: bootstrap.alternates, jsonld: bootstrap.jsonld }, hreflangEnabled);
252
+ }
253
+ writeCriticalCache(targetLocale, normalizedPath, {
254
+ map: criticalMap,
255
+ exclusions,
256
+ loading_bg_color: bootstrap?.loading_bg_color && /^#[0-9a-fA-F]{6}$/.test(bootstrap.loading_bg_color) ? bootstrap.loading_bg_color : null,
257
+ });
258
+ const shouldWaitForBundle = !revealedViaCachedCritical && !hasBootstrapCritical;
259
+ if (shouldWaitForBundle) {
260
+ // Why: if there's no critical slice for first paint, wait for the bundle (within the prehide failsafe) to avoid a visible flash.
261
+ const bundle = await apiRef.current.fetchBundle(targetLocale, currentPath);
262
+ if (bundle?.map && typeof bundle.map === "object") {
263
+ const translations = toTranslations(bundle.map, targetLocale);
264
+ if (translations.length > 0) {
265
+ translationCacheRef.current.set(cacheKey, { translations });
266
+ setActiveTranslations(translations);
267
+ if (mode === "dom") {
268
+ applyActiveTranslations(document.body);
269
+ }
270
+ }
271
+ }
272
+ }
273
+ else {
274
+ // Lazy-load the full page bundle after first paint.
275
+ void (async () => {
276
+ const bundle = await apiRef.current.fetchBundle(targetLocale, currentPath);
277
+ if (!bundle || !bundle.map)
278
+ return;
279
+ const translations = toTranslations(bundle.map, targetLocale);
280
+ if (translations.length === 0)
281
+ return;
282
+ translationCacheRef.current.set(cacheKey, { translations });
283
+ setActiveTranslations(translations);
284
+ if (mode === "dom") {
285
+ applyActiveTranslations(document.body);
286
+ }
287
+ })();
288
+ }
289
+ disablePrehide();
290
+ // Delayed retry scan to catch late-rendering content
291
+ retryTimeoutRef.current = setTimeout(() => {
292
+ // Don't scan if we're navigating (prevents React conflicts)
293
+ if (isNavigatingRef.current) {
294
+ return;
295
+ }
296
+ logDebug(`[Lovalingo] 🔄 Retry scan for late-rendering content`);
297
+ if (mode === "dom") {
298
+ applyActiveTranslations(document.body);
299
+ }
300
+ if (autoApplyRules) {
301
+ applyCachedDomRules(cacheKey);
302
+ }
303
+ }, 500);
304
+ }
305
+ catch (error) {
306
+ errorDebug("Error loading translations:", error);
307
+ disablePrehide();
308
+ }
309
+ finally {
310
+ setIsLoading(false);
311
+ if (loadingFailsafeTimeoutRef.current != null) {
312
+ window.clearTimeout(loadingFailsafeTimeoutRef.current);
313
+ loadingFailsafeTimeoutRef.current = null;
314
+ }
315
+ isNavigatingRef.current = false;
316
+ if (inFlightLoadKeyRef.current === cacheKey) {
317
+ inFlightLoadKeyRef.current = null;
318
+ }
319
+ }
320
+ }, [
321
+ allLocales,
322
+ applyCachedDomRules,
323
+ applySeoBundle,
324
+ autoApplyRules,
325
+ defaultLocale,
326
+ disablePrehide,
327
+ enablePrehide,
328
+ enhancedPathConfig,
329
+ fetchAndApplyDomRules,
330
+ getCachedDomRules,
331
+ getCachedLoadingBgColor,
332
+ isSeoActive,
333
+ mode,
334
+ nonLocalizedPaths,
335
+ readCriticalCache,
336
+ routing,
337
+ setAndApplyDomRules,
338
+ setBrandingEnabled,
339
+ setCachedBrandingEnabled,
340
+ setCachedLoadingBgColor,
341
+ setEntitlements,
342
+ toTranslations,
343
+ writeCriticalCache,
344
+ ]);
345
+ useEffect(() => {
346
+ return () => {
347
+ if (retryTimeoutRef.current)
348
+ clearTimeout(retryTimeoutRef.current);
349
+ if (loadingFailsafeTimeoutRef.current != null)
350
+ window.clearTimeout(loadingFailsafeTimeoutRef.current);
351
+ };
352
+ }, []);
353
+ return { isLoading, isNavigatingRef, loadData };
354
+ }
@@ -0,0 +1,15 @@
1
+ import type React from "react";
2
+ import type { DomRule } from "../../types";
3
+ import type { LovalingoAPI } from "../../utils/api";
4
+ type UseDomRulesOptions = {
5
+ apiRef: React.MutableRefObject<LovalingoAPI>;
6
+ autoApplyRules: boolean;
7
+ };
8
+ export declare function useDomRules({ apiRef, autoApplyRules }: UseDomRulesOptions): {
9
+ applyCachedDomRules: (cacheKey: string, fallbackRules?: DomRule[] | null) => number;
10
+ fetchAndApplyDomRules: (cacheKey: string, targetLocale: string) => Promise<DomRule[]>;
11
+ getCachedDomRules: (cacheKey: string) => DomRule[] | undefined;
12
+ setAndApplyDomRules: (cacheKey: string, rules: DomRule[]) => number;
13
+ setCachedDomRules: (cacheKey: string, rules: DomRule[]) => void;
14
+ };
15
+ export {};
@@ -0,0 +1,38 @@
1
+ import { useCallback, useRef } from "react";
2
+ import { applyDomRules } from "../../utils/domRules";
3
+ export function useDomRules({ apiRef, autoApplyRules }) {
4
+ const domRulesCacheRef = useRef(new Map());
5
+ const getCachedDomRules = useCallback((cacheKey) => {
6
+ return domRulesCacheRef.current.get(cacheKey);
7
+ }, []);
8
+ const applyCachedDomRules = useCallback((cacheKey, fallbackRules) => {
9
+ if (!autoApplyRules)
10
+ return 0;
11
+ const rules = domRulesCacheRef.current.get(cacheKey) || fallbackRules || [];
12
+ return applyDomRules(rules);
13
+ }, [autoApplyRules]);
14
+ const setCachedDomRules = useCallback((cacheKey, rules) => {
15
+ domRulesCacheRef.current.set(cacheKey, rules);
16
+ }, []);
17
+ const setAndApplyDomRules = useCallback((cacheKey, rules) => {
18
+ domRulesCacheRef.current.set(cacheKey, rules);
19
+ if (!autoApplyRules)
20
+ return 0;
21
+ return applyDomRules(rules);
22
+ }, [autoApplyRules]);
23
+ const fetchAndApplyDomRules = useCallback(async (cacheKey, targetLocale) => {
24
+ if (!autoApplyRules)
25
+ return [];
26
+ const rules = await apiRef.current.fetchDomRules(targetLocale);
27
+ domRulesCacheRef.current.set(cacheKey, rules);
28
+ applyDomRules(rules);
29
+ return rules;
30
+ }, [apiRef, autoApplyRules]);
31
+ return {
32
+ applyCachedDomRules,
33
+ fetchAndApplyDomRules,
34
+ getCachedDomRules,
35
+ setAndApplyDomRules,
36
+ setCachedDomRules,
37
+ };
38
+ }
@@ -0,0 +1,12 @@
1
+ import type React from "react";
2
+ import type { NonLocalizedPathRule } from "../../utils/nonLocalizedPaths";
3
+ type UseLinkAutoPrefixOptions = {
4
+ routing: "path" | "query";
5
+ autoPrefixLinks: boolean;
6
+ allLocales: string[];
7
+ locale: string;
8
+ navigateRef?: React.MutableRefObject<((path: string) => void) | undefined>;
9
+ nonLocalizedPaths: NonLocalizedPathRule[];
10
+ };
11
+ export declare function useLinkAutoPrefix({ routing, autoPrefixLinks, allLocales, locale, navigateRef, nonLocalizedPaths, }: UseLinkAutoPrefixOptions): void;
12
+ export {};
@@ -0,0 +1,146 @@
1
+ import { useEffect } from "react";
2
+ import { isNonLocalizedPath } from "../../utils/nonLocalizedPaths";
3
+ export function useLinkAutoPrefix({ routing, autoPrefixLinks, allLocales, locale, navigateRef, nonLocalizedPaths, }) {
4
+ // PATH mode: auto-prefix internal links that are missing a locale segment.
5
+ // This prevents "losing" the current locale when the app renders absolute links like "/projects/slug"
6
+ // while the user is on "/de/...".
7
+ useEffect(() => {
8
+ if (routing !== "path")
9
+ return;
10
+ if (!autoPrefixLinks)
11
+ return;
12
+ const supportedLocales = allLocales;
13
+ const shouldProcessCurrentPath = () => {
14
+ const parts = window.location.pathname.split("/").filter(Boolean);
15
+ return parts.length > 0 && supportedLocales.includes(parts[0]);
16
+ };
17
+ const buildLocalePrefixedPath = (rawHref) => {
18
+ if (!rawHref)
19
+ return null;
20
+ const trimmed = rawHref.trim();
21
+ if (!trimmed)
22
+ return null;
23
+ // Only rewrite absolute-path or same-origin absolute URLs.
24
+ const isAbsolutePath = trimmed.startsWith("/");
25
+ const isAbsoluteUrl = /^https?:\/\//i.test(trimmed) || trimmed.startsWith("//");
26
+ if (!isAbsolutePath && !isAbsoluteUrl)
27
+ return null;
28
+ // Ignore special schemes / fragments
29
+ if (/^(?:#|mailto:|tel:|sms:|javascript:)/i.test(trimmed))
30
+ return null;
31
+ let url;
32
+ try {
33
+ url = new URL(trimmed, window.location.origin);
34
+ }
35
+ catch {
36
+ return null;
37
+ }
38
+ if (url.origin !== window.location.origin)
39
+ return null;
40
+ if (isNonLocalizedPath(url.pathname, nonLocalizedPaths))
41
+ return null;
42
+ const parts = url.pathname.split("/").filter(Boolean);
43
+ // Root ("/") should be locale-prefixed too (e.g. clicking a logo linking to "https://example.com")
44
+ // when we are currently on a locale URL like "/de/...".
45
+ if (parts.length === 0) {
46
+ return `/${locale}${url.search}${url.hash}`;
47
+ }
48
+ if (supportedLocales.includes(parts[0]))
49
+ return null; // already locale-prefixed
50
+ const pathWithoutLeadingSlashes = url.pathname.replace(/^\/+/, "");
51
+ const nextPathname = pathWithoutLeadingSlashes ? `/${locale}/${pathWithoutLeadingSlashes}` : `/${locale}`;
52
+ return `${nextPathname}${url.search}${url.hash}`;
53
+ };
54
+ const ORIGINAL_HREF_KEY = "data-Lovalingo-href-original";
55
+ const patchAnchor = (a) => {
56
+ if (!a || a.hasAttribute("data-Lovalingo-exclude"))
57
+ return;
58
+ const original = a.getAttribute(ORIGINAL_HREF_KEY) ?? a.getAttribute("href") ?? "";
59
+ if (!a.getAttribute(ORIGINAL_HREF_KEY) && original) {
60
+ a.setAttribute(ORIGINAL_HREF_KEY, original);
61
+ }
62
+ const fixed = buildLocalePrefixedPath(original);
63
+ if (fixed) {
64
+ if (a.getAttribute("href") !== fixed)
65
+ a.setAttribute("href", fixed);
66
+ }
67
+ else if (original) {
68
+ // If we previously rewrote it, restore the original when it no longer applies.
69
+ if (a.getAttribute("href") !== original)
70
+ a.setAttribute("href", original);
71
+ }
72
+ };
73
+ const patchAllAnchors = () => {
74
+ if (!shouldProcessCurrentPath())
75
+ return;
76
+ document.querySelectorAll("a[href]").forEach((node) => {
77
+ if (node instanceof HTMLAnchorElement)
78
+ patchAnchor(node);
79
+ });
80
+ };
81
+ // Patch existing anchors (also updates when locale changes)
82
+ patchAllAnchors();
83
+ // Patch new anchors when the DOM changes
84
+ const mo = new MutationObserver((mutations) => {
85
+ if (!shouldProcessCurrentPath())
86
+ return;
87
+ for (const mutation of mutations) {
88
+ mutation.addedNodes.forEach((node) => {
89
+ if (!(node instanceof HTMLElement))
90
+ return;
91
+ if (node instanceof HTMLAnchorElement) {
92
+ patchAnchor(node);
93
+ return;
94
+ }
95
+ node.querySelectorAll?.("a[href]").forEach((a) => {
96
+ if (a instanceof HTMLAnchorElement)
97
+ patchAnchor(a);
98
+ });
99
+ });
100
+ }
101
+ });
102
+ mo.observe(document.body, { childList: true, subtree: true });
103
+ // Click interception (capture) to handle cases where frameworks (e.g. React Router <Link>)
104
+ // navigate based on their "to" prop rather than the DOM href attribute.
105
+ const onClickCapture = (event) => {
106
+ if (!shouldProcessCurrentPath())
107
+ return;
108
+ if (event.defaultPrevented)
109
+ return;
110
+ if (event.button !== 0)
111
+ return;
112
+ if (event.metaKey || event.ctrlKey || event.shiftKey || event.altKey)
113
+ return;
114
+ const target = event.target;
115
+ const a = target?.closest?.("a[href]");
116
+ if (!a)
117
+ return;
118
+ // Let the browser handle new tabs/downloads/etc.
119
+ if (a.target && a.target !== "_self")
120
+ return;
121
+ if (a.hasAttribute("download"))
122
+ return;
123
+ if (a.getAttribute("rel")?.includes("external"))
124
+ return;
125
+ const original = a.getAttribute(ORIGINAL_HREF_KEY) ?? a.getAttribute("href") ?? "";
126
+ const fixed = buildLocalePrefixedPath(original);
127
+ if (!fixed)
128
+ return;
129
+ event.preventDefault();
130
+ event.stopImmediatePropagation();
131
+ event.stopPropagation();
132
+ const navigate = navigateRef?.current;
133
+ if (navigate) {
134
+ navigate(fixed);
135
+ }
136
+ else {
137
+ window.location.assign(fixed);
138
+ }
139
+ };
140
+ document.addEventListener("click", onClickCapture, true);
141
+ return () => {
142
+ mo.disconnect();
143
+ document.removeEventListener("click", onClickCapture, true);
144
+ };
145
+ }, [routing, autoPrefixLinks, allLocales, locale, navigateRef, nonLocalizedPaths]);
146
+ }
@@ -0,0 +1,12 @@
1
+ import type { PathNormalizationConfig } from "../../utils/pathNormalizer";
2
+ type UseNavigationPrefetchOptions = {
3
+ resolvedApiKey: string;
4
+ apiBase: string;
5
+ defaultLocale: string;
6
+ locale: string;
7
+ routing: "path" | "query";
8
+ allLocales: string[];
9
+ enhancedPathConfig: PathNormalizationConfig;
10
+ };
11
+ export declare function useNavigationPrefetch({ resolvedApiKey, apiBase, defaultLocale, locale, routing, allLocales, enhancedPathConfig, }: UseNavigationPrefetchOptions): void;
12
+ export {};