@lovalingo/lovalingo 0.5.25 → 0.5.28
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/dist/__tests__/languageFlags.test.d.ts +1 -0
- package/dist/__tests__/languageFlags.test.js +42 -0
- package/dist/__tests__/mergeEntitlements.test.d.ts +1 -0
- package/dist/__tests__/mergeEntitlements.test.js +27 -0
- package/dist/components/LanguageSwitcher.js +80 -53
- package/dist/components/LovalingoProvider.js +18 -473
- package/dist/components/provider/__tests__/seoUtils.test.d.ts +1 -0
- package/dist/components/provider/__tests__/seoUtils.test.js +13 -0
- package/dist/components/provider/editModeUtils.d.ts +6 -0
- package/dist/components/provider/editModeUtils.js +59 -0
- package/dist/components/provider/localeUtils.d.ts +8 -0
- package/dist/components/provider/localeUtils.js +46 -0
- package/dist/components/provider/providerConstants.d.ts +12 -0
- package/dist/components/provider/providerConstants.js +11 -0
- package/dist/components/provider/seoUtils.d.ts +8 -0
- package/dist/components/provider/seoUtils.js +118 -0
- package/dist/components/provider/useEditModeOverlay.d.ts +7 -0
- package/dist/components/provider/useEditModeOverlay.js +134 -0
- package/dist/components/provider/useHistoryNavigationPatch.d.ts +3 -0
- package/dist/components/provider/useHistoryNavigationPatch.js +47 -0
- package/dist/components/provider/useProviderCache.d.ts +12 -0
- package/dist/components/provider/useProviderCache.js +82 -0
- package/dist/hooks/provider/useBundleLoading.d.ts +2 -1
- package/dist/hooks/provider/useBundleLoading.js +15 -3
- package/dist/utils/api.d.ts +3 -78
- package/dist/utils/api.js +1 -53
- package/dist/utils/apiTypes.d.ts +78 -0
- package/dist/utils/apiTypes.js +1 -0
- package/dist/utils/apiUtils.d.ts +4 -0
- package/dist/utils/apiUtils.js +54 -0
- package/dist/utils/languageFlags.d.ts +7 -0
- package/dist/utils/languageFlags.js +90 -0
- package/dist/utils/markerEngine.d.ts +8 -66
- package/dist/utils/markerEngine.js +19 -703
- package/dist/utils/markerEngineApply.d.ts +3 -0
- package/dist/utils/markerEngineApply.js +136 -0
- package/dist/utils/markerEngineConstants.d.ts +10 -0
- package/dist/utils/markerEngineConstants.js +12 -0
- package/dist/utils/markerEngineCritical.d.ts +2 -0
- package/dist/utils/markerEngineCritical.js +98 -0
- package/dist/utils/markerEngineDomUtils.d.ts +8 -0
- package/dist/utils/markerEngineDomUtils.js +74 -0
- package/dist/utils/markerEngineFilters.d.ts +2 -0
- package/dist/utils/markerEngineFilters.js +26 -0
- package/dist/utils/markerEngineMisses.d.ts +5 -0
- package/dist/utils/markerEngineMisses.js +81 -0
- package/dist/utils/markerEngineOriginals.d.ts +5 -0
- package/dist/utils/markerEngineOriginals.js +29 -0
- package/dist/utils/markerEngineScan.d.ts +5 -0
- package/dist/utils/markerEngineScan.js +162 -0
- package/dist/utils/markerEngineState.d.ts +4 -0
- package/dist/utils/markerEngineState.js +14 -0
- package/dist/utils/markerEngineStats.d.ts +3 -0
- package/dist/utils/markerEngineStats.js +28 -0
- package/dist/utils/markerEngineTranslations.d.ts +3 -0
- package/dist/utils/markerEngineTranslations.js +49 -0
- package/dist/utils/markerEngineTypes.d.ts +62 -0
- package/dist/utils/markerEngineTypes.js +1 -0
- package/dist/utils/markerEngineViewport.d.ts +2 -0
- package/dist/utils/markerEngineViewport.js +27 -0
- package/dist/utils/mergeEntitlements.d.ts +2 -0
- package/dist/utils/mergeEntitlements.js +7 -0
- package/dist/version.d.ts +1 -1
- package/dist/version.js +1 -1
- package/package.json +1 -1
- package/dist/utils/translator.d.ts +0 -80
- package/dist/utils/translator.js +0 -802
|
@@ -1,41 +1,21 @@
|
|
|
1
|
-
import {
|
|
2
|
-
|
|
3
|
-
|
|
4
|
-
|
|
5
|
-
|
|
6
|
-
|
|
7
|
-
|
|
8
|
-
|
|
9
|
-
{ attr: "title", marker: "data-lovalingo-title-original" },
|
|
10
|
-
{ attr: "aria-label", marker: "data-lovalingo-aria-label-original" },
|
|
11
|
-
{ attr: "placeholder", marker: "data-lovalingo-placeholder-original" },
|
|
12
|
-
];
|
|
13
|
-
const unsafeSelector = Array.from(UNSAFE_CONTAINER_TAGS).join(",");
|
|
1
|
+
import { DEFAULT_THROTTLE_MS } from "./markerEngineConstants";
|
|
2
|
+
import { applyActiveTranslations, applyTranslationMap, restoreDom } from "./markerEngineApply";
|
|
3
|
+
import { getCriticalFingerprint } from "./markerEngineCritical";
|
|
4
|
+
import { scanDom } from "./markerEngineScan";
|
|
5
|
+
import { scanDomForMisses } from "./markerEngineMisses";
|
|
6
|
+
import { addActiveTranslations, setActiveTranslations } from "./markerEngineTranslations";
|
|
7
|
+
import { buildEmptyStats } from "./markerEngineStats";
|
|
8
|
+
import { getActiveTranslationMap, setCustomExcludeSelector } from "./markerEngineState";
|
|
14
9
|
let observer = null;
|
|
15
10
|
let scheduled = null;
|
|
16
11
|
let running = false;
|
|
17
12
|
let lastStats = buildEmptyStats();
|
|
18
13
|
let throttleMs = DEFAULT_THROTTLE_MS;
|
|
19
14
|
let applying = false;
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
function buildEmptyStats() {
|
|
25
|
-
return {
|
|
26
|
-
totalTextNodes: 0,
|
|
27
|
-
markedNodes: 0,
|
|
28
|
-
skippedUnsafeNodes: 0,
|
|
29
|
-
skippedExcludedNodes: 0,
|
|
30
|
-
skippedNonTranslatableNodes: 0,
|
|
31
|
-
totalChars: 0,
|
|
32
|
-
markedChars: 0,
|
|
33
|
-
skippedUnsafeChars: 0,
|
|
34
|
-
skippedExcludedChars: 0,
|
|
35
|
-
skippedNonTranslatableChars: 0,
|
|
36
|
-
coverageRatio: 0,
|
|
37
|
-
coverageRatioChars: 0,
|
|
38
|
-
};
|
|
15
|
+
function scanDomWithGlobals(opts) {
|
|
16
|
+
const result = scanDom(opts);
|
|
17
|
+
setGlobalStats(result.stats);
|
|
18
|
+
return result;
|
|
39
19
|
}
|
|
40
20
|
function setGlobalStats(stats) {
|
|
41
21
|
lastStats = stats;
|
|
@@ -49,422 +29,11 @@ function setGlobalStats(stats) {
|
|
|
49
29
|
if (!g.__lovalingo.dom)
|
|
50
30
|
g.__lovalingo.dom = {};
|
|
51
31
|
g.__lovalingo.dom.getStats = () => lastStats;
|
|
52
|
-
g.__lovalingo.dom.scan = () =>
|
|
32
|
+
g.__lovalingo.dom.scan = () => scanDomWithGlobals({ maxSegments: 20000, includeCritical: true });
|
|
53
33
|
g.__lovalingo.dom.getCriticalFingerprint = () => getCriticalFingerprint();
|
|
54
34
|
g.__lovalingo.dom.apply = (bundle) => ({ applied: applyTranslationMap(bundle, document.body) });
|
|
55
35
|
g.__lovalingo.dom.restore = () => restoreDom(document.body);
|
|
56
36
|
}
|
|
57
|
-
function isExcludedElement(el) {
|
|
58
|
-
if (!el)
|
|
59
|
-
return false;
|
|
60
|
-
if (el.closest(EXCLUDE_SELECTOR))
|
|
61
|
-
return true;
|
|
62
|
-
if (customExcludeSelector) {
|
|
63
|
-
try {
|
|
64
|
-
if (el.closest(customExcludeSelector))
|
|
65
|
-
return true;
|
|
66
|
-
}
|
|
67
|
-
catch {
|
|
68
|
-
// ignore invalid selector strings
|
|
69
|
-
}
|
|
70
|
-
}
|
|
71
|
-
return false;
|
|
72
|
-
}
|
|
73
|
-
function findUnsafeContainer(el) {
|
|
74
|
-
if (!el)
|
|
75
|
-
return null;
|
|
76
|
-
if (!unsafeSelector)
|
|
77
|
-
return null;
|
|
78
|
-
return el.closest(unsafeSelector);
|
|
79
|
-
}
|
|
80
|
-
function getStableKey(el) {
|
|
81
|
-
const owner = el.closest("[data-lovalingo-key]");
|
|
82
|
-
const key = owner?.getAttribute("data-lovalingo-key") || "";
|
|
83
|
-
return key.trim();
|
|
84
|
-
}
|
|
85
|
-
function getElementIndex(el) {
|
|
86
|
-
const parent = el.parentElement;
|
|
87
|
-
if (!parent)
|
|
88
|
-
return 0;
|
|
89
|
-
const children = Array.from(parent.children);
|
|
90
|
-
const idx = children.indexOf(el);
|
|
91
|
-
return idx >= 0 ? idx : 0;
|
|
92
|
-
}
|
|
93
|
-
function getTextNodeIndex(node) {
|
|
94
|
-
let index = 0;
|
|
95
|
-
let prev = node.previousSibling;
|
|
96
|
-
while (prev) {
|
|
97
|
-
if (prev.nodeType === Node.TEXT_NODE)
|
|
98
|
-
index += 1;
|
|
99
|
-
prev = prev.previousSibling;
|
|
100
|
-
}
|
|
101
|
-
return index;
|
|
102
|
-
}
|
|
103
|
-
function buildElementPath(el) {
|
|
104
|
-
const parts = [];
|
|
105
|
-
let current = el;
|
|
106
|
-
while (current && current.tagName && current !== document.body) {
|
|
107
|
-
const tag = current.tagName.toLowerCase();
|
|
108
|
-
const idx = getElementIndex(current);
|
|
109
|
-
parts.push(`${tag}[${idx}]`);
|
|
110
|
-
current = current.parentElement;
|
|
111
|
-
}
|
|
112
|
-
parts.push("body");
|
|
113
|
-
return parts.reverse().join("/");
|
|
114
|
-
}
|
|
115
|
-
function normalizeWhitespace(value) {
|
|
116
|
-
return (value || "").toString().replace(/\s+/g, " ").trim();
|
|
117
|
-
}
|
|
118
|
-
function isTranslatableText(text) {
|
|
119
|
-
if (!text || text.trim().length < 2)
|
|
120
|
-
return false;
|
|
121
|
-
if (/^(__[A-Z0-9_]+__\s*)+$/.test(text))
|
|
122
|
-
return false;
|
|
123
|
-
if (/^\d+(\.\d+)?$/.test(text))
|
|
124
|
-
return false;
|
|
125
|
-
if (!/[a-zA-Z\u00C0-\u024F\u1E00-\u1EFF]/.test(text))
|
|
126
|
-
return false;
|
|
127
|
-
return true;
|
|
128
|
-
}
|
|
129
|
-
function buildStableId(el, text, textIndex) {
|
|
130
|
-
const key = getStableKey(el);
|
|
131
|
-
const path = buildElementPath(el);
|
|
132
|
-
const raw = `${path}#text[${textIndex}]|${text.trim()}|${key}`;
|
|
133
|
-
return hashContent(raw);
|
|
134
|
-
}
|
|
135
|
-
function buildSelector(el) {
|
|
136
|
-
const id = el.id;
|
|
137
|
-
if (id)
|
|
138
|
-
return `#${id.replace(/[^a-zA-Z0-9_-]/g, "\\$&")}`;
|
|
139
|
-
const className = el.className;
|
|
140
|
-
if (typeof className === "string" && className.trim()) {
|
|
141
|
-
const classes = className
|
|
142
|
-
.split(/\s+/)
|
|
143
|
-
.map((c) => c.trim())
|
|
144
|
-
.filter(Boolean)
|
|
145
|
-
.slice(0, 3)
|
|
146
|
-
.map((c) => `.${c.replace(/[^a-zA-Z0-9_-]/g, "\\$&")}`)
|
|
147
|
-
.join("");
|
|
148
|
-
if (classes)
|
|
149
|
-
return classes;
|
|
150
|
-
}
|
|
151
|
-
return null;
|
|
152
|
-
}
|
|
153
|
-
function getOrInitTextOriginal(node, parent) {
|
|
154
|
-
const existing = originalTextByNode.get(node);
|
|
155
|
-
if (existing)
|
|
156
|
-
return existing;
|
|
157
|
-
const raw = node.nodeValue || "";
|
|
158
|
-
const leading = raw.match(/^\s*/)?.[0] ?? "";
|
|
159
|
-
const trailing = raw.match(/\s*$/)?.[0] ?? "";
|
|
160
|
-
const trimmed = raw.trim();
|
|
161
|
-
const id = buildStableId(parent, trimmed, getTextNodeIndex(node));
|
|
162
|
-
const created = { raw, trimmed, leading, trailing, id };
|
|
163
|
-
originalTextByNode.set(node, created);
|
|
164
|
-
return created;
|
|
165
|
-
}
|
|
166
|
-
function getOrInitAttrOriginal(el, attr) {
|
|
167
|
-
let map = originalAttrByEl.get(el);
|
|
168
|
-
if (!map) {
|
|
169
|
-
map = new Map();
|
|
170
|
-
originalAttrByEl.set(el, map);
|
|
171
|
-
}
|
|
172
|
-
const existing = map.get(attr);
|
|
173
|
-
if (existing != null)
|
|
174
|
-
return existing;
|
|
175
|
-
const value = (el.getAttribute(attr) || "").toString();
|
|
176
|
-
map.set(attr, value);
|
|
177
|
-
return value;
|
|
178
|
-
}
|
|
179
|
-
function isInViewport(rect, viewportHeight, bufferPx) {
|
|
180
|
-
if (!rect)
|
|
181
|
-
return false;
|
|
182
|
-
if (!Number.isFinite(rect.top) || !Number.isFinite(rect.bottom))
|
|
183
|
-
return false;
|
|
184
|
-
if (rect.width <= 0 || rect.height <= 0)
|
|
185
|
-
return false;
|
|
186
|
-
return rect.bottom > -bufferPx && rect.top < viewportHeight + bufferPx;
|
|
187
|
-
}
|
|
188
|
-
function getTextNodeRect(node) {
|
|
189
|
-
try {
|
|
190
|
-
const range = document.createRange();
|
|
191
|
-
range.selectNodeContents(node);
|
|
192
|
-
const rect = range.getBoundingClientRect();
|
|
193
|
-
if (rect && rect.width > 0 && rect.height > 0)
|
|
194
|
-
return rect;
|
|
195
|
-
}
|
|
196
|
-
catch {
|
|
197
|
-
// ignore
|
|
198
|
-
}
|
|
199
|
-
try {
|
|
200
|
-
return node.parentElement ? node.parentElement.getBoundingClientRect() : null;
|
|
201
|
-
}
|
|
202
|
-
catch {
|
|
203
|
-
return null;
|
|
204
|
-
}
|
|
205
|
-
}
|
|
206
|
-
function considerTextNode(node, stats, segments, occurrences, seen, maxSegments, critical) {
|
|
207
|
-
const raw = node.nodeValue || "";
|
|
208
|
-
if (!raw)
|
|
209
|
-
return;
|
|
210
|
-
const trimmed = raw.trim();
|
|
211
|
-
if (!trimmed)
|
|
212
|
-
return;
|
|
213
|
-
stats.totalTextNodes += 1;
|
|
214
|
-
stats.totalChars += raw.length;
|
|
215
|
-
const parent = node.parentElement;
|
|
216
|
-
if (!parent)
|
|
217
|
-
return;
|
|
218
|
-
if (isExcludedElement(parent)) {
|
|
219
|
-
stats.skippedExcludedNodes += 1;
|
|
220
|
-
stats.skippedExcludedChars += raw.length;
|
|
221
|
-
return;
|
|
222
|
-
}
|
|
223
|
-
const unsafe = findUnsafeContainer(parent);
|
|
224
|
-
if (unsafe) {
|
|
225
|
-
stats.skippedUnsafeNodes += 1;
|
|
226
|
-
stats.skippedUnsafeChars += raw.length;
|
|
227
|
-
return;
|
|
228
|
-
}
|
|
229
|
-
if (!isTranslatableText(trimmed)) {
|
|
230
|
-
stats.skippedNonTranslatableNodes += 1;
|
|
231
|
-
stats.skippedNonTranslatableChars += raw.length;
|
|
232
|
-
return;
|
|
233
|
-
}
|
|
234
|
-
const original = getOrInitTextOriginal(node, parent);
|
|
235
|
-
stats.markedNodes += 1;
|
|
236
|
-
stats.markedChars += raw.length;
|
|
237
|
-
if (segments.length < maxSegments) {
|
|
238
|
-
const originalText = normalizeWhitespace(original.trimmed) || null;
|
|
239
|
-
const currentText = normalizeWhitespace(node.nodeValue || "") || null;
|
|
240
|
-
segments.push({
|
|
241
|
-
kind: "text",
|
|
242
|
-
selector: buildSelector(parent),
|
|
243
|
-
original: originalText,
|
|
244
|
-
current: currentText,
|
|
245
|
-
html: null,
|
|
246
|
-
});
|
|
247
|
-
if (originalText && !seen.has(originalText)) {
|
|
248
|
-
seen.add(originalText);
|
|
249
|
-
occurrences.push({ source_text: originalText, semantic_context: "text" });
|
|
250
|
-
}
|
|
251
|
-
if (critical?.enabled && originalText && !critical.seen.has(originalText)) {
|
|
252
|
-
const rect = getTextNodeRect(node);
|
|
253
|
-
if (isInViewport(rect, critical.viewportHeight, critical.bufferPx)) {
|
|
254
|
-
critical.seen.add(originalText);
|
|
255
|
-
critical.occurrences.push({ source_text: originalText, semantic_context: "critical:text" });
|
|
256
|
-
}
|
|
257
|
-
}
|
|
258
|
-
}
|
|
259
|
-
}
|
|
260
|
-
function considerAttributes(root, segments, occurrences, seen, maxSegments, critical) {
|
|
261
|
-
const nodes = root.querySelectorAll("[title],[aria-label],[placeholder]");
|
|
262
|
-
nodes.forEach((el) => {
|
|
263
|
-
if (isExcludedElement(el))
|
|
264
|
-
return;
|
|
265
|
-
if (findUnsafeContainer(el))
|
|
266
|
-
return;
|
|
267
|
-
for (const { attr } of ATTRIBUTE_MARKS) {
|
|
268
|
-
const value = el.getAttribute(attr);
|
|
269
|
-
if (!value)
|
|
270
|
-
continue;
|
|
271
|
-
const trimmed = value.trim();
|
|
272
|
-
if (!trimmed || !isTranslatableText(trimmed))
|
|
273
|
-
continue;
|
|
274
|
-
const original = normalizeWhitespace(getOrInitAttrOriginal(el, attr)) || null;
|
|
275
|
-
const current = normalizeWhitespace(el.getAttribute(attr) || "") || null;
|
|
276
|
-
const kind = (attr === "title" ? "title" : attr === "aria-label" ? "aria-label" : "placeholder");
|
|
277
|
-
if (segments.length < maxSegments) {
|
|
278
|
-
segments.push({
|
|
279
|
-
kind,
|
|
280
|
-
selector: buildSelector(el),
|
|
281
|
-
original,
|
|
282
|
-
current,
|
|
283
|
-
html: null,
|
|
284
|
-
});
|
|
285
|
-
}
|
|
286
|
-
if (original && !seen.has(original)) {
|
|
287
|
-
seen.add(original);
|
|
288
|
-
occurrences.push({ source_text: original, semantic_context: `attr:${attr}` });
|
|
289
|
-
}
|
|
290
|
-
if (critical?.enabled && original && !critical.seen.has(original)) {
|
|
291
|
-
let rect = null;
|
|
292
|
-
try {
|
|
293
|
-
rect = el.getBoundingClientRect();
|
|
294
|
-
}
|
|
295
|
-
catch {
|
|
296
|
-
rect = null;
|
|
297
|
-
}
|
|
298
|
-
if (isInViewport(rect, critical.viewportHeight, critical.bufferPx)) {
|
|
299
|
-
critical.seen.add(original);
|
|
300
|
-
critical.occurrences.push({ source_text: original, semantic_context: `critical:attr:${attr}` });
|
|
301
|
-
}
|
|
302
|
-
}
|
|
303
|
-
}
|
|
304
|
-
});
|
|
305
|
-
}
|
|
306
|
-
function finalizeStats(stats) {
|
|
307
|
-
const eligibleNodes = stats.totalTextNodes -
|
|
308
|
-
stats.skippedUnsafeNodes -
|
|
309
|
-
stats.skippedExcludedNodes -
|
|
310
|
-
stats.skippedNonTranslatableNodes;
|
|
311
|
-
const eligibleChars = stats.totalChars -
|
|
312
|
-
stats.skippedUnsafeChars -
|
|
313
|
-
stats.skippedExcludedChars -
|
|
314
|
-
stats.skippedNonTranslatableChars;
|
|
315
|
-
stats.coverageRatio = eligibleNodes > 0 ? stats.markedNodes / eligibleNodes : 1;
|
|
316
|
-
stats.coverageRatioChars = eligibleChars > 0 ? stats.markedChars / eligibleChars : 1;
|
|
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
|
-
}
|
|
410
|
-
function scanDom(opts) {
|
|
411
|
-
const root = document.body;
|
|
412
|
-
if (!root) {
|
|
413
|
-
const empty = buildEmptyStats();
|
|
414
|
-
setGlobalStats(empty);
|
|
415
|
-
return { version: 1, stats: empty, segments: [], occurrences: [], truncated: false };
|
|
416
|
-
}
|
|
417
|
-
const stats = buildEmptyStats();
|
|
418
|
-
const maxSegments = Math.max(0, Math.floor(opts.maxSegments || 0)) || 20000;
|
|
419
|
-
const includeCritical = opts.includeCritical === true;
|
|
420
|
-
const viewportHeight = includeCritical ? Math.max(0, Math.floor(window.innerHeight || 0)) : 0;
|
|
421
|
-
const viewportWidth = includeCritical ? Math.max(0, Math.floor(window.innerWidth || 0)) : 0;
|
|
422
|
-
// Why: include a small buffer so "near the fold" text is ready without delaying first paint.
|
|
423
|
-
const critical = includeCritical
|
|
424
|
-
? {
|
|
425
|
-
enabled: true,
|
|
426
|
-
viewportHeight,
|
|
427
|
-
bufferPx: DEFAULT_CRITICAL_BUFFER_PX,
|
|
428
|
-
max: DEFAULT_CRITICAL_MAX,
|
|
429
|
-
seen: new Set(),
|
|
430
|
-
occurrences: [],
|
|
431
|
-
}
|
|
432
|
-
: null;
|
|
433
|
-
const walker = document.createTreeWalker(root, NodeFilter.SHOW_TEXT);
|
|
434
|
-
const nodes = [];
|
|
435
|
-
const segments = [];
|
|
436
|
-
const occurrences = [];
|
|
437
|
-
const seen = new Set();
|
|
438
|
-
let node = walker.nextNode();
|
|
439
|
-
while (node) {
|
|
440
|
-
if (node.nodeType === Node.TEXT_NODE)
|
|
441
|
-
nodes.push(node);
|
|
442
|
-
node = walker.nextNode();
|
|
443
|
-
}
|
|
444
|
-
nodes.forEach((textNode) => {
|
|
445
|
-
if (critical?.enabled && critical.occurrences.length >= critical.max) {
|
|
446
|
-
critical.enabled = false;
|
|
447
|
-
}
|
|
448
|
-
considerTextNode(textNode, stats, segments, occurrences, seen, maxSegments, critical);
|
|
449
|
-
});
|
|
450
|
-
considerAttributes(root, segments, occurrences, seen, maxSegments, critical);
|
|
451
|
-
finalizeStats(stats);
|
|
452
|
-
setGlobalStats(stats);
|
|
453
|
-
const truncated = segments.length >= maxSegments;
|
|
454
|
-
return {
|
|
455
|
-
version: 1,
|
|
456
|
-
stats,
|
|
457
|
-
segments,
|
|
458
|
-
occurrences,
|
|
459
|
-
...(includeCritical
|
|
460
|
-
? {
|
|
461
|
-
critical_occurrences: critical?.occurrences ?? [],
|
|
462
|
-
viewport: { width: viewportWidth, height: viewportHeight },
|
|
463
|
-
}
|
|
464
|
-
: {}),
|
|
465
|
-
truncated,
|
|
466
|
-
};
|
|
467
|
-
}
|
|
468
37
|
function scheduleScan() {
|
|
469
38
|
if (!running)
|
|
470
39
|
return;
|
|
@@ -473,8 +42,8 @@ function scheduleScan() {
|
|
|
473
42
|
scheduled = window.setTimeout(() => {
|
|
474
43
|
scheduled = null;
|
|
475
44
|
try {
|
|
476
|
-
|
|
477
|
-
if (
|
|
45
|
+
scanDomWithGlobals({ maxSegments: 20000 });
|
|
46
|
+
if (getActiveTranslationMap()) {
|
|
478
47
|
applying = true;
|
|
479
48
|
applyActiveTranslations(document.body);
|
|
480
49
|
}
|
|
@@ -508,7 +77,7 @@ export function startMarkerEngine(options = {}) {
|
|
|
508
77
|
subtree: true,
|
|
509
78
|
characterData: true,
|
|
510
79
|
});
|
|
511
|
-
|
|
80
|
+
scanDomWithGlobals({ maxSegments: 20000 });
|
|
512
81
|
};
|
|
513
82
|
startObserver();
|
|
514
83
|
return stopMarkerEngine;
|
|
@@ -529,265 +98,12 @@ export function getMarkerStats() {
|
|
|
529
98
|
}
|
|
530
99
|
export function setMarkerEngineExclusions(exclusions) {
|
|
531
100
|
if (!exclusions || exclusions.length === 0) {
|
|
532
|
-
|
|
101
|
+
setCustomExcludeSelector(null);
|
|
533
102
|
return;
|
|
534
103
|
}
|
|
535
104
|
const selectors = exclusions
|
|
536
105
|
.filter((e) => e && e.type === "css" && typeof e.selector === "string" && e.selector.trim())
|
|
537
106
|
.map((e) => e.selector.trim());
|
|
538
|
-
|
|
539
|
-
}
|
|
540
|
-
export function setActiveTranslations(translations) {
|
|
541
|
-
if (!translations || translations.length === 0) {
|
|
542
|
-
activeTranslationMap = null;
|
|
543
|
-
return;
|
|
544
|
-
}
|
|
545
|
-
const map = new Map();
|
|
546
|
-
for (const t of translations) {
|
|
547
|
-
const source = normalizeWhitespace((t?.source_text || "").toString());
|
|
548
|
-
const translated = (t?.translated_text ?? "").toString();
|
|
549
|
-
if (!source || !translated)
|
|
550
|
-
continue;
|
|
551
|
-
map.set(source, translated);
|
|
552
|
-
}
|
|
553
|
-
activeTranslationMap = map;
|
|
554
|
-
}
|
|
555
|
-
export function addActiveTranslations(translations) {
|
|
556
|
-
if (!translations)
|
|
557
|
-
return 0;
|
|
558
|
-
const map = activeTranslationMap ?? new Map();
|
|
559
|
-
let added = 0;
|
|
560
|
-
if (Array.isArray(translations)) {
|
|
561
|
-
for (const t of translations) {
|
|
562
|
-
const source = normalizeWhitespace((t?.source_text || "").toString());
|
|
563
|
-
const translated = (t?.translated_text ?? "").toString();
|
|
564
|
-
if (!source || !translated)
|
|
565
|
-
continue;
|
|
566
|
-
if (map.get(source) === translated)
|
|
567
|
-
continue;
|
|
568
|
-
map.set(source, translated);
|
|
569
|
-
added += 1;
|
|
570
|
-
}
|
|
571
|
-
}
|
|
572
|
-
else {
|
|
573
|
-
for (const [keyRaw, valueRaw] of Object.entries(translations || {})) {
|
|
574
|
-
const source = normalizeWhitespace((keyRaw || "").toString());
|
|
575
|
-
const translated = (valueRaw ?? "").toString();
|
|
576
|
-
if (!source || !translated)
|
|
577
|
-
continue;
|
|
578
|
-
if (map.get(source) === translated)
|
|
579
|
-
continue;
|
|
580
|
-
map.set(source, translated);
|
|
581
|
-
added += 1;
|
|
582
|
-
}
|
|
583
|
-
}
|
|
584
|
-
activeTranslationMap = map;
|
|
585
|
-
return added;
|
|
586
|
-
}
|
|
587
|
-
function applyTranslationMap(bundle, root) {
|
|
588
|
-
if (!root)
|
|
589
|
-
return 0;
|
|
590
|
-
const map = new Map();
|
|
591
|
-
for (const [k, v] of Object.entries(bundle || {})) {
|
|
592
|
-
const source = normalizeWhitespace((k || "").toString());
|
|
593
|
-
const translated = (v ?? "").toString();
|
|
594
|
-
if (!source || !translated)
|
|
595
|
-
continue;
|
|
596
|
-
map.set(source, translated);
|
|
597
|
-
}
|
|
598
|
-
activeTranslationMap = map;
|
|
599
|
-
return applyActiveTranslations(root);
|
|
600
|
-
}
|
|
601
|
-
export function applyActiveTranslations(root = document.body) {
|
|
602
|
-
if (!root || !activeTranslationMap || activeTranslationMap.size === 0)
|
|
603
|
-
return 0;
|
|
604
|
-
const map = activeTranslationMap;
|
|
605
|
-
let applied = 0;
|
|
606
|
-
const walk = document.createTreeWalker(root, NodeFilter.SHOW_TEXT);
|
|
607
|
-
const nodes = [];
|
|
608
|
-
let node = walk.nextNode();
|
|
609
|
-
while (node) {
|
|
610
|
-
if (node.nodeType === Node.TEXT_NODE)
|
|
611
|
-
nodes.push(node);
|
|
612
|
-
node = walk.nextNode();
|
|
613
|
-
}
|
|
614
|
-
for (const textNode of nodes) {
|
|
615
|
-
const parent = textNode.parentElement;
|
|
616
|
-
if (!parent)
|
|
617
|
-
continue;
|
|
618
|
-
const raw = textNode.nodeValue || "";
|
|
619
|
-
const trimmed = raw.trim();
|
|
620
|
-
if (!trimmed)
|
|
621
|
-
continue;
|
|
622
|
-
if (isExcludedElement(parent))
|
|
623
|
-
continue;
|
|
624
|
-
if (findUnsafeContainer(parent))
|
|
625
|
-
continue;
|
|
626
|
-
if (!isTranslatableText(trimmed))
|
|
627
|
-
continue;
|
|
628
|
-
const original = getOrInitTextOriginal(textNode, parent);
|
|
629
|
-
const key = normalizeWhitespace(original.trimmed);
|
|
630
|
-
const translation = map.get(key);
|
|
631
|
-
if (!translation)
|
|
632
|
-
continue;
|
|
633
|
-
const next = `${original.leading}${translation}${original.trailing}`;
|
|
634
|
-
if (textNode.nodeValue === next)
|
|
635
|
-
continue;
|
|
636
|
-
try {
|
|
637
|
-
textNode.nodeValue = next;
|
|
638
|
-
applied += 1;
|
|
639
|
-
}
|
|
640
|
-
catch {
|
|
641
|
-
// ignore
|
|
642
|
-
}
|
|
643
|
-
}
|
|
644
|
-
if (root instanceof HTMLElement) {
|
|
645
|
-
const elements = root.querySelectorAll("[title],[aria-label],[placeholder]");
|
|
646
|
-
elements.forEach((el) => {
|
|
647
|
-
if (isExcludedElement(el))
|
|
648
|
-
return;
|
|
649
|
-
if (findUnsafeContainer(el))
|
|
650
|
-
return;
|
|
651
|
-
for (const { attr } of ATTRIBUTE_MARKS) {
|
|
652
|
-
const current = el.getAttribute(attr);
|
|
653
|
-
if (!current)
|
|
654
|
-
continue;
|
|
655
|
-
const trimmed = current.trim();
|
|
656
|
-
if (!trimmed || !isTranslatableText(trimmed))
|
|
657
|
-
continue;
|
|
658
|
-
const original = normalizeWhitespace(getOrInitAttrOriginal(el, attr));
|
|
659
|
-
const translation = map.get(original);
|
|
660
|
-
if (!translation)
|
|
661
|
-
continue;
|
|
662
|
-
if (el.getAttribute(attr) === translation)
|
|
663
|
-
continue;
|
|
664
|
-
try {
|
|
665
|
-
el.setAttribute(attr, translation);
|
|
666
|
-
applied += 1;
|
|
667
|
-
}
|
|
668
|
-
catch {
|
|
669
|
-
// ignore
|
|
670
|
-
}
|
|
671
|
-
}
|
|
672
|
-
});
|
|
673
|
-
}
|
|
674
|
-
return applied;
|
|
675
|
-
}
|
|
676
|
-
export function scanDomForMisses(opts) {
|
|
677
|
-
const root = document.body;
|
|
678
|
-
const misses = [];
|
|
679
|
-
if (!root) {
|
|
680
|
-
return { misses };
|
|
681
|
-
}
|
|
682
|
-
// Why: allow live miss scans even when bundles are empty so first-time pages still report.
|
|
683
|
-
const translationMap = activeTranslationMap;
|
|
684
|
-
const hasTranslations = Boolean(translationMap && translationMap.size > 0);
|
|
685
|
-
const max = Math.max(0, Math.floor(opts.max || 0));
|
|
686
|
-
if (max <= 0)
|
|
687
|
-
return { misses };
|
|
688
|
-
const ignore = opts.ignore || new Set();
|
|
689
|
-
const seen = new Set();
|
|
690
|
-
const recordMiss = (text, context) => {
|
|
691
|
-
if (!text || seen.has(text) || ignore.has(text))
|
|
692
|
-
return;
|
|
693
|
-
if (hasTranslations && translationMap.has(text))
|
|
694
|
-
return;
|
|
695
|
-
if (misses.length >= max)
|
|
696
|
-
return;
|
|
697
|
-
seen.add(text);
|
|
698
|
-
misses.push({ source_text: text, semantic_context: context });
|
|
699
|
-
};
|
|
700
|
-
const walker = document.createTreeWalker(root, NodeFilter.SHOW_TEXT);
|
|
701
|
-
let node = walker.nextNode();
|
|
702
|
-
while (node && misses.length < max) {
|
|
703
|
-
if (node.nodeType !== Node.TEXT_NODE) {
|
|
704
|
-
node = walker.nextNode();
|
|
705
|
-
continue;
|
|
706
|
-
}
|
|
707
|
-
const textNode = node;
|
|
708
|
-
const parent = textNode.parentElement;
|
|
709
|
-
if (!parent || isExcludedElement(parent) || findUnsafeContainer(parent)) {
|
|
710
|
-
node = walker.nextNode();
|
|
711
|
-
continue;
|
|
712
|
-
}
|
|
713
|
-
const raw = textNode.nodeValue || "";
|
|
714
|
-
const trimmed = raw.trim();
|
|
715
|
-
if (!trimmed || !isTranslatableText(trimmed)) {
|
|
716
|
-
node = walker.nextNode();
|
|
717
|
-
continue;
|
|
718
|
-
}
|
|
719
|
-
const original = getOrInitTextOriginal(textNode, parent);
|
|
720
|
-
const key = normalizeWhitespace(original.trimmed);
|
|
721
|
-
if (key) {
|
|
722
|
-
recordMiss(key, "text");
|
|
723
|
-
}
|
|
724
|
-
node = walker.nextNode();
|
|
725
|
-
}
|
|
726
|
-
if (misses.length < max) {
|
|
727
|
-
const nodes = root.querySelectorAll("[title],[aria-label],[placeholder]");
|
|
728
|
-
nodes.forEach((el) => {
|
|
729
|
-
if (misses.length >= max)
|
|
730
|
-
return;
|
|
731
|
-
if (isExcludedElement(el) || findUnsafeContainer(el))
|
|
732
|
-
return;
|
|
733
|
-
for (const { attr } of ATTRIBUTE_MARKS) {
|
|
734
|
-
if (misses.length >= max)
|
|
735
|
-
break;
|
|
736
|
-
const value = el.getAttribute(attr);
|
|
737
|
-
if (!value)
|
|
738
|
-
continue;
|
|
739
|
-
const trimmed = value.trim();
|
|
740
|
-
if (!trimmed || !isTranslatableText(trimmed))
|
|
741
|
-
continue;
|
|
742
|
-
const original = normalizeWhitespace(getOrInitAttrOriginal(el, attr));
|
|
743
|
-
if (!original)
|
|
744
|
-
continue;
|
|
745
|
-
const context = attr === "title" ? "attr:title" : attr === "aria-label" ? "attr:aria-label" : "attr:placeholder";
|
|
746
|
-
recordMiss(original, context);
|
|
747
|
-
}
|
|
748
|
-
});
|
|
749
|
-
}
|
|
750
|
-
return { misses };
|
|
751
|
-
}
|
|
752
|
-
export function restoreDom(root = document.body) {
|
|
753
|
-
if (!root)
|
|
754
|
-
return;
|
|
755
|
-
const walk = document.createTreeWalker(root, NodeFilter.SHOW_TEXT);
|
|
756
|
-
let node = walk.nextNode();
|
|
757
|
-
while (node) {
|
|
758
|
-
if (node.nodeType === Node.TEXT_NODE) {
|
|
759
|
-
const textNode = node;
|
|
760
|
-
const original = originalTextByNode.get(textNode);
|
|
761
|
-
if (original && textNode.nodeValue !== original.raw) {
|
|
762
|
-
try {
|
|
763
|
-
textNode.nodeValue = original.raw;
|
|
764
|
-
}
|
|
765
|
-
catch {
|
|
766
|
-
// ignore
|
|
767
|
-
}
|
|
768
|
-
}
|
|
769
|
-
}
|
|
770
|
-
node = walk.nextNode();
|
|
771
|
-
}
|
|
772
|
-
if (root instanceof HTMLElement) {
|
|
773
|
-
const elements = root.querySelectorAll("[title],[aria-label],[placeholder]");
|
|
774
|
-
elements.forEach((el) => {
|
|
775
|
-
const originals = originalAttrByEl.get(el);
|
|
776
|
-
if (!originals)
|
|
777
|
-
return;
|
|
778
|
-
for (const { attr } of ATTRIBUTE_MARKS) {
|
|
779
|
-
const original = originals.get(attr);
|
|
780
|
-
if (original == null)
|
|
781
|
-
continue;
|
|
782
|
-
if (el.getAttribute(attr) === original)
|
|
783
|
-
continue;
|
|
784
|
-
try {
|
|
785
|
-
el.setAttribute(attr, original);
|
|
786
|
-
}
|
|
787
|
-
catch {
|
|
788
|
-
// ignore
|
|
789
|
-
}
|
|
790
|
-
}
|
|
791
|
-
});
|
|
792
|
-
}
|
|
107
|
+
setCustomExcludeSelector(selectors.length ? selectors.join(",") : null);
|
|
793
108
|
}
|
|
109
|
+
export { applyActiveTranslations, scanDomForMisses, restoreDom, setActiveTranslations, addActiveTranslations, getCriticalFingerprint };
|