@lovalingo/lovalingo 0.2.0 → 0.3.0

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
@@ -7,10 +7,9 @@ Built for React and Next.js apps, Lovalingo does **not** generate translations i
7
7
  ## i18n alternative for lovable and vibecoding tools
8
8
 
9
9
  1. Your app renders normally (source language).
10
- 2. Lovalingo loads the current locale’s bundle from the backend.
11
- 3. The runtime mutates the DOM before paint (and keeps up with route changes + dynamic content).
12
- 4. Optional: Lovalingo fetches DOM rules (CSS/JS/DOM patches) to fix edge cases (hidden UI, wrapping, etc.).
13
- 5. Optional SEO: Lovalingo updates `<head>` (canonical + hreflang + basic meta) using `seo-bundle`.
10
+ 2. Lovalingo fetches a single `bootstrap` payload (critical above-the-fold translations + SEO + rules + exclusions).
11
+ 3. The runtime hides the page (`visibility:hidden`) until the critical slice is applied (prevents the EN → FR flash).
12
+ 4. The full page bundle is lazy-loaded after first paint (keeps route changes + dynamic content translated).
14
13
 
15
14
  All artifacts are produced server-side by the pipeline (render → audit → deterministic translate → optional fix loop).
16
15
 
@@ -117,6 +116,8 @@ Enabled by default. Disable if you already manage `<head>` yourself:
117
116
  <LovalingoProvider seo={false} ... />
118
117
  ```
119
118
 
119
+ Tip: Set the "No-flash loading background" color in the Lovalingo dashboard (Project → Setup) to match your site’s background during the brief prehide window.
120
+
120
121
  ## Sitemap link tag
121
122
 
122
123
  By default Lovalingo injects `<link rel="sitemap" href="/sitemap.xml">` for discovery. Disable it:
@@ -2,11 +2,14 @@ import React, { useMemo, useState, useEffect, useCallback, useRef } from 'react'
2
2
  import { LovalingoContext } from '../context/LovalingoContext';
3
3
  import { LovalingoAPI } from '../utils/api';
4
4
  import { applyDomRules } from '../utils/domRules';
5
+ import { hashContent } from '../utils/hash';
5
6
  import { applyActiveTranslations, restoreDom, setActiveTranslations, setMarkerEngineExclusions, startMarkerEngine } from '../utils/markerEngine';
6
7
  import { logDebug, warnDebug, errorDebug } from '../utils/logger';
8
+ import { processPath } from '../utils/pathNormalizer';
7
9
  import { LanguageSwitcher } from './LanguageSwitcher';
8
- import { NavigationOverlay } from './NavigationOverlay';
9
10
  const LOCALE_STORAGE_KEY = 'Lovalingo_locale';
11
+ const LOADING_BG_STORAGE_PREFIX = "Lovalingo_loading_bg_color";
12
+ const CRITICAL_CACHE_PREFIX = "Lovalingo_critical_v0_3";
10
13
  export const LovalingoProvider = ({ children, apiKey: apiKeyProp, publicAnonKey, defaultLocale, locales, apiBase = 'https://leuskvkajliuzalrlwhw.supabase.co', routing = 'query', // Default to query mode (backward compatible)
11
14
  autoPrefixLinks = true, autoApplyRules = true, switcherPosition = 'bottom-right', switcherOffsetY = 20, switcherTheme = 'dark', editMode: initialEditMode = false, editKey = 'KeyE', pathNormalization = { enabled: true }, // Enable by default
12
15
  mode = 'dom', // Default to legacy DOM mode for backward compatibility
@@ -34,27 +37,41 @@ navigateRef, // For path mode routing
34
37
  const base = rawLocales.includes(defaultLocale) ? rawLocales : [defaultLocale, ...rawLocales];
35
38
  return Array.from(new Set(base));
36
39
  }, [defaultLocale, localesKey, rawLocales]);
37
- // Initialize locale from localStorage to prevent flash of default locale on navigation
40
+ // Why: read locale synchronously from the URL to avoid an initial default-locale render (EN → FR flash) before effects run.
38
41
  const [locale, setLocaleState] = useState(() => {
42
+ if (typeof window === "undefined")
43
+ return defaultLocale;
44
+ if (routing === "path") {
45
+ const pathLocale = window.location.pathname.split("/")[1];
46
+ if (pathLocale && allLocales.includes(pathLocale)) {
47
+ return pathLocale;
48
+ }
49
+ }
50
+ else if (routing === "query") {
51
+ const params = new URLSearchParams(window.location.search);
52
+ const queryLocale = params.get("t") || params.get("locale");
53
+ if (queryLocale && allLocales.includes(queryLocale)) {
54
+ return queryLocale;
55
+ }
56
+ }
39
57
  try {
40
58
  const stored = localStorage.getItem(LOCALE_STORAGE_KEY);
41
- if (stored && (allLocales.includes(stored) || stored === defaultLocale)) {
59
+ if (stored && allLocales.includes(stored)) {
42
60
  return stored;
43
61
  }
44
62
  }
45
- catch (e) {
46
- // localStorage unavailable (SSR, private browsing)
63
+ catch {
64
+ // ignore
47
65
  }
48
66
  return defaultLocale;
49
67
  });
50
68
  const [isLoading, setIsLoading] = useState(false);
51
- const [isNavigationLoading, setIsNavigationLoading] = useState(false);
52
69
  const [editMode, setEditMode] = useState(initialEditMode);
53
- // Enhanced path normalization with supportedLocales for path mode
54
- const enhancedPathConfig = routing === 'path'
55
- ? { ...pathNormalization, supportedLocales: allLocales }
56
- : pathNormalization;
70
+ const enhancedPathConfig = useMemo(() => (routing === "path" ? { ...pathNormalization, supportedLocales: allLocales } : pathNormalization), [allLocales, pathNormalization, routing]);
57
71
  const apiRef = useRef(new LovalingoAPI(resolvedApiKey, apiBase, enhancedPathConfig));
72
+ useEffect(() => {
73
+ apiRef.current = new LovalingoAPI(resolvedApiKey, apiBase, enhancedPathConfig);
74
+ }, [apiBase, enhancedPathConfig, resolvedApiKey]);
58
75
  const [entitlements, setEntitlements] = useState(() => apiRef.current.getEntitlements());
59
76
  const retryTimeoutRef = useRef(null);
60
77
  const isNavigatingRef = useRef(false);
@@ -62,6 +79,141 @@ navigateRef, // For path mode routing
62
79
  const translationCacheRef = useRef(new Map());
63
80
  const exclusionsCacheRef = useRef(null);
64
81
  const domRulesCacheRef = useRef(new Map());
82
+ const loadingBgStorageKey = `${LOADING_BG_STORAGE_PREFIX}:${resolvedApiKey || "anonymous"}`;
83
+ const prehideStateRef = useRef({
84
+ active: false,
85
+ timeoutId: null,
86
+ prevHtmlVisibility: "",
87
+ prevBodyVisibility: "",
88
+ prevHtmlBg: "",
89
+ prevBodyBg: "",
90
+ });
91
+ const getCachedLoadingBgColor = useCallback(() => {
92
+ try {
93
+ const cached = localStorage.getItem(loadingBgStorageKey) || "";
94
+ if (/^#[0-9a-fA-F]{6}$/.test(cached.trim()))
95
+ return cached.trim();
96
+ }
97
+ catch {
98
+ // ignore
99
+ }
100
+ return "#ffffff";
101
+ }, [loadingBgStorageKey]);
102
+ const setCachedLoadingBgColor = useCallback((color) => {
103
+ const next = (color || "").toString().trim();
104
+ if (!/^#[0-9a-fA-F]{6}$/.test(next))
105
+ return;
106
+ try {
107
+ localStorage.setItem(loadingBgStorageKey, next);
108
+ }
109
+ catch {
110
+ // ignore
111
+ }
112
+ }, [loadingBgStorageKey]);
113
+ const enablePrehide = useCallback((bgColor) => {
114
+ if (typeof document === "undefined")
115
+ return;
116
+ const html = document.documentElement;
117
+ const body = document.body;
118
+ if (!html || !body)
119
+ return;
120
+ const state = prehideStateRef.current;
121
+ if (!state.active) {
122
+ state.active = true;
123
+ state.prevHtmlVisibility = html.style.visibility || "";
124
+ state.prevBodyVisibility = body.style.visibility || "";
125
+ state.prevHtmlBg = html.style.backgroundColor || "";
126
+ state.prevBodyBg = body.style.backgroundColor || "";
127
+ }
128
+ html.style.visibility = "hidden";
129
+ body.style.visibility = "hidden";
130
+ if (bgColor) {
131
+ html.style.backgroundColor = bgColor;
132
+ body.style.backgroundColor = bgColor;
133
+ }
134
+ if (state.timeoutId != null) {
135
+ window.clearTimeout(state.timeoutId);
136
+ }
137
+ // Why: avoid leaving the page hidden forever if the network is blocked or the project has no translations yet.
138
+ state.timeoutId = window.setTimeout(() => {
139
+ disablePrehide();
140
+ }, 2500);
141
+ }, []);
142
+ const disablePrehide = useCallback(() => {
143
+ if (typeof document === "undefined")
144
+ return;
145
+ const html = document.documentElement;
146
+ const body = document.body;
147
+ if (!html || !body)
148
+ return;
149
+ const state = prehideStateRef.current;
150
+ if (state.timeoutId != null) {
151
+ window.clearTimeout(state.timeoutId);
152
+ state.timeoutId = null;
153
+ }
154
+ if (!state.active)
155
+ return;
156
+ state.active = false;
157
+ html.style.visibility = state.prevHtmlVisibility;
158
+ body.style.visibility = state.prevBodyVisibility;
159
+ html.style.backgroundColor = state.prevHtmlBg;
160
+ body.style.backgroundColor = state.prevBodyBg;
161
+ }, []);
162
+ const buildCriticalCacheKey = useCallback((targetLocale, normalizedPath) => {
163
+ const key = `${resolvedApiKey || "anonymous"}:${targetLocale}:${normalizedPath || "/"}`;
164
+ return `${CRITICAL_CACHE_PREFIX}:${hashContent(key)}`;
165
+ }, [resolvedApiKey]);
166
+ const readCriticalCache = useCallback((targetLocale, normalizedPath) => {
167
+ const key = buildCriticalCacheKey(targetLocale, normalizedPath);
168
+ try {
169
+ const raw = localStorage.getItem(key);
170
+ if (!raw)
171
+ return null;
172
+ const parsed = JSON.parse(raw);
173
+ if (!parsed || typeof parsed !== "object")
174
+ return null;
175
+ const record = parsed;
176
+ const map = record.map && typeof record.map === "object" && !Array.isArray(record.map) ? record.map : null;
177
+ const exclusionsRaw = Array.isArray(record.exclusions) ? record.exclusions : [];
178
+ const exclusions = exclusionsRaw
179
+ .map((row) => {
180
+ if (!row || typeof row !== "object")
181
+ return null;
182
+ const r = row;
183
+ const selector = typeof r.selector === "string" ? r.selector.trim() : "";
184
+ const type = typeof r.type === "string" ? r.type.trim() : "";
185
+ if (!selector)
186
+ return null;
187
+ if (type !== "css" && type !== "xpath")
188
+ return null;
189
+ return { selector, type: type };
190
+ })
191
+ .filter(Boolean);
192
+ const bg = typeof record.loading_bg_color === "string" ? record.loading_bg_color.trim() : "";
193
+ return {
194
+ map: map || {},
195
+ exclusions,
196
+ loading_bg_color: /^#[0-9a-fA-F]{6}$/.test(bg) ? bg : null,
197
+ };
198
+ }
199
+ catch {
200
+ return null;
201
+ }
202
+ }, [buildCriticalCacheKey]);
203
+ const writeCriticalCache = useCallback((targetLocale, normalizedPath, entry) => {
204
+ const key = buildCriticalCacheKey(targetLocale, normalizedPath);
205
+ try {
206
+ localStorage.setItem(key, JSON.stringify({
207
+ stored_at: Date.now(),
208
+ map: entry.map || {},
209
+ exclusions: entry.exclusions || [],
210
+ loading_bg_color: entry.loading_bg_color,
211
+ }));
212
+ }
213
+ catch {
214
+ // ignore
215
+ }
216
+ }, [buildCriticalCacheKey]);
65
217
  const config = {
66
218
  apiKey: resolvedApiKey,
67
219
  publicAnonKey: resolvedApiKey,
@@ -99,15 +251,70 @@ navigateRef, // For path mode routing
99
251
  return false;
100
252
  return seo !== false;
101
253
  }, [entitlements, seo]);
102
- const updateSeoLinks = useCallback(async (activeLocale, hreflangEnabled) => {
254
+ // Marker engine: always mark full DOM content for deterministic pipeline extraction.
255
+ useEffect(() => {
256
+ const stop = startMarkerEngine({ throttleMs: 120 });
257
+ return () => stop();
258
+ }, []);
259
+ useEffect(() => {
260
+ return () => disablePrehide();
261
+ }, [disablePrehide]);
262
+ // Detect locale from URL or localStorage
263
+ const detectLocale = useCallback(() => {
264
+ // 1. Check URL first based on routing mode
265
+ if (routing === 'path') {
266
+ // Path mode: language is in path (/en/pricing, /fr/about)
267
+ const pathLocale = window.location.pathname.split('/')[1];
268
+ if (allLocales.includes(pathLocale)) {
269
+ return pathLocale;
270
+ }
271
+ }
272
+ else if (routing === 'query') {
273
+ // Query mode: language is in query param (/pricing?t=fr)
274
+ const params = new URLSearchParams(window.location.search);
275
+ const queryLocale = params.get('t') || params.get('locale');
276
+ if (queryLocale && allLocales.includes(queryLocale)) {
277
+ return queryLocale;
278
+ }
279
+ }
280
+ // 2. Check localStorage (fallback for all routing modes)
281
+ try {
282
+ const storedLocale = localStorage.getItem(LOCALE_STORAGE_KEY);
283
+ if (storedLocale && allLocales.includes(storedLocale)) {
284
+ return storedLocale;
285
+ }
286
+ }
287
+ catch (e) {
288
+ // localStorage might be unavailable (SSR, private browsing)
289
+ warnDebug('localStorage not available:', e);
290
+ }
291
+ // 3. Default locale
292
+ return defaultLocale;
293
+ }, [allLocales, defaultLocale, routing]);
294
+ // Fetch entitlements early so SEO can be enabled even on default locale
295
+ useEffect(() => {
296
+ if (locale !== defaultLocale)
297
+ return;
298
+ if (entitlements)
299
+ return;
300
+ let cancelled = false;
301
+ (async () => {
302
+ const next = await apiRef.current.fetchEntitlements(locale);
303
+ if (!cancelled && next)
304
+ setEntitlements(next);
305
+ })();
306
+ return () => {
307
+ cancelled = true;
308
+ };
309
+ }, [defaultLocale, entitlements, locale]);
310
+ const applySeoBundle = useCallback((bundle, hreflangEnabled) => {
103
311
  try {
104
312
  const head = document.head;
105
313
  if (!head)
106
314
  return;
107
315
  head.querySelectorAll('link[data-Lovalingo="hreflang"], link[data-Lovalingo="canonical"]').forEach((el) => el.remove());
108
- if (!isSeoActive())
316
+ if (!bundle)
109
317
  return;
110
- const bundle = await apiRef.current.fetchSeoBundle(activeLocale);
111
318
  const seo = (bundle?.seo && typeof bundle.seo === "object" ? bundle.seo : {});
112
319
  const alternates = (bundle?.alternates && typeof bundle.alternates === "object" ? bundle.alternates : {});
113
320
  const setOrCreateMeta = (attrs, content) => {
@@ -203,66 +410,36 @@ navigateRef, // For path mode routing
203
410
  head.appendChild(xDefault);
204
411
  }
205
412
  }
206
- catch (e) {
207
- warnDebug("[Lovalingo] updateSeoLinks() failed:", e);
413
+ catch {
414
+ // ignore SEO errors
208
415
  }
209
- }, [isSeoActive]);
210
- // Marker engine: always mark full DOM content for deterministic pipeline extraction.
211
- useEffect(() => {
212
- const stop = startMarkerEngine({ throttleMs: 120 });
213
- return () => stop();
214
416
  }, []);
215
- // Detect locale from URL or localStorage
216
- const detectLocale = useCallback(() => {
217
- // 1. Check URL first based on routing mode
218
- if (routing === 'path') {
219
- // Path mode: language is in path (/en/pricing, /fr/about)
220
- const pathLocale = window.location.pathname.split('/')[1];
221
- if (allLocales.includes(pathLocale)) {
222
- return pathLocale;
223
- }
224
- }
225
- else if (routing === 'query') {
226
- // Query mode: language is in query param (/pricing?t=fr)
227
- const params = new URLSearchParams(window.location.search);
228
- const queryLocale = params.get('t') || params.get('locale');
229
- if (queryLocale && allLocales.includes(queryLocale)) {
230
- return queryLocale;
231
- }
232
- }
233
- // 2. Check localStorage (fallback for all routing modes)
234
- try {
235
- const storedLocale = localStorage.getItem(LOCALE_STORAGE_KEY);
236
- if (storedLocale && allLocales.includes(storedLocale)) {
237
- return storedLocale;
238
- }
239
- }
240
- catch (e) {
241
- // localStorage might be unavailable (SSR, private browsing)
242
- warnDebug('localStorage not available:', e);
243
- }
244
- // 3. Default locale
245
- return defaultLocale;
246
- }, [allLocales, defaultLocale, routing]);
247
- // Fetch entitlements early so SEO can be enabled even on default locale
248
- useEffect(() => {
249
- let cancelled = false;
250
- (async () => {
251
- const next = await apiRef.current.fetchEntitlements(detectLocale());
252
- if (!cancelled && next)
253
- setEntitlements(next);
254
- })();
255
- return () => {
256
- cancelled = true;
257
- };
258
- }, [detectLocale]);
259
- // Keep <html lang> + canonical/hreflang in sync with routing + entitlements
417
+ // Keep <html lang> in sync and apply default-locale SEO (non-default locales use the bootstrap payload).
260
418
  useEffect(() => {
261
419
  setDocumentLocale(locale);
262
- void updateSeoLinks(locale, Boolean(entitlements?.hreflangEnabled));
263
- }, [locale, entitlements, setDocumentLocale, updateSeoLinks]);
264
- // Load translations and exclusions
265
- const loadData = useCallback(async (targetLocale, previousLocale, showOverlay = false) => {
420
+ if (locale !== defaultLocale)
421
+ return;
422
+ if (!isSeoActive())
423
+ return;
424
+ void apiRef.current.fetchSeoBundle(locale).then((bundle) => {
425
+ applySeoBundle(bundle, Boolean(entitlements?.hreflangEnabled));
426
+ });
427
+ }, [applySeoBundle, defaultLocale, entitlements, isSeoActive, locale, setDocumentLocale]);
428
+ const toTranslations = useCallback((map, targetLocale) => {
429
+ const out = [];
430
+ for (const [source_text, translated_text] of Object.entries(map || {})) {
431
+ if (!source_text || !translated_text)
432
+ continue;
433
+ out.push({
434
+ source_text,
435
+ translated_text,
436
+ source_locale: defaultLocale,
437
+ target_locale: targetLocale,
438
+ });
439
+ }
440
+ return out;
441
+ }, [defaultLocale]);
442
+ const loadData = useCallback(async (targetLocale, previousLocale) => {
266
443
  // Cancel any pending retry scan to prevent race conditions
267
444
  if (retryTimeoutRef.current) {
268
445
  clearTimeout(retryTimeoutRef.current);
@@ -271,23 +448,23 @@ navigateRef, // For path mode routing
271
448
  // If switching to default locale, clear translations and translate with empty map
272
449
  // This will show original text using stored data-Lovalingo-original-html
273
450
  if (targetLocale === defaultLocale) {
274
- if (showOverlay)
275
- setIsNavigationLoading(false);
451
+ disablePrehide();
276
452
  setActiveTranslations(null);
277
453
  restoreDom(document.body); // React-safe: only text/attrs, no DOM structure mutation
278
454
  isNavigatingRef.current = false;
279
455
  return;
280
456
  }
281
- // Create cache key from locale and current path
282
- const currentPath = window.location.pathname;
283
- const cacheKey = `${targetLocale}:${currentPath}`;
457
+ const currentPath = window.location.pathname + window.location.search;
458
+ const normalizedPath = processPath(window.location.pathname, enhancedPathConfig);
459
+ const cacheKey = `${targetLocale}:${normalizedPath}`;
284
460
  // Check if we have cached translations for this locale + path
285
461
  const cachedEntry = translationCacheRef.current.get(cacheKey);
286
462
  const cachedExclusions = exclusionsCacheRef.current;
287
463
  const cachedDomRules = domRulesCacheRef.current.get(cacheKey);
288
464
  if (cachedEntry && cachedExclusions) {
289
465
  // CACHE HIT - Use cached data immediately (FAST!)
290
- logDebug(`[Lovalingo] Using cached translations for ${targetLocale} on ${currentPath}`);
466
+ logDebug(`[Lovalingo] Using cached translations for ${targetLocale} on ${normalizedPath}`);
467
+ enablePrehide(getCachedLoadingBgColor());
291
468
  setActiveTranslations(cachedEntry.translations);
292
469
  setMarkerEngineExclusions(cachedExclusions);
293
470
  if (mode === 'dom') {
@@ -318,49 +495,84 @@ navigateRef, // For path mode routing
318
495
  applyDomRules(rules);
319
496
  }
320
497
  }, 500);
321
- if (showOverlay) {
322
- setTimeout(() => setIsNavigationLoading(false), 50);
323
- }
498
+ disablePrehide();
324
499
  isNavigatingRef.current = false;
325
500
  return;
326
501
  }
327
502
  // CACHE MISS - Fetch from API
328
- logDebug(`[Lovalingo] Fetching translations for ${targetLocale} on ${currentPath}`);
503
+ logDebug(`[Lovalingo] Fetching translations for ${targetLocale} on ${normalizedPath}`);
329
504
  setIsLoading(true);
505
+ enablePrehide(getCachedLoadingBgColor());
330
506
  try {
331
507
  if (previousLocale && previousLocale !== defaultLocale) {
332
508
  logDebug(`[Lovalingo] Switching from ${previousLocale} to ${targetLocale}`);
333
509
  }
334
- const [bundle, exclusions, domRules] = await Promise.all([
335
- apiRef.current.fetchBundle(targetLocale),
336
- apiRef.current.fetchExclusions(),
337
- autoApplyRules ? apiRef.current.fetchDomRules(targetLocale) : Promise.resolve([]),
338
- ]);
339
- const nextEntitlements = apiRef.current.getEntitlements();
510
+ const cachedCritical = readCriticalCache(targetLocale, normalizedPath);
511
+ if (cachedCritical?.loading_bg_color) {
512
+ setCachedLoadingBgColor(cachedCritical.loading_bg_color);
513
+ enablePrehide(cachedCritical.loading_bg_color);
514
+ }
515
+ if (cachedCritical?.exclusions && cachedCritical.exclusions.length > 0) {
516
+ exclusionsCacheRef.current = cachedCritical.exclusions;
517
+ setMarkerEngineExclusions(cachedCritical.exclusions);
518
+ }
519
+ if (cachedCritical?.map && Object.keys(cachedCritical.map).length > 0) {
520
+ setActiveTranslations(toTranslations(cachedCritical.map, targetLocale));
521
+ if (mode === "dom") {
522
+ applyActiveTranslations(document.body);
523
+ }
524
+ disablePrehide();
525
+ }
526
+ const bootstrap = await apiRef.current.fetchBootstrap(targetLocale, currentPath);
527
+ const nextEntitlements = bootstrap?.entitlements || apiRef.current.getEntitlements();
340
528
  if (nextEntitlements)
341
529
  setEntitlements(nextEntitlements);
342
- const translations = bundle
343
- ? Object.entries(bundle.map).map(([source_text, translated_text]) => ({
344
- source_text,
345
- translated_text,
346
- source_locale: defaultLocale,
347
- target_locale: targetLocale,
348
- }))
349
- : [];
350
- // Store in cache for next time
351
- translationCacheRef.current.set(cacheKey, { translations });
352
- exclusionsCacheRef.current = exclusions;
353
- if (autoApplyRules) {
354
- domRulesCacheRef.current.set(cacheKey, domRules);
530
+ if (bootstrap?.loading_bg_color) {
531
+ setCachedLoadingBgColor(bootstrap.loading_bg_color);
532
+ enablePrehide(bootstrap.loading_bg_color);
355
533
  }
356
- setActiveTranslations(translations);
534
+ const exclusions = Array.isArray(bootstrap?.exclusions)
535
+ ? bootstrap.exclusions
536
+ .map((row) => {
537
+ if (!row || typeof row !== "object")
538
+ return null;
539
+ const r = row;
540
+ const selector = typeof r.selector === "string" ? r.selector.trim() : "";
541
+ const type = typeof r.type === "string" ? r.type.trim() : "";
542
+ if (!selector)
543
+ return null;
544
+ if (type !== "css" && type !== "xpath")
545
+ return null;
546
+ return { selector, type: type };
547
+ })
548
+ .filter(Boolean)
549
+ : await apiRef.current.fetchExclusions();
550
+ exclusionsCacheRef.current = exclusions;
357
551
  setMarkerEngineExclusions(exclusions);
358
- if (mode === 'dom') {
359
- applyActiveTranslations(document.body);
552
+ const criticalMap = bootstrap?.critical?.map && typeof bootstrap.critical.map === "object" && !Array.isArray(bootstrap.critical.map)
553
+ ? bootstrap.critical.map
554
+ : {};
555
+ if (Object.keys(criticalMap).length > 0) {
556
+ setActiveTranslations(toTranslations(criticalMap, targetLocale));
557
+ if (mode === "dom") {
558
+ applyActiveTranslations(document.body);
559
+ }
360
560
  }
361
561
  if (autoApplyRules) {
562
+ const domRules = Array.isArray(bootstrap?.dom_rules) ? bootstrap.dom_rules : await apiRef.current.fetchDomRules(targetLocale);
563
+ domRulesCacheRef.current.set(cacheKey, domRules);
362
564
  applyDomRules(domRules);
363
565
  }
566
+ if (isSeoActive() && bootstrap) {
567
+ const hreflangEnabled = Boolean((bootstrap.entitlements || nextEntitlements)?.hreflangEnabled);
568
+ applySeoBundle({ seo: bootstrap.seo, alternates: bootstrap.alternates, jsonld: bootstrap.jsonld }, hreflangEnabled);
569
+ }
570
+ writeCriticalCache(targetLocale, normalizedPath, {
571
+ map: criticalMap,
572
+ exclusions,
573
+ loading_bg_color: bootstrap?.loading_bg_color && /^#[0-9a-fA-F]{6}$/.test(bootstrap.loading_bg_color) ? bootstrap.loading_bg_color : null,
574
+ });
575
+ disablePrehide();
364
576
  // Delayed retry scan to catch late-rendering content
365
577
  retryTimeoutRef.current = setTimeout(() => {
366
578
  // Don't scan if we're navigating (prevents React conflicts)
@@ -372,24 +584,48 @@ navigateRef, // For path mode routing
372
584
  applyActiveTranslations(document.body);
373
585
  }
374
586
  if (autoApplyRules) {
375
- const rules = domRulesCacheRef.current.get(cacheKey) || domRules || [];
587
+ const rules = domRulesCacheRef.current.get(cacheKey) || [];
376
588
  applyDomRules(rules);
377
589
  }
378
590
  }, 500);
379
- if (showOverlay) {
380
- setTimeout(() => setIsNavigationLoading(false), 100);
381
- }
591
+ // Lazy-load the full page bundle after first paint.
592
+ void (async () => {
593
+ const bundle = await apiRef.current.fetchBundle(targetLocale, currentPath);
594
+ if (!bundle || !bundle.map)
595
+ return;
596
+ const translations = toTranslations(bundle.map, targetLocale);
597
+ if (translations.length === 0)
598
+ return;
599
+ translationCacheRef.current.set(cacheKey, { translations });
600
+ setActiveTranslations(translations);
601
+ if (mode === "dom") {
602
+ applyActiveTranslations(document.body);
603
+ }
604
+ })();
382
605
  }
383
606
  catch (error) {
384
607
  errorDebug('Error loading translations:', error);
385
- if (showOverlay)
386
- setIsNavigationLoading(false);
608
+ disablePrehide();
387
609
  }
388
610
  finally {
389
611
  setIsLoading(false);
390
612
  isNavigatingRef.current = false;
391
613
  }
392
- }, [autoApplyRules, defaultLocale, mode]);
614
+ }, [
615
+ applySeoBundle,
616
+ autoApplyRules,
617
+ defaultLocale,
618
+ disablePrehide,
619
+ enablePrehide,
620
+ enhancedPathConfig,
621
+ getCachedLoadingBgColor,
622
+ isSeoActive,
623
+ mode,
624
+ readCriticalCache,
625
+ setCachedLoadingBgColor,
626
+ toTranslations,
627
+ writeCriticalCache,
628
+ ]);
393
629
  // SPA router hook-in: track History API navigations (React Router/Next/etc) without app changes.
394
630
  useEffect(() => {
395
631
  const historyObj = window.history;
@@ -405,7 +641,7 @@ navigateRef, // For path mode routing
405
641
  const nextLocale = detectLocale();
406
642
  if (nextLocale !== locale) {
407
643
  setLocaleState(nextLocale);
408
- void loadData(nextLocale, locale, false);
644
+ void loadData(nextLocale, locale);
409
645
  }
410
646
  else if (mode === "dom" && nextLocale !== defaultLocale) {
411
647
  applyActiveTranslations(document.body);
@@ -444,10 +680,6 @@ navigateRef, // For path mode routing
444
680
  warnDebug('Failed to save locale to localStorage:', e);
445
681
  }
446
682
  isInternalNavigationRef.current = true;
447
- // Show navigation overlay immediately (only when a non-default locale is involved)
448
- if (locale !== defaultLocale || newLocale !== defaultLocale) {
449
- setIsNavigationLoading(true);
450
- }
451
683
  // Prevent MutationObserver work during the switch to avoid React conflicts
452
684
  isNavigatingRef.current = true;
453
685
  // Update URL based on routing strategy
@@ -480,24 +712,17 @@ navigateRef, // For path mode routing
480
712
  window.history.pushState({}, '', url.toString());
481
713
  }
482
714
  setLocaleState(newLocale);
483
- // Always load data with overlay when switching locales to provide feedback and ensure cleanup
484
- await loadData(newLocale, previousLocale, true);
715
+ await loadData(newLocale, previousLocale);
485
716
  })().finally(() => {
486
717
  isInternalNavigationRef.current = false;
487
718
  });
488
- }, [allLocales, locale, routing, loadData, defaultLocale, navigateRef]);
719
+ }, [allLocales, locale, routing, loadData, navigateRef]);
489
720
  // No string-level "misses" from the client: the pipeline (Browser Rendering) is source-of-truth for string discovery.
490
721
  // Initialize
491
722
  useEffect(() => {
492
723
  const initialLocale = detectLocale();
493
- setLocaleState(initialLocale);
494
724
  // Track initial page (fallback discovery for pages not present in the routes feed).
495
725
  apiRef.current.trackPageview(window.location.pathname + window.location.search);
496
- // Fetch tier/entitlements early (so the badge can render even on default locale)
497
- apiRef.current.fetchEntitlements(initialLocale).then((next) => {
498
- if (next)
499
- setEntitlements(next);
500
- });
501
726
  // Always prefetch artifacts for the initial locale (pipeline-produced translations + rules).
502
727
  loadData(initialLocale);
503
728
  // Set up keyboard shortcut for edit mode
@@ -541,80 +766,6 @@ navigateRef, // For path mode routing
541
766
  };
542
767
  }
543
768
  }, [sitemap, resolvedApiKey, apiBase, isSeoActive]);
544
- // Watch for route changes (browser back/forward + SPA navigation)
545
- useEffect(() => {
546
- const handlePopState = () => {
547
- if (isInternalNavigationRef.current)
548
- return;
549
- apiRef.current.trackPageview(window.location.pathname + window.location.search);
550
- const newLocale = detectLocale();
551
- const previousLocale = locale;
552
- // Show navigation overlay immediately
553
- if (locale !== defaultLocale || newLocale !== defaultLocale) {
554
- setIsNavigationLoading(true);
555
- }
556
- // SET NAVIGATION FLAG (MutationObserver callback will ignore while navigating)
557
- isNavigatingRef.current = true;
558
- if (newLocale !== locale) {
559
- setLocaleState(newLocale);
560
- // Load translations immediately (no delay needed with overlay)
561
- loadData(newLocale, previousLocale, true);
562
- }
563
- else if (locale !== defaultLocale) {
564
- // Same locale but NEW PATH - fetch translations for this path
565
- // Load translations immediately (no delay needed with overlay)
566
- loadData(locale, previousLocale, true);
567
- }
568
- else {
569
- // Going back to default locale on same path - hide overlay
570
- setIsNavigationLoading(false);
571
- }
572
- };
573
- // Patch history.pushState and replaceState to detect SPA navigation
574
- const originalPushState = history.pushState;
575
- const originalReplaceState = history.replaceState;
576
- const handleNavigation = () => {
577
- if (isInternalNavigationRef.current)
578
- return;
579
- apiRef.current.trackPageview(window.location.pathname + window.location.search);
580
- const newLocale = detectLocale();
581
- const previousLocale = locale;
582
- // Show navigation overlay immediately
583
- if (locale !== defaultLocale || newLocale !== defaultLocale) {
584
- setIsNavigationLoading(true);
585
- }
586
- // SET NAVIGATION FLAG (MutationObserver callback will ignore while navigating)
587
- isNavigatingRef.current = true;
588
- if (newLocale !== locale) {
589
- setLocaleState(newLocale);
590
- // Load translations immediately (no delay needed with overlay)
591
- loadData(newLocale, previousLocale, true);
592
- }
593
- else if (locale !== defaultLocale) {
594
- // Same locale but NEW PATH - fetch translations for this path
595
- // Load translations immediately (no delay needed with overlay)
596
- loadData(locale, previousLocale, true);
597
- }
598
- else {
599
- // Navigating on default locale - hide overlay
600
- setIsNavigationLoading(false);
601
- }
602
- };
603
- history.pushState = function (...args) {
604
- originalPushState.apply(history, args);
605
- handleNavigation();
606
- };
607
- history.replaceState = function (...args) {
608
- originalReplaceState.apply(history, args);
609
- handleNavigation();
610
- };
611
- window.addEventListener('popstate', handlePopState);
612
- return () => {
613
- window.removeEventListener('popstate', handlePopState);
614
- history.pushState = originalPushState;
615
- history.replaceState = originalReplaceState;
616
- };
617
- }, [locale, detectLocale, loadData, defaultLocale]);
618
769
  // PATH mode: auto-prefix internal links that are missing a locale segment.
619
770
  // This prevents "losing" the current locale when the app renders absolute links like "/projects/slug"
620
771
  // while the user is on "/de/...".
@@ -766,6 +917,83 @@ navigateRef, // For path mode routing
766
917
  document.removeEventListener('click', onClickCapture, true);
767
918
  };
768
919
  }, [routing, autoPrefixLinks, allLocales, locale, navigateRef]);
920
+ // Navigation prefetch: warm the HTTP cache for bootstrap + page bundle before the user clicks (reduces EN→FR flash on SPA route changes).
921
+ useEffect(() => {
922
+ if (!resolvedApiKey)
923
+ return;
924
+ if (typeof window === "undefined" || typeof document === "undefined")
925
+ return;
926
+ const connection = navigator?.connection;
927
+ if (connection?.saveData)
928
+ return;
929
+ if (typeof connection?.effectiveType === "string" && /(^|-)2g$/.test(connection.effectiveType))
930
+ return;
931
+ const prefetched = new Set();
932
+ // Why: cap speculative requests to avoid flooding the network on pages with many links.
933
+ const maxPrefetch = 40;
934
+ const isAssetPath = (pathname) => {
935
+ if (pathname === "/robots.txt" || pathname === "/sitemap.xml")
936
+ return true;
937
+ if (pathname.startsWith("/.well-known/"))
938
+ return true;
939
+ 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);
940
+ };
941
+ const pickLocaleForUrl = (url) => {
942
+ if (routing === "path") {
943
+ const segment = url.pathname.split("/")[1] || "";
944
+ if (segment && allLocales.includes(segment))
945
+ return segment;
946
+ return locale;
947
+ }
948
+ const q = url.searchParams.get("t") || url.searchParams.get("locale");
949
+ if (q && allLocales.includes(q))
950
+ return q;
951
+ return locale;
952
+ };
953
+ const onIntent = (event) => {
954
+ if (prefetched.size >= maxPrefetch)
955
+ return;
956
+ const target = event.target;
957
+ const anchor = target?.closest?.("a[href]");
958
+ if (!anchor)
959
+ return;
960
+ const href = anchor.getAttribute("href") || "";
961
+ if (!href || /^(?:#|mailto:|tel:|sms:|javascript:)/i.test(href))
962
+ return;
963
+ let url;
964
+ try {
965
+ url = new URL(href, window.location.origin);
966
+ }
967
+ catch {
968
+ return;
969
+ }
970
+ if (url.origin !== window.location.origin)
971
+ return;
972
+ if (isAssetPath(url.pathname))
973
+ return;
974
+ const targetLocale = pickLocaleForUrl(url);
975
+ if (!targetLocale || targetLocale === defaultLocale)
976
+ return;
977
+ const normalizedPath = processPath(url.pathname, enhancedPathConfig);
978
+ const key = `${targetLocale}:${normalizedPath}`;
979
+ if (prefetched.has(key))
980
+ return;
981
+ prefetched.add(key);
982
+ const pathParam = `${url.pathname}${url.search}`;
983
+ const bootstrapUrl = `${apiBase}/functions/v1/bootstrap?key=${encodeURIComponent(resolvedApiKey)}&locale=${encodeURIComponent(targetLocale)}&path=${encodeURIComponent(pathParam)}`;
984
+ const bundleUrl = `${apiBase}/functions/v1/bundle?key=${encodeURIComponent(resolvedApiKey)}&locale=${encodeURIComponent(targetLocale)}&path=${encodeURIComponent(pathParam)}`;
985
+ void fetch(bootstrapUrl, { cache: "force-cache" }).catch(() => undefined);
986
+ void fetch(bundleUrl, { cache: "force-cache" }).catch(() => undefined);
987
+ };
988
+ document.addEventListener("pointerover", onIntent, { passive: true });
989
+ document.addEventListener("touchstart", onIntent, { passive: true });
990
+ document.addEventListener("focusin", onIntent);
991
+ return () => {
992
+ document.removeEventListener("pointerover", onIntent);
993
+ document.removeEventListener("touchstart", onIntent);
994
+ document.removeEventListener("focusin", onIntent);
995
+ };
996
+ }, [allLocales, apiBase, defaultLocale, enhancedPathConfig, locale, resolvedApiKey, routing]);
769
997
  // Dynamic DOM updates are handled by the marker engine observer (React-safe in-place text/attr updates).
770
998
  // No periodic string-miss reporting. Page discovery is tracked via pageview only.
771
999
  const translateElement = useCallback((element) => {
@@ -802,6 +1030,5 @@ navigateRef, // For path mode routing
802
1030
  children,
803
1031
  React.createElement(LanguageSwitcher, { locales: allLocales, currentLocale: locale, onLocaleChange: setLocale, position: switcherPosition, offsetY: switcherOffsetY, theme: switcherTheme, branding: entitlements?.brandingRequired
804
1032
  ? { required: true, href: "https://lovalingo.com" }
805
- : undefined }),
806
- React.createElement(NavigationOverlay, { isVisible: isNavigationLoading })));
1033
+ : undefined })));
807
1034
  };
@@ -21,6 +21,34 @@ export type SeoBundleResponse = {
21
21
  seoEnabled?: boolean;
22
22
  entitlements?: ProjectEntitlements;
23
23
  };
24
+ export type BootstrapResponse = {
25
+ locale?: string;
26
+ normalized_path?: string;
27
+ routing_strategy?: string;
28
+ loading_bg_color?: string | null;
29
+ seoEnabled?: boolean;
30
+ entitlements?: ProjectEntitlements;
31
+ alternates?: {
32
+ canonical?: string;
33
+ xDefault?: string;
34
+ languages?: Record<string, string>;
35
+ } | null;
36
+ seo?: Record<string, unknown>;
37
+ jsonld?: Array<{
38
+ type: string;
39
+ json: unknown;
40
+ hash?: string;
41
+ }>;
42
+ dom_rules?: DomRule[];
43
+ exclusions?: unknown[];
44
+ critical?: {
45
+ map?: Record<string, string>;
46
+ keys?: number;
47
+ viewport?: unknown;
48
+ etag?: string;
49
+ };
50
+ etag?: string;
51
+ };
24
52
  export declare class LovalingoAPI {
25
53
  private apiKey;
26
54
  private apiBase;
@@ -28,6 +56,7 @@ export declare class LovalingoAPI {
28
56
  private entitlements;
29
57
  constructor(apiKey: string, apiBase: string, pathConfig?: PathNormalizationConfig);
30
58
  private hasApiKey;
59
+ private buildPathParam;
31
60
  private warnMissingApiKey;
32
61
  private logActivationRequired;
33
62
  private isActivationRequiredPayload;
@@ -37,10 +66,11 @@ export declare class LovalingoAPI {
37
66
  fetchSeoBundle(localeHint: string): Promise<SeoBundleResponse | null>;
38
67
  trackPageview(pathOrUrl: string): Promise<void>;
39
68
  fetchTranslations(sourceLocale: string, targetLocale: string): Promise<Translation[]>;
40
- fetchBundle(localeHint: string): Promise<{
69
+ fetchBundle(localeHint: string, pathOrUrl?: string): Promise<{
41
70
  map: Record<string, string>;
42
71
  hashMap: Record<string, string>;
43
72
  } | null>;
73
+ fetchBootstrap(localeHint: string, pathOrUrl?: string): Promise<BootstrapResponse | null>;
44
74
  fetchExclusions(): Promise<Exclusion[]>;
45
75
  fetchDomRules(targetLocale: string): Promise<DomRule[]>;
46
76
  saveExclusion(selector: string, type: 'css' | 'xpath'): Promise<void>;
package/dist/utils/api.js CHANGED
@@ -1,4 +1,3 @@
1
- import { processPath } from './pathNormalizer';
2
1
  import { warnDebug, errorDebug } from './logger';
3
2
  export class LovalingoAPI {
4
3
  constructor(apiKey, apiBase, pathConfig) {
@@ -10,6 +9,23 @@ export class LovalingoAPI {
10
9
  hasApiKey() {
11
10
  return typeof this.apiKey === 'string' && this.apiKey.trim().length > 0;
12
11
  }
12
+ buildPathParam(pathOrUrl) {
13
+ if (typeof window === "undefined")
14
+ return "/";
15
+ const input = (pathOrUrl || "").toString().trim();
16
+ if (!input)
17
+ return window.location.pathname + window.location.search;
18
+ try {
19
+ if (/^https?:\/\//i.test(input)) {
20
+ const url = new URL(input);
21
+ return url.pathname + url.search;
22
+ }
23
+ }
24
+ catch {
25
+ // ignore invalid URL strings
26
+ }
27
+ return input;
28
+ }
13
29
  warnMissingApiKey(action) {
14
30
  // Avoid hard-crashing apps; make the failure mode obvious.
15
31
  warnDebug(`[Lovalingo] Missing public project key: ${action} was skipped. Pass publicAnonKey to <LovalingoProvider ...> (or set VITE_LOVALINGO_PUBLIC_ANON_KEY).`);
@@ -44,8 +60,8 @@ export class LovalingoAPI {
44
60
  this.warnMissingApiKey('fetchEntitlements');
45
61
  return null;
46
62
  }
47
- const normalizedPath = processPath(window.location.pathname, this.pathConfig);
48
- const response = await fetch(`${this.apiBase}/functions/v1/bundle?key=${this.apiKey}&locale=${localeHint}&path=${normalizedPath}`);
63
+ const pathParam = this.buildPathParam();
64
+ const response = await fetch(`${this.apiBase}/functions/v1/bundle?key=${this.apiKey}&locale=${encodeURIComponent(localeHint)}&path=${encodeURIComponent(pathParam)}`);
49
65
  if (this.isActivationRequiredResponse(response)) {
50
66
  this.logActivationRequired('fetchEntitlements', response);
51
67
  return null;
@@ -76,17 +92,23 @@ export class LovalingoAPI {
76
92
  this.warnMissingApiKey("fetchSeoBundle");
77
93
  return null;
78
94
  }
79
- const normalizedPath = processPath(window.location.pathname, this.pathConfig);
80
- const response = await fetch(`${this.apiBase}/functions/v1/seo-bundle?key=${this.apiKey}&locale=${encodeURIComponent(localeHint)}&path=${encodeURIComponent(normalizedPath)}`, { cache: "no-store" });
95
+ const pathParam = this.buildPathParam();
96
+ const requestUrl = `${this.apiBase}/functions/v1/seo-bundle?key=${this.apiKey}&locale=${encodeURIComponent(localeHint)}&path=${encodeURIComponent(pathParam)}`;
97
+ const response = await fetch(requestUrl);
81
98
  if (this.isActivationRequiredResponse(response)) {
82
99
  this.logActivationRequired("fetchSeoBundle", response);
83
100
  return null;
84
101
  }
85
- if (!response.ok)
102
+ const resolvedResponse = response.status === 304 ? await fetch(requestUrl, { cache: "force-cache" }) : response;
103
+ if (resolvedResponse !== response && this.isActivationRequiredResponse(resolvedResponse)) {
104
+ this.logActivationRequired("fetchSeoBundle", resolvedResponse);
86
105
  return null;
87
- const data = (await response.json());
88
- if (this.isActivationRequiredResponse(response, data)) {
89
- this.logActivationRequired("fetchSeoBundle", response);
106
+ }
107
+ if (!resolvedResponse.ok)
108
+ return null;
109
+ const data = (await resolvedResponse.json());
110
+ if (this.isActivationRequiredResponse(resolvedResponse, data)) {
111
+ this.logActivationRequired("fetchSeoBundle", resolvedResponse);
90
112
  return null;
91
113
  }
92
114
  return (data || null);
@@ -99,11 +121,7 @@ export class LovalingoAPI {
99
121
  try {
100
122
  if (!this.hasApiKey())
101
123
  return;
102
- const response = await fetch(`${this.apiBase}/functions/v1/pageview`, {
103
- method: "POST",
104
- headers: { "Content-Type": "application/json" },
105
- body: JSON.stringify({ key: this.apiKey, path: pathOrUrl }),
106
- });
124
+ const response = await fetch(`${this.apiBase}/functions/v1/pageview?key=${encodeURIComponent(this.apiKey)}&path=${encodeURIComponent(pathOrUrl)}`, { method: "GET", keepalive: true });
107
125
  if (response.status === 403) {
108
126
  // Tracking should never block app behavior; keep logging consistent.
109
127
  this.logActivationRequired("trackPageview", response);
@@ -134,23 +152,29 @@ export class LovalingoAPI {
134
152
  return [];
135
153
  }
136
154
  }
137
- async fetchBundle(localeHint) {
155
+ async fetchBundle(localeHint, pathOrUrl) {
138
156
  try {
139
157
  if (!this.hasApiKey()) {
140
158
  this.warnMissingApiKey("fetchBundle");
141
159
  return null;
142
160
  }
143
- const normalizedPath = processPath(window.location.pathname, this.pathConfig);
144
- const response = await fetch(`${this.apiBase}/functions/v1/bundle?key=${this.apiKey}&locale=${localeHint}&path=${normalizedPath}`);
161
+ const pathParam = this.buildPathParam(pathOrUrl);
162
+ const requestUrl = `${this.apiBase}/functions/v1/bundle?key=${this.apiKey}&locale=${encodeURIComponent(localeHint)}&path=${encodeURIComponent(pathParam)}&scoped=1`;
163
+ const response = await fetch(requestUrl);
145
164
  if (this.isActivationRequiredResponse(response)) {
146
165
  this.logActivationRequired("fetchBundle", response);
147
166
  return null;
148
167
  }
149
- if (!response.ok)
168
+ const resolvedResponse = response.status === 304 ? await fetch(requestUrl, { cache: "force-cache" }) : response;
169
+ if (resolvedResponse !== response && this.isActivationRequiredResponse(resolvedResponse)) {
170
+ this.logActivationRequired("fetchBundle", resolvedResponse);
150
171
  return null;
151
- const data = await response.json();
152
- if (this.isActivationRequiredResponse(response, data)) {
153
- this.logActivationRequired("fetchBundle", response);
172
+ }
173
+ if (!resolvedResponse.ok)
174
+ return null;
175
+ const data = await resolvedResponse.json();
176
+ if (this.isActivationRequiredResponse(resolvedResponse, data)) {
177
+ this.logActivationRequired("fetchBundle", resolvedResponse);
154
178
  return null;
155
179
  }
156
180
  if (data?.entitlements) {
@@ -167,6 +191,37 @@ export class LovalingoAPI {
167
191
  return null;
168
192
  }
169
193
  }
194
+ async fetchBootstrap(localeHint, pathOrUrl) {
195
+ try {
196
+ if (!this.hasApiKey()) {
197
+ this.warnMissingApiKey("fetchBootstrap");
198
+ return null;
199
+ }
200
+ const pathParam = this.buildPathParam(pathOrUrl);
201
+ const requestUrl = `${this.apiBase}/functions/v1/bootstrap?key=${this.apiKey}&locale=${encodeURIComponent(localeHint)}&path=${encodeURIComponent(pathParam)}`;
202
+ const response = await fetch(requestUrl);
203
+ if (this.isActivationRequiredResponse(response)) {
204
+ this.logActivationRequired("fetchBootstrap", response);
205
+ return null;
206
+ }
207
+ const resolvedResponse = response.status === 304 ? await fetch(requestUrl, { cache: "force-cache" }) : response;
208
+ if (resolvedResponse !== response && this.isActivationRequiredResponse(resolvedResponse)) {
209
+ this.logActivationRequired("fetchBootstrap", resolvedResponse);
210
+ return null;
211
+ }
212
+ if (!resolvedResponse.ok)
213
+ return null;
214
+ const data = (await resolvedResponse.json());
215
+ if (this.isActivationRequiredResponse(resolvedResponse, data)) {
216
+ this.logActivationRequired("fetchBootstrap", resolvedResponse);
217
+ return null;
218
+ }
219
+ return (data || null);
220
+ }
221
+ catch {
222
+ return null;
223
+ }
224
+ }
170
225
  async fetchExclusions() {
171
226
  try {
172
227
  if (!this.hasApiKey()) {
@@ -181,8 +236,28 @@ export class LovalingoAPI {
181
236
  if (!response.ok)
182
237
  throw new Error('Failed to fetch exclusions');
183
238
  const data = await response.json();
184
- // Handle response format { exclusions: [...] }
185
- return Array.isArray(data.exclusions) ? data.exclusions : [];
239
+ // Handle legacy and vNext row shapes.
240
+ const rows = Array.isArray(data.exclusions) ? data.exclusions : [];
241
+ const out = [];
242
+ for (const row of rows) {
243
+ if (!row || typeof row !== "object")
244
+ continue;
245
+ const record = row;
246
+ const selector = (typeof record.selector === "string" ? record.selector : "") ||
247
+ (typeof record.selector_value === "string" ? record.selector_value : "") ||
248
+ (typeof record.selectorValue === "string" ? record.selectorValue : "");
249
+ const type = (typeof record.type === "string" ? record.type : "") ||
250
+ (typeof record.selector_type === "string" ? record.selector_type : "") ||
251
+ (typeof record.selectorType === "string" ? record.selectorType : "");
252
+ const trimmedSelector = selector.trim();
253
+ const trimmedType = type.trim();
254
+ if (!trimmedSelector)
255
+ continue;
256
+ if (trimmedType !== "css" && trimmedType !== "xpath")
257
+ continue;
258
+ out.push({ selector: trimmedSelector, type: trimmedType });
259
+ }
260
+ return out;
186
261
  }
187
262
  catch (error) {
188
263
  errorDebug('Error fetching exclusions:', error);
@@ -195,8 +270,8 @@ export class LovalingoAPI {
195
270
  this.warnMissingApiKey('fetchDomRules');
196
271
  return [];
197
272
  }
198
- const normalizedPath = processPath(window.location.pathname, this.pathConfig);
199
- const response = await fetch(`${this.apiBase}/functions/v1/dom-rules?key=${this.apiKey}&locale=${targetLocale}&path=${normalizedPath}`);
273
+ const pathParam = this.buildPathParam();
274
+ const response = await fetch(`${this.apiBase}/functions/v1/dom-rules?key=${this.apiKey}&locale=${encodeURIComponent(targetLocale)}&path=${encodeURIComponent(pathParam)}`);
200
275
  if (this.isActivationRequiredResponse(response)) {
201
276
  this.logActivationRequired('fetchDomRules', response);
202
277
  return [];
@@ -32,6 +32,11 @@ export type DomScanResult = {
32
32
  stats: MarkerStats;
33
33
  segments: DomScanSegment[];
34
34
  occurrences: DomScanOccurrence[];
35
+ critical_occurrences?: DomScanOccurrence[];
36
+ viewport?: {
37
+ width: number;
38
+ height: number;
39
+ };
35
40
  truncated: boolean;
36
41
  };
37
42
  export declare function startMarkerEngine(options?: MarkerEngineOptions): typeof stopMarkerEngine;
@@ -1,5 +1,8 @@
1
1
  import { hashContent } from "./hash";
2
+ // Why: keep marker scans cheap while still capturing a small above-the-fold "critical slice" for first paint.
2
3
  const DEFAULT_THROTTLE_MS = 150;
4
+ const DEFAULT_CRITICAL_BUFFER_PX = 200;
5
+ const DEFAULT_CRITICAL_MAX = 800;
3
6
  const EXCLUDE_SELECTOR = "[data-lovalingo-exclude],[data-notranslate],[translate-no],[data-no-translate]";
4
7
  const UNSAFE_CONTAINER_TAGS = new Set(["script", "style", "noscript", "template", "svg", "canvas"]);
5
8
  const ATTRIBUTE_MARKS = [
@@ -46,7 +49,7 @@ function setGlobalStats(stats) {
46
49
  if (!g.__lovalingo.dom)
47
50
  g.__lovalingo.dom = {};
48
51
  g.__lovalingo.dom.getStats = () => lastStats;
49
- g.__lovalingo.dom.scan = () => scanDom({ maxSegments: 20000 });
52
+ g.__lovalingo.dom.scan = () => scanDom({ maxSegments: 20000, includeCritical: true });
50
53
  g.__lovalingo.dom.apply = (bundle) => ({ applied: applyTranslationMap(bundle, document.body) });
51
54
  g.__lovalingo.dom.restore = () => restoreDom(document.body);
52
55
  }
@@ -172,7 +175,34 @@ function getOrInitAttrOriginal(el, attr) {
172
175
  map.set(attr, value);
173
176
  return value;
174
177
  }
175
- function considerTextNode(node, stats, segments, occurrences, seen, maxSegments) {
178
+ function isInViewport(rect, viewportHeight, bufferPx) {
179
+ if (!rect)
180
+ return false;
181
+ if (!Number.isFinite(rect.top) || !Number.isFinite(rect.bottom))
182
+ return false;
183
+ if (rect.width <= 0 || rect.height <= 0)
184
+ return false;
185
+ return rect.bottom > -bufferPx && rect.top < viewportHeight + bufferPx;
186
+ }
187
+ function getTextNodeRect(node) {
188
+ try {
189
+ const range = document.createRange();
190
+ range.selectNodeContents(node);
191
+ const rect = range.getBoundingClientRect();
192
+ if (rect && rect.width > 0 && rect.height > 0)
193
+ return rect;
194
+ }
195
+ catch {
196
+ // ignore
197
+ }
198
+ try {
199
+ return node.parentElement ? node.parentElement.getBoundingClientRect() : null;
200
+ }
201
+ catch {
202
+ return null;
203
+ }
204
+ }
205
+ function considerTextNode(node, stats, segments, occurrences, seen, maxSegments, critical) {
176
206
  const raw = node.nodeValue || "";
177
207
  if (!raw)
178
208
  return;
@@ -217,9 +247,16 @@ function considerTextNode(node, stats, segments, occurrences, seen, maxSegments)
217
247
  seen.add(originalText);
218
248
  occurrences.push({ source_text: originalText, semantic_context: "text" });
219
249
  }
250
+ if (critical?.enabled && originalText && !critical.seen.has(originalText)) {
251
+ const rect = getTextNodeRect(node);
252
+ if (isInViewport(rect, critical.viewportHeight, critical.bufferPx)) {
253
+ critical.seen.add(originalText);
254
+ critical.occurrences.push({ source_text: originalText, semantic_context: "critical:text" });
255
+ }
256
+ }
220
257
  }
221
258
  }
222
- function considerAttributes(root, segments, occurrences, seen, maxSegments) {
259
+ function considerAttributes(root, segments, occurrences, seen, maxSegments, critical) {
223
260
  const nodes = root.querySelectorAll("[title],[aria-label],[placeholder]");
224
261
  nodes.forEach((el) => {
225
262
  if (isExcludedElement(el))
@@ -249,6 +286,19 @@ function considerAttributes(root, segments, occurrences, seen, maxSegments) {
249
286
  seen.add(original);
250
287
  occurrences.push({ source_text: original, semantic_context: `attr:${attr}` });
251
288
  }
289
+ if (critical?.enabled && original && !critical.seen.has(original)) {
290
+ let rect = null;
291
+ try {
292
+ rect = el.getBoundingClientRect();
293
+ }
294
+ catch {
295
+ rect = null;
296
+ }
297
+ if (isInViewport(rect, critical.viewportHeight, critical.bufferPx)) {
298
+ critical.seen.add(original);
299
+ critical.occurrences.push({ source_text: original, semantic_context: `critical:attr:${attr}` });
300
+ }
301
+ }
252
302
  }
253
303
  });
254
304
  }
@@ -273,6 +323,20 @@ function scanDom(opts) {
273
323
  }
274
324
  const stats = buildEmptyStats();
275
325
  const maxSegments = Math.max(0, Math.floor(opts.maxSegments || 0)) || 20000;
326
+ const includeCritical = opts.includeCritical === true;
327
+ const viewportHeight = includeCritical ? Math.max(0, Math.floor(window.innerHeight || 0)) : 0;
328
+ const viewportWidth = includeCritical ? Math.max(0, Math.floor(window.innerWidth || 0)) : 0;
329
+ // Why: include a small buffer so "near the fold" text is ready without delaying first paint.
330
+ const critical = includeCritical
331
+ ? {
332
+ enabled: true,
333
+ viewportHeight,
334
+ bufferPx: DEFAULT_CRITICAL_BUFFER_PX,
335
+ max: DEFAULT_CRITICAL_MAX,
336
+ seen: new Set(),
337
+ occurrences: [],
338
+ }
339
+ : null;
276
340
  const walker = document.createTreeWalker(root, NodeFilter.SHOW_TEXT);
277
341
  const nodes = [];
278
342
  const segments = [];
@@ -284,8 +348,13 @@ function scanDom(opts) {
284
348
  nodes.push(node);
285
349
  node = walker.nextNode();
286
350
  }
287
- nodes.forEach((textNode) => considerTextNode(textNode, stats, segments, occurrences, seen, maxSegments));
288
- considerAttributes(root, segments, occurrences, seen, maxSegments);
351
+ nodes.forEach((textNode) => {
352
+ if (critical?.enabled && critical.occurrences.length >= critical.max) {
353
+ critical.enabled = false;
354
+ }
355
+ considerTextNode(textNode, stats, segments, occurrences, seen, maxSegments, critical);
356
+ });
357
+ considerAttributes(root, segments, occurrences, seen, maxSegments, critical);
289
358
  finalizeStats(stats);
290
359
  setGlobalStats(stats);
291
360
  const truncated = segments.length >= maxSegments;
@@ -294,6 +363,12 @@ function scanDom(opts) {
294
363
  stats,
295
364
  segments,
296
365
  occurrences,
366
+ ...(includeCritical
367
+ ? {
368
+ critical_occurrences: critical?.occurrences ?? [],
369
+ viewport: { width: viewportWidth, height: viewportHeight },
370
+ }
371
+ : {}),
297
372
  truncated,
298
373
  };
299
374
  }
@@ -376,7 +451,7 @@ export function setActiveTranslations(translations) {
376
451
  }
377
452
  const map = new Map();
378
453
  for (const t of translations) {
379
- const source = (t?.source_text || "").toString().trim();
454
+ const source = normalizeWhitespace((t?.source_text || "").toString());
380
455
  const translated = (t?.translated_text ?? "").toString();
381
456
  if (!source || !translated)
382
457
  continue;
@@ -389,7 +464,7 @@ function applyTranslationMap(bundle, root) {
389
464
  return 0;
390
465
  const map = new Map();
391
466
  for (const [k, v] of Object.entries(bundle || {})) {
392
- const source = (k || "").toString().trim();
467
+ const source = normalizeWhitespace((k || "").toString());
393
468
  const translated = (v ?? "").toString();
394
469
  if (!source || !translated)
395
470
  continue;
@@ -426,7 +501,8 @@ export function applyActiveTranslations(root = document.body) {
426
501
  if (!isTranslatableText(trimmed))
427
502
  continue;
428
503
  const original = getOrInitTextOriginal(textNode, parent);
429
- const translation = map.get(original.trimmed);
504
+ const key = normalizeWhitespace(original.trimmed);
505
+ const translation = map.get(key);
430
506
  if (!translation)
431
507
  continue;
432
508
  const next = `${original.leading}${translation}${original.trailing}`;
package/dist/version.d.ts CHANGED
@@ -1 +1 @@
1
- export declare const VERSION = "0.2.0";
1
+ export declare const VERSION = "0.3.0";
package/dist/version.js CHANGED
@@ -1 +1 @@
1
- export const VERSION = "0.2.0";
1
+ export const VERSION = "0.3.0";
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@lovalingo/lovalingo",
3
- "version": "0.2.0",
3
+ "version": "0.3.0",
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",