@lovalingo/lovalingo 0.2.1 → 0.3.2

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,74 @@ 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 bootstrap = await apiRef.current.fetchBootstrap(locale, window.location.pathname + window.location.search);
303
+ if (cancelled)
304
+ return;
305
+ if (bootstrap?.entitlements)
306
+ setEntitlements(bootstrap.entitlements);
307
+ if (bootstrap?.loading_bg_color)
308
+ setCachedLoadingBgColor(bootstrap.loading_bg_color);
309
+ })();
310
+ return () => {
311
+ cancelled = true;
312
+ };
313
+ }, [defaultLocale, entitlements, locale, setCachedLoadingBgColor]);
314
+ const applySeoBundle = useCallback((bundle, hreflangEnabled) => {
103
315
  try {
104
316
  const head = document.head;
105
317
  if (!head)
106
318
  return;
107
319
  head.querySelectorAll('link[data-Lovalingo="hreflang"], link[data-Lovalingo="canonical"]').forEach((el) => el.remove());
108
- if (!isSeoActive())
320
+ if (!bundle)
109
321
  return;
110
- const bundle = await apiRef.current.fetchSeoBundle(activeLocale);
111
322
  const seo = (bundle?.seo && typeof bundle.seo === "object" ? bundle.seo : {});
112
323
  const alternates = (bundle?.alternates && typeof bundle.alternates === "object" ? bundle.alternates : {});
113
324
  const setOrCreateMeta = (attrs, content) => {
@@ -203,66 +414,36 @@ navigateRef, // For path mode routing
203
414
  head.appendChild(xDefault);
204
415
  }
205
416
  }
206
- catch (e) {
207
- warnDebug("[Lovalingo] updateSeoLinks() failed:", e);
417
+ catch {
418
+ // ignore SEO errors
208
419
  }
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
420
  }, []);
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
421
+ // Keep <html lang> in sync and apply default-locale SEO (non-default locales use the bootstrap payload).
260
422
  useEffect(() => {
261
423
  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) => {
424
+ if (locale !== defaultLocale)
425
+ return;
426
+ if (!isSeoActive())
427
+ return;
428
+ void apiRef.current.fetchSeoBundle(locale).then((bundle) => {
429
+ applySeoBundle(bundle, Boolean(entitlements?.hreflangEnabled));
430
+ });
431
+ }, [applySeoBundle, defaultLocale, entitlements, isSeoActive, locale, setDocumentLocale]);
432
+ const toTranslations = useCallback((map, targetLocale) => {
433
+ const out = [];
434
+ for (const [source_text, translated_text] of Object.entries(map || {})) {
435
+ if (!source_text || !translated_text)
436
+ continue;
437
+ out.push({
438
+ source_text,
439
+ translated_text,
440
+ source_locale: defaultLocale,
441
+ target_locale: targetLocale,
442
+ });
443
+ }
444
+ return out;
445
+ }, [defaultLocale]);
446
+ const loadData = useCallback(async (targetLocale, previousLocale) => {
266
447
  // Cancel any pending retry scan to prevent race conditions
267
448
  if (retryTimeoutRef.current) {
268
449
  clearTimeout(retryTimeoutRef.current);
@@ -271,23 +452,23 @@ navigateRef, // For path mode routing
271
452
  // If switching to default locale, clear translations and translate with empty map
272
453
  // This will show original text using stored data-Lovalingo-original-html
273
454
  if (targetLocale === defaultLocale) {
274
- if (showOverlay)
275
- setIsNavigationLoading(false);
455
+ disablePrehide();
276
456
  setActiveTranslations(null);
277
457
  restoreDom(document.body); // React-safe: only text/attrs, no DOM structure mutation
278
458
  isNavigatingRef.current = false;
279
459
  return;
280
460
  }
281
- // Create cache key from locale and current path
282
- const currentPath = window.location.pathname;
283
- const cacheKey = `${targetLocale}:${currentPath}`;
461
+ const currentPath = window.location.pathname + window.location.search;
462
+ const normalizedPath = processPath(window.location.pathname, enhancedPathConfig);
463
+ const cacheKey = `${targetLocale}:${normalizedPath}`;
284
464
  // Check if we have cached translations for this locale + path
285
465
  const cachedEntry = translationCacheRef.current.get(cacheKey);
286
466
  const cachedExclusions = exclusionsCacheRef.current;
287
467
  const cachedDomRules = domRulesCacheRef.current.get(cacheKey);
288
468
  if (cachedEntry && cachedExclusions) {
289
469
  // CACHE HIT - Use cached data immediately (FAST!)
290
- logDebug(`[Lovalingo] Using cached translations for ${targetLocale} on ${currentPath}`);
470
+ logDebug(`[Lovalingo] Using cached translations for ${targetLocale} on ${normalizedPath}`);
471
+ enablePrehide(getCachedLoadingBgColor());
291
472
  setActiveTranslations(cachedEntry.translations);
292
473
  setMarkerEngineExclusions(cachedExclusions);
293
474
  if (mode === 'dom') {
@@ -318,49 +499,84 @@ navigateRef, // For path mode routing
318
499
  applyDomRules(rules);
319
500
  }
320
501
  }, 500);
321
- if (showOverlay) {
322
- setTimeout(() => setIsNavigationLoading(false), 50);
323
- }
502
+ disablePrehide();
324
503
  isNavigatingRef.current = false;
325
504
  return;
326
505
  }
327
506
  // CACHE MISS - Fetch from API
328
- logDebug(`[Lovalingo] Fetching translations for ${targetLocale} on ${currentPath}`);
507
+ logDebug(`[Lovalingo] Fetching translations for ${targetLocale} on ${normalizedPath}`);
329
508
  setIsLoading(true);
509
+ enablePrehide(getCachedLoadingBgColor());
330
510
  try {
331
511
  if (previousLocale && previousLocale !== defaultLocale) {
332
512
  logDebug(`[Lovalingo] Switching from ${previousLocale} to ${targetLocale}`);
333
513
  }
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();
514
+ const cachedCritical = readCriticalCache(targetLocale, normalizedPath);
515
+ if (cachedCritical?.loading_bg_color) {
516
+ setCachedLoadingBgColor(cachedCritical.loading_bg_color);
517
+ enablePrehide(cachedCritical.loading_bg_color);
518
+ }
519
+ if (cachedCritical?.exclusions && cachedCritical.exclusions.length > 0) {
520
+ exclusionsCacheRef.current = cachedCritical.exclusions;
521
+ setMarkerEngineExclusions(cachedCritical.exclusions);
522
+ }
523
+ if (cachedCritical?.map && Object.keys(cachedCritical.map).length > 0) {
524
+ setActiveTranslations(toTranslations(cachedCritical.map, targetLocale));
525
+ if (mode === "dom") {
526
+ applyActiveTranslations(document.body);
527
+ }
528
+ disablePrehide();
529
+ }
530
+ const bootstrap = await apiRef.current.fetchBootstrap(targetLocale, currentPath);
531
+ const nextEntitlements = bootstrap?.entitlements || apiRef.current.getEntitlements();
340
532
  if (nextEntitlements)
341
533
  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);
534
+ if (bootstrap?.loading_bg_color) {
535
+ setCachedLoadingBgColor(bootstrap.loading_bg_color);
536
+ enablePrehide(bootstrap.loading_bg_color);
355
537
  }
356
- setActiveTranslations(translations);
538
+ const exclusions = Array.isArray(bootstrap?.exclusions)
539
+ ? bootstrap.exclusions
540
+ .map((row) => {
541
+ if (!row || typeof row !== "object")
542
+ return null;
543
+ const r = row;
544
+ const selector = typeof r.selector === "string" ? r.selector.trim() : "";
545
+ const type = typeof r.type === "string" ? r.type.trim() : "";
546
+ if (!selector)
547
+ return null;
548
+ if (type !== "css" && type !== "xpath")
549
+ return null;
550
+ return { selector, type: type };
551
+ })
552
+ .filter(Boolean)
553
+ : await apiRef.current.fetchExclusions();
554
+ exclusionsCacheRef.current = exclusions;
357
555
  setMarkerEngineExclusions(exclusions);
358
- if (mode === 'dom') {
359
- applyActiveTranslations(document.body);
556
+ const criticalMap = bootstrap?.critical?.map && typeof bootstrap.critical.map === "object" && !Array.isArray(bootstrap.critical.map)
557
+ ? bootstrap.critical.map
558
+ : {};
559
+ if (Object.keys(criticalMap).length > 0) {
560
+ setActiveTranslations(toTranslations(criticalMap, targetLocale));
561
+ if (mode === "dom") {
562
+ applyActiveTranslations(document.body);
563
+ }
360
564
  }
361
565
  if (autoApplyRules) {
566
+ const domRules = Array.isArray(bootstrap?.dom_rules) ? bootstrap.dom_rules : await apiRef.current.fetchDomRules(targetLocale);
567
+ domRulesCacheRef.current.set(cacheKey, domRules);
362
568
  applyDomRules(domRules);
363
569
  }
570
+ if (isSeoActive() && bootstrap) {
571
+ const hreflangEnabled = Boolean((bootstrap.entitlements || nextEntitlements)?.hreflangEnabled);
572
+ applySeoBundle({ seo: bootstrap.seo, alternates: bootstrap.alternates, jsonld: bootstrap.jsonld }, hreflangEnabled);
573
+ }
574
+ writeCriticalCache(targetLocale, normalizedPath, {
575
+ map: criticalMap,
576
+ exclusions,
577
+ loading_bg_color: bootstrap?.loading_bg_color && /^#[0-9a-fA-F]{6}$/.test(bootstrap.loading_bg_color) ? bootstrap.loading_bg_color : null,
578
+ });
579
+ disablePrehide();
364
580
  // Delayed retry scan to catch late-rendering content
365
581
  retryTimeoutRef.current = setTimeout(() => {
366
582
  // Don't scan if we're navigating (prevents React conflicts)
@@ -372,24 +588,48 @@ navigateRef, // For path mode routing
372
588
  applyActiveTranslations(document.body);
373
589
  }
374
590
  if (autoApplyRules) {
375
- const rules = domRulesCacheRef.current.get(cacheKey) || domRules || [];
591
+ const rules = domRulesCacheRef.current.get(cacheKey) || [];
376
592
  applyDomRules(rules);
377
593
  }
378
594
  }, 500);
379
- if (showOverlay) {
380
- setTimeout(() => setIsNavigationLoading(false), 100);
381
- }
595
+ // Lazy-load the full page bundle after first paint.
596
+ void (async () => {
597
+ const bundle = await apiRef.current.fetchBundle(targetLocale, currentPath);
598
+ if (!bundle || !bundle.map)
599
+ return;
600
+ const translations = toTranslations(bundle.map, targetLocale);
601
+ if (translations.length === 0)
602
+ return;
603
+ translationCacheRef.current.set(cacheKey, { translations });
604
+ setActiveTranslations(translations);
605
+ if (mode === "dom") {
606
+ applyActiveTranslations(document.body);
607
+ }
608
+ })();
382
609
  }
383
610
  catch (error) {
384
611
  errorDebug('Error loading translations:', error);
385
- if (showOverlay)
386
- setIsNavigationLoading(false);
612
+ disablePrehide();
387
613
  }
388
614
  finally {
389
615
  setIsLoading(false);
390
616
  isNavigatingRef.current = false;
391
617
  }
392
- }, [autoApplyRules, defaultLocale, mode]);
618
+ }, [
619
+ applySeoBundle,
620
+ autoApplyRules,
621
+ defaultLocale,
622
+ disablePrehide,
623
+ enablePrehide,
624
+ enhancedPathConfig,
625
+ getCachedLoadingBgColor,
626
+ isSeoActive,
627
+ mode,
628
+ readCriticalCache,
629
+ setCachedLoadingBgColor,
630
+ toTranslations,
631
+ writeCriticalCache,
632
+ ]);
393
633
  // SPA router hook-in: track History API navigations (React Router/Next/etc) without app changes.
394
634
  useEffect(() => {
395
635
  const historyObj = window.history;
@@ -405,7 +645,7 @@ navigateRef, // For path mode routing
405
645
  const nextLocale = detectLocale();
406
646
  if (nextLocale !== locale) {
407
647
  setLocaleState(nextLocale);
408
- void loadData(nextLocale, locale, false);
648
+ void loadData(nextLocale, locale);
409
649
  }
410
650
  else if (mode === "dom" && nextLocale !== defaultLocale) {
411
651
  applyActiveTranslations(document.body);
@@ -444,10 +684,6 @@ navigateRef, // For path mode routing
444
684
  warnDebug('Failed to save locale to localStorage:', e);
445
685
  }
446
686
  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
687
  // Prevent MutationObserver work during the switch to avoid React conflicts
452
688
  isNavigatingRef.current = true;
453
689
  // Update URL based on routing strategy
@@ -480,24 +716,17 @@ navigateRef, // For path mode routing
480
716
  window.history.pushState({}, '', url.toString());
481
717
  }
482
718
  setLocaleState(newLocale);
483
- // Always load data with overlay when switching locales to provide feedback and ensure cleanup
484
- await loadData(newLocale, previousLocale, true);
719
+ await loadData(newLocale, previousLocale);
485
720
  })().finally(() => {
486
721
  isInternalNavigationRef.current = false;
487
722
  });
488
- }, [allLocales, locale, routing, loadData, defaultLocale, navigateRef]);
723
+ }, [allLocales, locale, routing, loadData, navigateRef]);
489
724
  // No string-level "misses" from the client: the pipeline (Browser Rendering) is source-of-truth for string discovery.
490
725
  // Initialize
491
726
  useEffect(() => {
492
727
  const initialLocale = detectLocale();
493
- setLocaleState(initialLocale);
494
728
  // Track initial page (fallback discovery for pages not present in the routes feed).
495
729
  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
730
  // Always prefetch artifacts for the initial locale (pipeline-produced translations + rules).
502
731
  loadData(initialLocale);
503
732
  // Set up keyboard shortcut for edit mode
@@ -541,80 +770,6 @@ navigateRef, // For path mode routing
541
770
  };
542
771
  }
543
772
  }, [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
773
  // PATH mode: auto-prefix internal links that are missing a locale segment.
619
774
  // This prevents "losing" the current locale when the app renders absolute links like "/projects/slug"
620
775
  // while the user is on "/de/...".
@@ -766,6 +921,83 @@ navigateRef, // For path mode routing
766
921
  document.removeEventListener('click', onClickCapture, true);
767
922
  };
768
923
  }, [routing, autoPrefixLinks, allLocales, locale, navigateRef]);
924
+ // Navigation prefetch: warm the HTTP cache for bootstrap + page bundle before the user clicks (reduces EN→FR flash on SPA route changes).
925
+ useEffect(() => {
926
+ if (!resolvedApiKey)
927
+ return;
928
+ if (typeof window === "undefined" || typeof document === "undefined")
929
+ return;
930
+ const connection = navigator?.connection;
931
+ if (connection?.saveData)
932
+ return;
933
+ if (typeof connection?.effectiveType === "string" && /(^|-)2g$/.test(connection.effectiveType))
934
+ return;
935
+ const prefetched = new Set();
936
+ // Why: cap speculative requests to avoid flooding the network on pages with many links.
937
+ const maxPrefetch = 40;
938
+ const isAssetPath = (pathname) => {
939
+ if (pathname === "/robots.txt" || pathname === "/sitemap.xml")
940
+ return true;
941
+ if (pathname.startsWith("/.well-known/"))
942
+ return true;
943
+ 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);
944
+ };
945
+ const pickLocaleForUrl = (url) => {
946
+ if (routing === "path") {
947
+ const segment = url.pathname.split("/")[1] || "";
948
+ if (segment && allLocales.includes(segment))
949
+ return segment;
950
+ return locale;
951
+ }
952
+ const q = url.searchParams.get("t") || url.searchParams.get("locale");
953
+ if (q && allLocales.includes(q))
954
+ return q;
955
+ return locale;
956
+ };
957
+ const onIntent = (event) => {
958
+ if (prefetched.size >= maxPrefetch)
959
+ return;
960
+ const target = event.target;
961
+ const anchor = target?.closest?.("a[href]");
962
+ if (!anchor)
963
+ return;
964
+ const href = anchor.getAttribute("href") || "";
965
+ if (!href || /^(?:#|mailto:|tel:|sms:|javascript:)/i.test(href))
966
+ return;
967
+ let url;
968
+ try {
969
+ url = new URL(href, window.location.origin);
970
+ }
971
+ catch {
972
+ return;
973
+ }
974
+ if (url.origin !== window.location.origin)
975
+ return;
976
+ if (isAssetPath(url.pathname))
977
+ return;
978
+ const targetLocale = pickLocaleForUrl(url);
979
+ if (!targetLocale || targetLocale === defaultLocale)
980
+ return;
981
+ const normalizedPath = processPath(url.pathname, enhancedPathConfig);
982
+ const key = `${targetLocale}:${normalizedPath}`;
983
+ if (prefetched.has(key))
984
+ return;
985
+ prefetched.add(key);
986
+ const pathParam = `${url.pathname}${url.search}`;
987
+ const bootstrapUrl = `${apiBase}/functions/v1/bootstrap?key=${encodeURIComponent(resolvedApiKey)}&locale=${encodeURIComponent(targetLocale)}&path=${encodeURIComponent(pathParam)}`;
988
+ const bundleUrl = `${apiBase}/functions/v1/bundle?key=${encodeURIComponent(resolvedApiKey)}&locale=${encodeURIComponent(targetLocale)}&path=${encodeURIComponent(pathParam)}`;
989
+ void fetch(bootstrapUrl, { cache: "force-cache" }).catch(() => undefined);
990
+ void fetch(bundleUrl, { cache: "force-cache" }).catch(() => undefined);
991
+ };
992
+ document.addEventListener("pointerover", onIntent, { passive: true });
993
+ document.addEventListener("touchstart", onIntent, { passive: true });
994
+ document.addEventListener("focusin", onIntent);
995
+ return () => {
996
+ document.removeEventListener("pointerover", onIntent);
997
+ document.removeEventListener("touchstart", onIntent);
998
+ document.removeEventListener("focusin", onIntent);
999
+ };
1000
+ }, [allLocales, apiBase, defaultLocale, enhancedPathConfig, locale, resolvedApiKey, routing]);
769
1001
  // Dynamic DOM updates are handled by the marker engine observer (React-safe in-place text/attr updates).
770
1002
  // No periodic string-miss reporting. Page discovery is tracked via pageview only.
771
1003
  const translateElement = useCallback((element) => {
@@ -802,6 +1034,5 @@ navigateRef, // For path mode routing
802
1034
  children,
803
1035
  React.createElement(LanguageSwitcher, { locales: allLocales, currentLocale: locale, onLocaleChange: setLocale, position: switcherPosition, offsetY: switcherOffsetY, theme: switcherTheme, branding: entitlements?.brandingRequired
804
1036
  ? { required: true, href: "https://lovalingo.com" }
805
- : undefined }),
806
- React.createElement(NavigationOverlay, { isVisible: isNavigationLoading })));
1037
+ : undefined })));
807
1038
  };
@@ -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
  }
package/dist/version.d.ts CHANGED
@@ -1 +1 @@
1
- export declare const VERSION = "0.2.0";
1
+ export declare const VERSION = "0.3.2";
package/dist/version.js CHANGED
@@ -1 +1 @@
1
- export const VERSION = "0.2.0";
1
+ export const VERSION = "0.3.2";
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@lovalingo/lovalingo",
3
- "version": "0.2.1",
3
+ "version": "0.3.2",
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",