@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")
|
package/dist/utils/api.d.ts
CHANGED
|
@@ -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
|
|
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
|
|
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