@lovalingo/lovalingo 0.5.0 → 0.5.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
@@ -205,6 +205,7 @@ Lovalingo includes a floating language switcher.
205
205
  publicAnonKey="aix_xxx"
206
206
  defaultLocale="en"
207
207
  locales={["en", "de", "fr"]}
208
+ overlayBgColor="#ffffff" // optional: background during the no-flash prehide phase (default: #ffffff)
208
209
  switcherPosition="bottom-right"
209
210
  switcherOffsetY={20}
210
211
  switcherTheme="light" // "dark" | "light" (default: "dark")
@@ -213,6 +214,16 @@ Lovalingo includes a floating language switcher.
213
214
  </LovalingoProvider>
214
215
  ```
215
216
 
217
+ ## Overlay Background (No-Flash UX)
218
+
219
+ Lovalingo may briefly hide the page while it loads translations to avoid a visible EN→DE flash.
220
+
221
+ Set `overlayBgColor` to match your app background so the fallback looks seamless:
222
+
223
+ ```tsx
224
+ <LovalingoProvider overlayBgColor="#0b0b0b" ... />
225
+ ```
226
+
216
227
  ## SEO (Canonical + hreflang)
217
228
 
218
229
  Lovalingo can keep `<head>` SEO signals in sync with the active locale:
@@ -3,7 +3,7 @@ import { LovalingoContext } from '../context/LovalingoContext';
3
3
  import { LovalingoAPI } from '../utils/api';
4
4
  import { applyDomRules } from '../utils/domRules';
5
5
  import { hashContent } from '../utils/hash';
6
- import { applyActiveTranslations, restoreDom, setActiveTranslations, setMarkerEngineExclusions, startMarkerEngine } from '../utils/markerEngine';
6
+ import { applyActiveTranslations, getCriticalFingerprint, restoreDom, setActiveTranslations, setMarkerEngineExclusions, startMarkerEngine } from '../utils/markerEngine';
7
7
  import { logDebug, warnDebug, errorDebug } from '../utils/logger';
8
8
  import { processPath } from '../utils/pathNormalizer';
9
9
  import { LanguageSwitcher } from './LanguageSwitcher';
@@ -11,8 +11,10 @@ const LOCALE_STORAGE_KEY = 'Lovalingo_locale';
11
11
  const LOADING_BG_STORAGE_PREFIX = "Lovalingo_loading_bg_color";
12
12
  const BRANDING_STORAGE_PREFIX = "Lovalingo_branding_enabled";
13
13
  const CRITICAL_CACHE_PREFIX = "Lovalingo_critical_v0_3";
14
+ // Why: avoid long blank screens on blocked/untranslated routes by always revealing the original page quickly.
15
+ const PREHIDE_FAILSAFE_MS = 1700;
14
16
  export const LovalingoProvider = ({ children, apiKey: apiKeyProp, publicAnonKey, defaultLocale, locales, apiBase = 'https://cdn.lovalingo.com', routing = 'path', // Default to path mode (SEO-friendly, recommended)
15
- autoPrefixLinks = true, autoApplyRules = true, switcherPosition = 'bottom-right', switcherOffsetY = 20, switcherTheme = 'dark', editMode: initialEditMode = false, editKey = 'KeyE', pathNormalization = { enabled: true }, // Enable by default
17
+ autoPrefixLinks = true, overlayBgColor, autoApplyRules = true, switcherPosition = 'bottom-right', switcherOffsetY = 20, switcherTheme = 'dark', editMode: initialEditMode = false, editKey = 'KeyE', pathNormalization = { enabled: true }, // Enable by default
16
18
  mode = 'dom', // Default to legacy DOM mode for backward compatibility
17
19
  sitemap = true, // Default: true - Auto-inject sitemap link tag
18
20
  seo = true, // Default: true - Can be disabled per project entitlements
@@ -75,12 +77,18 @@ navigateRef, // For path mode routing
75
77
  }, [apiBase, enhancedPathConfig, resolvedApiKey]);
76
78
  const [entitlements, setEntitlements] = useState(() => apiRef.current.getEntitlements());
77
79
  const lastPageviewRef = useRef("");
80
+ const lastPageviewFingerprintRef = useRef("");
81
+ const pageviewFingerprintTimeoutRef = useRef(null);
82
+ const pageviewFingerprintRetryTimeoutRef = useRef(null);
83
+ const lastNormalizedPathRef = useRef("");
78
84
  const historyPatchedRef = useRef(false);
79
85
  const originalHistoryRef = useRef(null);
80
86
  const onNavigateRef = useRef(() => undefined);
81
87
  const retryTimeoutRef = useRef(null);
88
+ const loadingFailsafeTimeoutRef = useRef(null);
82
89
  const isNavigatingRef = useRef(false);
83
90
  const isInternalNavigationRef = useRef(false);
91
+ const inFlightLoadKeyRef = useRef(null);
84
92
  const translationCacheRef = useRef(new Map());
85
93
  const exclusionsCacheRef = useRef(null);
86
94
  const domRulesCacheRef = useRef(new Map());
@@ -109,6 +117,9 @@ navigateRef, // For path mode routing
109
117
  prevBodyBg: "",
110
118
  });
111
119
  const getCachedLoadingBgColor = useCallback(() => {
120
+ const configured = (overlayBgColor || "").toString().trim();
121
+ if (/^#[0-9a-fA-F]{6}$/.test(configured))
122
+ return configured;
112
123
  try {
113
124
  const cached = localStorage.getItem(loadingBgStorageKey) || "";
114
125
  if (/^#[0-9a-fA-F]{6}$/.test(cached.trim()))
@@ -118,7 +129,7 @@ navigateRef, // For path mode routing
118
129
  // ignore
119
130
  }
120
131
  return "#ffffff";
121
- }, [loadingBgStorageKey]);
132
+ }, [loadingBgStorageKey, overlayBgColor]);
122
133
  const setCachedLoadingBgColor = useCallback((color) => {
123
134
  const next = (color || "").toString().trim();
124
135
  if (!/^#[0-9a-fA-F]{6}$/.test(next))
@@ -130,6 +141,13 @@ navigateRef, // For path mode routing
130
141
  // ignore
131
142
  }
132
143
  }, [loadingBgStorageKey]);
144
+ useEffect(() => {
145
+ // Why: make `overlayBgColor` the source of truth while keeping the existing cache key for backwards compatibility.
146
+ const configured = (overlayBgColor || "").toString().trim();
147
+ if (!/^#[0-9a-fA-F]{6}$/.test(configured))
148
+ return;
149
+ setCachedLoadingBgColor(configured);
150
+ }, [overlayBgColor, setCachedLoadingBgColor]);
133
151
  const setCachedBrandingEnabled = useCallback((enabled) => {
134
152
  try {
135
153
  localStorage.setItem(brandingStorageKey, enabled === false ? "0" : "1");
@@ -144,6 +162,7 @@ navigateRef, // For path mode routing
144
162
  }, [brandingStorageKey]);
145
163
  useEffect(() => {
146
164
  lastPageviewRef.current = "";
165
+ lastPageviewFingerprintRef.current = "";
147
166
  }, [resolvedApiKey]);
148
167
  const trackPageviewOnce = useCallback((path) => {
149
168
  const next = (path || "").toString();
@@ -153,6 +172,28 @@ navigateRef, // For path mode routing
153
172
  return;
154
173
  lastPageviewRef.current = next;
155
174
  apiRef.current.trackPageview(next);
175
+ const trySendFingerprint = () => {
176
+ if (typeof window === "undefined")
177
+ return;
178
+ const markersReady = window.__lovalingoMarkersReady === true;
179
+ if (!markersReady)
180
+ return;
181
+ const fp = getCriticalFingerprint();
182
+ if (!fp || fp.critical_count <= 0)
183
+ return;
184
+ const signature = `${next}|${fp.critical_hash}|${fp.critical_count}`;
185
+ if (lastPageviewFingerprintRef.current === signature)
186
+ return;
187
+ lastPageviewFingerprintRef.current = signature;
188
+ apiRef.current.trackPageview(next, fp);
189
+ };
190
+ if (pageviewFingerprintTimeoutRef.current != null)
191
+ window.clearTimeout(pageviewFingerprintTimeoutRef.current);
192
+ if (pageviewFingerprintRetryTimeoutRef.current != null)
193
+ window.clearTimeout(pageviewFingerprintRetryTimeoutRef.current);
194
+ // Why: wait briefly for markers/content to settle before computing a critical fingerprint for change detection.
195
+ pageviewFingerprintTimeoutRef.current = window.setTimeout(trySendFingerprint, 800);
196
+ pageviewFingerprintRetryTimeoutRef.current = window.setTimeout(trySendFingerprint, 2000);
156
197
  }, []);
157
198
  const enablePrehide = useCallback((bgColor) => {
158
199
  if (typeof document === "undefined")
@@ -176,12 +217,10 @@ navigateRef, // For path mode routing
176
217
  body.style.backgroundColor = bgColor;
177
218
  }
178
219
  if (state.timeoutId != null) {
179
- window.clearTimeout(state.timeoutId);
220
+ return;
180
221
  }
181
- // Why: avoid leaving the page hidden forever if the network is blocked or the project has no translations yet.
182
- state.timeoutId = window.setTimeout(() => {
183
- disablePrehide();
184
- }, 2500);
222
+ // Why: avoid a "perma-hide" when navigation events repeatedly re-trigger prehide and keep extending the timeout.
223
+ state.timeoutId = window.setTimeout(() => disablePrehide(), PREHIDE_FAILSAFE_MS);
185
224
  }, []);
186
225
  const disablePrehide = useCallback(() => {
187
226
  if (typeof document === "undefined")
@@ -266,6 +305,7 @@ navigateRef, // For path mode routing
266
305
  apiBase,
267
306
  routing,
268
307
  autoPrefixLinks,
308
+ overlayBgColor,
269
309
  switcherPosition,
270
310
  switcherOffsetY,
271
311
  switcherTheme,
@@ -501,6 +541,10 @@ navigateRef, // For path mode routing
501
541
  clearTimeout(retryTimeoutRef.current);
502
542
  retryTimeoutRef.current = null;
503
543
  }
544
+ if (loadingFailsafeTimeoutRef.current != null) {
545
+ window.clearTimeout(loadingFailsafeTimeoutRef.current);
546
+ loadingFailsafeTimeoutRef.current = null;
547
+ }
504
548
  // If switching to default locale, clear translations and translate with empty map
505
549
  // This will show original text using stored data-Lovalingo-original-html
506
550
  if (targetLocale === defaultLocale) {
@@ -513,6 +557,10 @@ navigateRef, // For path mode routing
513
557
  const currentPath = window.location.pathname + window.location.search;
514
558
  const normalizedPath = processPath(window.location.pathname, enhancedPathConfig);
515
559
  const cacheKey = `${targetLocale}:${normalizedPath}`;
560
+ if (inFlightLoadKeyRef.current === cacheKey) {
561
+ return;
562
+ }
563
+ inFlightLoadKeyRef.current = cacheKey;
516
564
  // Check if we have cached translations for this locale + path
517
565
  const cachedEntry = translationCacheRef.current.get(cacheKey);
518
566
  const cachedExclusions = exclusionsCacheRef.current;
@@ -553,12 +601,20 @@ navigateRef, // For path mode routing
553
601
  }, 500);
554
602
  disablePrehide();
555
603
  isNavigatingRef.current = false;
604
+ if (inFlightLoadKeyRef.current === cacheKey) {
605
+ inFlightLoadKeyRef.current = null;
606
+ }
556
607
  return;
557
608
  }
558
609
  // CACHE MISS - Fetch from API
559
610
  logDebug(`[Lovalingo] Fetching translations for ${targetLocale} on ${normalizedPath}`);
560
611
  setIsLoading(true);
561
612
  enablePrehide(getCachedLoadingBgColor());
613
+ // Why: never keep the app hidden/blocked for longer than the UX budget; show the original content if translations aren't ready fast.
614
+ loadingFailsafeTimeoutRef.current = window.setTimeout(() => {
615
+ disablePrehide();
616
+ setIsLoading(false);
617
+ }, PREHIDE_FAILSAFE_MS);
562
618
  try {
563
619
  if (previousLocale && previousLocale !== defaultLocale) {
564
620
  logDebug(`[Lovalingo] Switching from ${previousLocale} to ${targetLocale}`);
@@ -673,7 +729,14 @@ navigateRef, // For path mode routing
673
729
  }
674
730
  finally {
675
731
  setIsLoading(false);
732
+ if (loadingFailsafeTimeoutRef.current != null) {
733
+ window.clearTimeout(loadingFailsafeTimeoutRef.current);
734
+ loadingFailsafeTimeoutRef.current = null;
735
+ }
676
736
  isNavigatingRef.current = false;
737
+ if (inFlightLoadKeyRef.current === cacheKey) {
738
+ inFlightLoadKeyRef.current = null;
739
+ }
677
740
  }
678
741
  }, [
679
742
  applySeoBundle,
@@ -694,15 +757,25 @@ navigateRef, // For path mode routing
694
757
  onNavigateRef.current = () => {
695
758
  trackPageviewOnce(window.location.pathname + window.location.search);
696
759
  const nextLocale = detectLocale();
760
+ const normalizedPath = processPath(window.location.pathname, enhancedPathConfig);
761
+ const normalizedPathChanged = normalizedPath !== lastNormalizedPathRef.current;
762
+ lastNormalizedPathRef.current = normalizedPath;
763
+ // Why: bundles are path-scoped, so SPA navigations within the same locale must trigger a reload for the new route.
764
+ if (normalizedPathChanged && nextLocale !== defaultLocale && !isInternalNavigationRef.current) {
765
+ void loadData(nextLocale, locale);
766
+ return;
767
+ }
697
768
  if (nextLocale !== locale) {
698
769
  setLocaleState(nextLocale);
699
- void loadData(nextLocale, locale);
770
+ if (!isInternalNavigationRef.current) {
771
+ void loadData(nextLocale, locale);
772
+ }
700
773
  }
701
774
  else if (mode === "dom" && nextLocale !== defaultLocale) {
702
775
  applyActiveTranslations(document.body);
703
776
  }
704
777
  };
705
- }, [defaultLocale, detectLocale, loadData, locale, mode, trackPageviewOnce]);
778
+ }, [defaultLocale, detectLocale, enhancedPathConfig, loadData, locale, mode, trackPageviewOnce]);
706
779
  // SPA router hook-in: patch History API once (prevents stacked wrappers → request storms).
707
780
  useEffect(() => {
708
781
  if (typeof window === "undefined")
@@ -801,6 +874,7 @@ navigateRef, // For path mode routing
801
874
  // Initialize
802
875
  useEffect(() => {
803
876
  const initialLocale = detectLocale();
877
+ lastNormalizedPathRef.current = processPath(window.location.pathname, enhancedPathConfig);
804
878
  // Track initial page (fallback discovery for pages not present in the routes feed).
805
879
  trackPageviewOnce(window.location.pathname + window.location.search);
806
880
  // Always prefetch artifacts for the initial locale (pipeline-produced translations + rules).
@@ -820,7 +894,7 @@ navigateRef, // For path mode routing
820
894
  clearTimeout(retryTimeoutRef.current);
821
895
  }
822
896
  };
823
- }, [detectLocale, loadData, editKey, trackPageviewOnce]);
897
+ }, [detectLocale, enhancedPathConfig, loadData, editKey, trackPageviewOnce]);
824
898
  // Auto-inject sitemap link tag
825
899
  useEffect(() => {
826
900
  if (sitemap && resolvedApiKey && isSeoActive()) {
@@ -2,12 +2,6 @@ import React from 'react';
2
2
  import { Link } from 'react-router-dom';
3
3
  import { useLang } from '../hooks/useLang';
4
4
  const NON_LOCALIZED_APP_PATHS = new Set([
5
- 'auth',
6
- 'login',
7
- 'signup',
8
- 'sign-in',
9
- 'sign-up',
10
- 'register',
11
5
  'robots.txt',
12
6
  'sitemap.xml',
13
7
  ]);
@@ -2,12 +2,6 @@ import React, { useEffect } from 'react';
2
2
  import { BrowserRouter, Routes, Route, Navigate, Outlet, useLocation, useNavigate } from 'react-router-dom';
3
3
  import { LangContext } from '../context/LangContext';
4
4
  const NON_LOCALIZED_APP_PATHS = new Set([
5
- '/auth',
6
- '/login',
7
- '/signup',
8
- '/sign-in',
9
- '/sign-up',
10
- '/register',
11
5
  '/robots.txt',
12
6
  '/sitemap.xml',
13
7
  ]);
@@ -35,7 +29,7 @@ function NavigateExporter({ navigateRef }) {
35
29
  */
36
30
  function LangGuard({ defaultLang, lang }) {
37
31
  const location = useLocation();
38
- // If the URL is language-prefixed but the underlying route is non-localized (auth/login/signup),
32
+ // If the URL is language-prefixed but the underlying route is non-localized (e.g. robots/sitemap),
39
33
  // redirect to the canonical non-localized path.
40
34
  const prefix = `/${lang}`;
41
35
  const restPath = location.pathname.startsWith(prefix) ? location.pathname.slice(prefix.length) || '/' : location.pathname;
@@ -3,12 +3,6 @@ import { useCallback } from 'react';
3
3
  import { useLang } from './useLang';
4
4
  //Globally excluded paths.
5
5
  const NON_LOCALIZED_APP_PATHS = new Set([
6
- 'auth',
7
- 'login',
8
- 'signup',
9
- 'sign-in',
10
- 'sign-up',
11
- 'register',
12
6
  'robots.txt',
13
7
  'sitemap.xml',
14
8
  ]);
package/dist/types.d.ts CHANGED
@@ -10,6 +10,7 @@ export interface LovalingoConfig {
10
10
  apiBase?: string;
11
11
  routing?: 'query' | 'path';
12
12
  autoPrefixLinks?: boolean;
13
+ overlayBgColor?: string;
13
14
  switcherPosition?: 'bottom-right' | 'bottom-left' | 'top-right' | 'top-left';
14
15
  switcherOffsetY?: number;
15
16
  switcherTheme?: 'dark' | 'light';
@@ -65,7 +65,10 @@ export declare class LovalingoAPI {
65
65
  getEntitlements(): ProjectEntitlements | null;
66
66
  fetchEntitlements(localeHint: string): Promise<ProjectEntitlements | null>;
67
67
  fetchSeoBundle(localeHint: string): Promise<SeoBundleResponse | null>;
68
- trackPageview(pathOrUrl: string): Promise<void>;
68
+ trackPageview(pathOrUrl: string, opts?: {
69
+ critical_count?: number;
70
+ critical_hash?: string;
71
+ }): Promise<void>;
69
72
  fetchTranslations(sourceLocale: string, targetLocale: string): Promise<Translation[]>;
70
73
  fetchBundle(localeHint: string, pathOrUrl?: string): Promise<{
71
74
  map: Record<string, string>;
package/dist/utils/api.js CHANGED
@@ -117,11 +117,23 @@ export class LovalingoAPI {
117
117
  return null;
118
118
  }
119
119
  }
120
- async trackPageview(pathOrUrl) {
120
+ async trackPageview(pathOrUrl, opts) {
121
121
  try {
122
122
  if (!this.hasApiKey())
123
123
  return;
124
- const response = await fetch(`${this.apiBase}/functions/v1/pageview?key=${encodeURIComponent(this.apiKey)}&path=${encodeURIComponent(pathOrUrl)}`, { method: "GET", keepalive: true });
124
+ const params = new URLSearchParams();
125
+ params.set("key", this.apiKey);
126
+ params.set("path", pathOrUrl);
127
+ const count = opts?.critical_count;
128
+ const hash = (opts?.critical_hash || "").toString().trim().toLowerCase();
129
+ if (typeof count === "number" && Number.isFinite(count) && count > 0 && count <= 5000 && /^[a-z0-9]{1,40}$/.test(hash)) {
130
+ params.set("critical_count", String(Math.floor(count)));
131
+ params.set("critical_hash", hash);
132
+ }
133
+ const response = await fetch(`${this.apiBase}/functions/v1/pageview?${params.toString()}`, {
134
+ method: "GET",
135
+ keepalive: true,
136
+ });
125
137
  if (response.status === 403) {
126
138
  // Tracking should never block app behavior; keep logging consistent.
127
139
  this.logActivationRequired("trackPageview", response);
@@ -39,6 +39,15 @@ export type DomScanResult = {
39
39
  };
40
40
  truncated: boolean;
41
41
  };
42
+ export type CriticalFingerprint = {
43
+ critical_count: number;
44
+ critical_hash: string;
45
+ viewport: {
46
+ width: number;
47
+ height: number;
48
+ };
49
+ };
50
+ export declare function getCriticalFingerprint(): CriticalFingerprint;
42
51
  export declare function startMarkerEngine(options?: MarkerEngineOptions): typeof stopMarkerEngine;
43
52
  export declare function stopMarkerEngine(): void;
44
53
  export declare function getMarkerStats(): MarkerStats;
@@ -50,6 +50,7 @@ function setGlobalStats(stats) {
50
50
  g.__lovalingo.dom = {};
51
51
  g.__lovalingo.dom.getStats = () => lastStats;
52
52
  g.__lovalingo.dom.scan = () => scanDom({ maxSegments: 20000, includeCritical: true });
53
+ g.__lovalingo.dom.getCriticalFingerprint = () => getCriticalFingerprint();
53
54
  g.__lovalingo.dom.apply = (bundle) => ({ applied: applyTranslationMap(bundle, document.body) });
54
55
  g.__lovalingo.dom.restore = () => restoreDom(document.body);
55
56
  }
@@ -314,6 +315,98 @@ function finalizeStats(stats) {
314
315
  stats.coverageRatio = eligibleNodes > 0 ? stats.markedNodes / eligibleNodes : 1;
315
316
  stats.coverageRatioChars = eligibleChars > 0 ? stats.markedChars / eligibleChars : 1;
316
317
  }
318
+ function scanCriticalTexts() {
319
+ const root = document.body;
320
+ const viewportHeight = Math.max(0, Math.floor(window.innerHeight || 0));
321
+ const viewportWidth = Math.max(0, Math.floor(window.innerWidth || 0));
322
+ const viewport = { width: viewportWidth, height: viewportHeight };
323
+ if (!root || viewportHeight <= 0)
324
+ return { texts: [], viewport };
325
+ const seen = new Set();
326
+ const texts = [];
327
+ const walker = document.createTreeWalker(root, NodeFilter.SHOW_TEXT);
328
+ let node = walker.nextNode();
329
+ while (node && texts.length < DEFAULT_CRITICAL_MAX) {
330
+ if (node.nodeType !== Node.TEXT_NODE) {
331
+ node = walker.nextNode();
332
+ continue;
333
+ }
334
+ const textNode = node;
335
+ const raw = textNode.nodeValue || "";
336
+ const trimmed = raw.trim();
337
+ if (!trimmed || !isTranslatableText(trimmed)) {
338
+ node = walker.nextNode();
339
+ continue;
340
+ }
341
+ const parent = textNode.parentElement;
342
+ if (!parent || isExcludedElement(parent) || findUnsafeContainer(parent)) {
343
+ node = walker.nextNode();
344
+ continue;
345
+ }
346
+ const original = getOrInitTextOriginal(textNode, parent);
347
+ const originalText = normalizeWhitespace(original.trimmed);
348
+ if (!originalText || seen.has(originalText)) {
349
+ node = walker.nextNode();
350
+ continue;
351
+ }
352
+ const rect = getTextNodeRect(textNode);
353
+ if (!isInViewport(rect, viewportHeight, DEFAULT_CRITICAL_BUFFER_PX)) {
354
+ node = walker.nextNode();
355
+ continue;
356
+ }
357
+ seen.add(originalText);
358
+ texts.push(originalText);
359
+ node = walker.nextNode();
360
+ }
361
+ if (texts.length < DEFAULT_CRITICAL_MAX) {
362
+ const nodes = root.querySelectorAll("[title],[aria-label],[placeholder]");
363
+ nodes.forEach((el) => {
364
+ if (texts.length >= DEFAULT_CRITICAL_MAX)
365
+ return;
366
+ if (isExcludedElement(el) || findUnsafeContainer(el))
367
+ return;
368
+ let rect = null;
369
+ try {
370
+ rect = el.getBoundingClientRect();
371
+ }
372
+ catch {
373
+ rect = null;
374
+ }
375
+ if (!isInViewport(rect, viewportHeight, DEFAULT_CRITICAL_BUFFER_PX))
376
+ return;
377
+ for (const { attr } of ATTRIBUTE_MARKS) {
378
+ if (texts.length >= DEFAULT_CRITICAL_MAX)
379
+ break;
380
+ const value = el.getAttribute(attr);
381
+ if (!value)
382
+ continue;
383
+ const trimmed = value.trim();
384
+ if (!trimmed || !isTranslatableText(trimmed))
385
+ continue;
386
+ const original = normalizeWhitespace(getOrInitAttrOriginal(el, attr));
387
+ if (!original || seen.has(original))
388
+ continue;
389
+ seen.add(original);
390
+ texts.push(original);
391
+ }
392
+ });
393
+ }
394
+ return { texts, viewport };
395
+ }
396
+ export function getCriticalFingerprint() {
397
+ if (typeof window === "undefined" || typeof document === "undefined") {
398
+ return { critical_count: 0, critical_hash: "0", viewport: { width: 0, height: 0 } };
399
+ }
400
+ const { texts, viewport } = scanCriticalTexts();
401
+ const normalized = texts.map((t) => normalizeWhitespace(t)).filter(Boolean);
402
+ // Why: sort to stay stable across minor DOM reordering without affecting the set of critical strings.
403
+ normalized.sort((a, b) => a.localeCompare(b));
404
+ return {
405
+ critical_count: normalized.length,
406
+ critical_hash: hashContent(normalized.join("\n")),
407
+ viewport,
408
+ };
409
+ }
317
410
  function scanDom(opts) {
318
411
  const root = document.body;
319
412
  if (!root) {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@lovalingo/lovalingo",
3
- "version": "0.5.0",
3
+ "version": "0.5.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",