@lovalingo/lovalingo 0.5.1 → 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.
@@ -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';
@@ -77,6 +77,9 @@ navigateRef, // For path mode routing
77
77
  }, [apiBase, enhancedPathConfig, resolvedApiKey]);
78
78
  const [entitlements, setEntitlements] = useState(() => apiRef.current.getEntitlements());
79
79
  const lastPageviewRef = useRef("");
80
+ const lastPageviewFingerprintRef = useRef("");
81
+ const pageviewFingerprintTimeoutRef = useRef(null);
82
+ const pageviewFingerprintRetryTimeoutRef = useRef(null);
80
83
  const lastNormalizedPathRef = useRef("");
81
84
  const historyPatchedRef = useRef(false);
82
85
  const originalHistoryRef = useRef(null);
@@ -159,6 +162,7 @@ navigateRef, // For path mode routing
159
162
  }, [brandingStorageKey]);
160
163
  useEffect(() => {
161
164
  lastPageviewRef.current = "";
165
+ lastPageviewFingerprintRef.current = "";
162
166
  }, [resolvedApiKey]);
163
167
  const trackPageviewOnce = useCallback((path) => {
164
168
  const next = (path || "").toString();
@@ -168,6 +172,28 @@ navigateRef, // For path mode routing
168
172
  return;
169
173
  lastPageviewRef.current = next;
170
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);
171
197
  }, []);
172
198
  const enablePrehide = useCallback((bgColor) => {
173
199
  if (typeof document === "undefined")
@@ -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.1",
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",