@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
|
@@ -0,0 +1,136 @@
|
|
|
1
|
+
import { ATTRIBUTE_MARKS } from "./markerEngineConstants";
|
|
2
|
+
import { findUnsafeContainer, isExcludedElement } from "./markerEngineFilters";
|
|
3
|
+
import { isTranslatableText, normalizeWhitespace } from "./markerEngineDomUtils";
|
|
4
|
+
import { getOrInitAttrOriginal, getOrInitTextOriginal, originalAttrByEl, originalTextByNode } from "./markerEngineOriginals";
|
|
5
|
+
import { getActiveTranslationMap, setActiveTranslationMap } from "./markerEngineState";
|
|
6
|
+
export function applyTranslationMap(bundle, root) {
|
|
7
|
+
if (!root)
|
|
8
|
+
return 0;
|
|
9
|
+
const map = new Map();
|
|
10
|
+
for (const [k, v] of Object.entries(bundle || {})) {
|
|
11
|
+
const source = normalizeWhitespace((k || "").toString());
|
|
12
|
+
const translated = (v ?? "").toString();
|
|
13
|
+
if (!source || !translated)
|
|
14
|
+
continue;
|
|
15
|
+
map.set(source, translated);
|
|
16
|
+
}
|
|
17
|
+
setActiveTranslationMap(map);
|
|
18
|
+
return applyActiveTranslations(root);
|
|
19
|
+
}
|
|
20
|
+
export function applyActiveTranslations(root = document.body) {
|
|
21
|
+
const map = getActiveTranslationMap();
|
|
22
|
+
if (!root || !map || map.size === 0)
|
|
23
|
+
return 0;
|
|
24
|
+
let applied = 0;
|
|
25
|
+
const walk = document.createTreeWalker(root, NodeFilter.SHOW_TEXT);
|
|
26
|
+
const nodes = [];
|
|
27
|
+
let node = walk.nextNode();
|
|
28
|
+
while (node) {
|
|
29
|
+
if (node.nodeType === Node.TEXT_NODE)
|
|
30
|
+
nodes.push(node);
|
|
31
|
+
node = walk.nextNode();
|
|
32
|
+
}
|
|
33
|
+
for (const textNode of nodes) {
|
|
34
|
+
const parent = textNode.parentElement;
|
|
35
|
+
if (!parent)
|
|
36
|
+
continue;
|
|
37
|
+
const raw = textNode.nodeValue || "";
|
|
38
|
+
const trimmed = raw.trim();
|
|
39
|
+
if (!trimmed)
|
|
40
|
+
continue;
|
|
41
|
+
if (isExcludedElement(parent))
|
|
42
|
+
continue;
|
|
43
|
+
if (findUnsafeContainer(parent))
|
|
44
|
+
continue;
|
|
45
|
+
if (!isTranslatableText(trimmed))
|
|
46
|
+
continue;
|
|
47
|
+
const original = getOrInitTextOriginal(textNode, parent);
|
|
48
|
+
const key = normalizeWhitespace(original.trimmed);
|
|
49
|
+
const translation = map.get(key);
|
|
50
|
+
if (!translation)
|
|
51
|
+
continue;
|
|
52
|
+
const next = `${original.leading}${translation}${original.trailing}`;
|
|
53
|
+
if (textNode.nodeValue === next)
|
|
54
|
+
continue;
|
|
55
|
+
try {
|
|
56
|
+
textNode.nodeValue = next;
|
|
57
|
+
applied += 1;
|
|
58
|
+
}
|
|
59
|
+
catch {
|
|
60
|
+
// ignore
|
|
61
|
+
}
|
|
62
|
+
}
|
|
63
|
+
if (root instanceof HTMLElement) {
|
|
64
|
+
const elements = root.querySelectorAll("[title],[aria-label],[placeholder]");
|
|
65
|
+
elements.forEach((el) => {
|
|
66
|
+
if (isExcludedElement(el))
|
|
67
|
+
return;
|
|
68
|
+
if (findUnsafeContainer(el))
|
|
69
|
+
return;
|
|
70
|
+
for (const { attr } of ATTRIBUTE_MARKS) {
|
|
71
|
+
const current = el.getAttribute(attr);
|
|
72
|
+
if (!current)
|
|
73
|
+
continue;
|
|
74
|
+
const trimmed = current.trim();
|
|
75
|
+
if (!trimmed || !isTranslatableText(trimmed))
|
|
76
|
+
continue;
|
|
77
|
+
const original = normalizeWhitespace(getOrInitAttrOriginal(el, attr));
|
|
78
|
+
const translation = map.get(original);
|
|
79
|
+
if (!translation)
|
|
80
|
+
continue;
|
|
81
|
+
if (el.getAttribute(attr) === translation)
|
|
82
|
+
continue;
|
|
83
|
+
try {
|
|
84
|
+
el.setAttribute(attr, translation);
|
|
85
|
+
applied += 1;
|
|
86
|
+
}
|
|
87
|
+
catch {
|
|
88
|
+
// ignore
|
|
89
|
+
}
|
|
90
|
+
}
|
|
91
|
+
});
|
|
92
|
+
}
|
|
93
|
+
return applied;
|
|
94
|
+
}
|
|
95
|
+
export function restoreDom(root = document.body) {
|
|
96
|
+
if (!root)
|
|
97
|
+
return;
|
|
98
|
+
const walk = document.createTreeWalker(root, NodeFilter.SHOW_TEXT);
|
|
99
|
+
let node = walk.nextNode();
|
|
100
|
+
while (node) {
|
|
101
|
+
if (node.nodeType === Node.TEXT_NODE) {
|
|
102
|
+
const textNode = node;
|
|
103
|
+
const original = originalTextByNode.get(textNode);
|
|
104
|
+
if (original && textNode.nodeValue !== original.raw) {
|
|
105
|
+
try {
|
|
106
|
+
textNode.nodeValue = original.raw;
|
|
107
|
+
}
|
|
108
|
+
catch {
|
|
109
|
+
// ignore
|
|
110
|
+
}
|
|
111
|
+
}
|
|
112
|
+
}
|
|
113
|
+
node = walk.nextNode();
|
|
114
|
+
}
|
|
115
|
+
if (root instanceof HTMLElement) {
|
|
116
|
+
const elements = root.querySelectorAll("[title],[aria-label],[placeholder]");
|
|
117
|
+
elements.forEach((el) => {
|
|
118
|
+
const originals = originalAttrByEl.get(el);
|
|
119
|
+
if (!originals)
|
|
120
|
+
return;
|
|
121
|
+
for (const { attr } of ATTRIBUTE_MARKS) {
|
|
122
|
+
const original = originals.get(attr);
|
|
123
|
+
if (original == null)
|
|
124
|
+
continue;
|
|
125
|
+
if (el.getAttribute(attr) === original)
|
|
126
|
+
continue;
|
|
127
|
+
try {
|
|
128
|
+
el.setAttribute(attr, original);
|
|
129
|
+
}
|
|
130
|
+
catch {
|
|
131
|
+
// ignore
|
|
132
|
+
}
|
|
133
|
+
}
|
|
134
|
+
});
|
|
135
|
+
}
|
|
136
|
+
}
|
|
@@ -0,0 +1,10 @@
|
|
|
1
|
+
export declare const DEFAULT_THROTTLE_MS = 150;
|
|
2
|
+
export declare const DEFAULT_CRITICAL_BUFFER_PX = 200;
|
|
3
|
+
export declare const DEFAULT_CRITICAL_MAX = 800;
|
|
4
|
+
export declare const EXCLUDE_SELECTOR = "[data-lovalingo-exclude],[data-notranslate],[translate-no],[data-no-translate]";
|
|
5
|
+
export declare const UNSAFE_CONTAINER_TAGS: Set<string>;
|
|
6
|
+
export declare const ATTRIBUTE_MARKS: {
|
|
7
|
+
attr: string;
|
|
8
|
+
marker: string;
|
|
9
|
+
}[];
|
|
10
|
+
export declare const unsafeSelector: string;
|
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
// Why: keep marker scans cheap while still capturing a small above-the-fold "critical slice" for first paint.
|
|
2
|
+
export const DEFAULT_THROTTLE_MS = 150;
|
|
3
|
+
export const DEFAULT_CRITICAL_BUFFER_PX = 200;
|
|
4
|
+
export const DEFAULT_CRITICAL_MAX = 800;
|
|
5
|
+
export const EXCLUDE_SELECTOR = "[data-lovalingo-exclude],[data-notranslate],[translate-no],[data-no-translate]";
|
|
6
|
+
export const UNSAFE_CONTAINER_TAGS = new Set(["script", "style", "noscript", "template", "svg", "canvas"]);
|
|
7
|
+
export const ATTRIBUTE_MARKS = [
|
|
8
|
+
{ attr: "title", marker: "data-lovalingo-title-original" },
|
|
9
|
+
{ attr: "aria-label", marker: "data-lovalingo-aria-label-original" },
|
|
10
|
+
{ attr: "placeholder", marker: "data-lovalingo-placeholder-original" },
|
|
11
|
+
];
|
|
12
|
+
export const unsafeSelector = Array.from(UNSAFE_CONTAINER_TAGS).join(",");
|
|
@@ -0,0 +1,98 @@
|
|
|
1
|
+
import { hashContent } from "./hash";
|
|
2
|
+
import { ATTRIBUTE_MARKS, DEFAULT_CRITICAL_BUFFER_PX, DEFAULT_CRITICAL_MAX } from "./markerEngineConstants";
|
|
3
|
+
import { isExcludedElement, findUnsafeContainer } from "./markerEngineFilters";
|
|
4
|
+
import { isTranslatableText, normalizeWhitespace } from "./markerEngineDomUtils";
|
|
5
|
+
import { getOrInitAttrOriginal, getOrInitTextOriginal } from "./markerEngineOriginals";
|
|
6
|
+
import { getTextNodeRect, isInViewport } from "./markerEngineViewport";
|
|
7
|
+
function scanCriticalTexts() {
|
|
8
|
+
const root = document.body;
|
|
9
|
+
const viewportHeight = Math.max(0, Math.floor(window.innerHeight || 0));
|
|
10
|
+
const viewportWidth = Math.max(0, Math.floor(window.innerWidth || 0));
|
|
11
|
+
const viewport = { width: viewportWidth, height: viewportHeight };
|
|
12
|
+
if (!root || viewportHeight <= 0)
|
|
13
|
+
return { texts: [], viewport };
|
|
14
|
+
const seen = new Set();
|
|
15
|
+
const texts = [];
|
|
16
|
+
const walker = document.createTreeWalker(root, NodeFilter.SHOW_TEXT);
|
|
17
|
+
let node = walker.nextNode();
|
|
18
|
+
while (node && texts.length < DEFAULT_CRITICAL_MAX) {
|
|
19
|
+
if (node.nodeType !== Node.TEXT_NODE) {
|
|
20
|
+
node = walker.nextNode();
|
|
21
|
+
continue;
|
|
22
|
+
}
|
|
23
|
+
const textNode = node;
|
|
24
|
+
const raw = textNode.nodeValue || "";
|
|
25
|
+
const trimmed = raw.trim();
|
|
26
|
+
if (!trimmed || !isTranslatableText(trimmed)) {
|
|
27
|
+
node = walker.nextNode();
|
|
28
|
+
continue;
|
|
29
|
+
}
|
|
30
|
+
const parent = textNode.parentElement;
|
|
31
|
+
if (!parent || isExcludedElement(parent) || findUnsafeContainer(parent)) {
|
|
32
|
+
node = walker.nextNode();
|
|
33
|
+
continue;
|
|
34
|
+
}
|
|
35
|
+
const original = getOrInitTextOriginal(textNode, parent);
|
|
36
|
+
const originalText = normalizeWhitespace(original.trimmed);
|
|
37
|
+
if (!originalText || seen.has(originalText)) {
|
|
38
|
+
node = walker.nextNode();
|
|
39
|
+
continue;
|
|
40
|
+
}
|
|
41
|
+
const rect = getTextNodeRect(textNode);
|
|
42
|
+
if (!isInViewport(rect, viewportHeight, DEFAULT_CRITICAL_BUFFER_PX)) {
|
|
43
|
+
node = walker.nextNode();
|
|
44
|
+
continue;
|
|
45
|
+
}
|
|
46
|
+
seen.add(originalText);
|
|
47
|
+
texts.push(originalText);
|
|
48
|
+
node = walker.nextNode();
|
|
49
|
+
}
|
|
50
|
+
if (texts.length < DEFAULT_CRITICAL_MAX) {
|
|
51
|
+
const nodes = root.querySelectorAll("[title],[aria-label],[placeholder]");
|
|
52
|
+
nodes.forEach((el) => {
|
|
53
|
+
if (texts.length >= DEFAULT_CRITICAL_MAX)
|
|
54
|
+
return;
|
|
55
|
+
if (isExcludedElement(el) || findUnsafeContainer(el))
|
|
56
|
+
return;
|
|
57
|
+
let rect = null;
|
|
58
|
+
try {
|
|
59
|
+
rect = el.getBoundingClientRect();
|
|
60
|
+
}
|
|
61
|
+
catch {
|
|
62
|
+
rect = null;
|
|
63
|
+
}
|
|
64
|
+
if (!isInViewport(rect, viewportHeight, DEFAULT_CRITICAL_BUFFER_PX))
|
|
65
|
+
return;
|
|
66
|
+
for (const { attr } of ATTRIBUTE_MARKS) {
|
|
67
|
+
if (texts.length >= DEFAULT_CRITICAL_MAX)
|
|
68
|
+
break;
|
|
69
|
+
const value = el.getAttribute(attr);
|
|
70
|
+
if (!value)
|
|
71
|
+
continue;
|
|
72
|
+
const trimmed = value.trim();
|
|
73
|
+
if (!trimmed || !isTranslatableText(trimmed))
|
|
74
|
+
continue;
|
|
75
|
+
const original = normalizeWhitespace(getOrInitAttrOriginal(el, attr));
|
|
76
|
+
if (!original || seen.has(original))
|
|
77
|
+
continue;
|
|
78
|
+
seen.add(original);
|
|
79
|
+
texts.push(original);
|
|
80
|
+
}
|
|
81
|
+
});
|
|
82
|
+
}
|
|
83
|
+
return { texts, viewport };
|
|
84
|
+
}
|
|
85
|
+
export function getCriticalFingerprint() {
|
|
86
|
+
if (typeof window === "undefined" || typeof document === "undefined") {
|
|
87
|
+
return { critical_count: 0, critical_hash: "0", viewport: { width: 0, height: 0 } };
|
|
88
|
+
}
|
|
89
|
+
const { texts, viewport } = scanCriticalTexts();
|
|
90
|
+
const normalized = texts.map((t) => normalizeWhitespace(t)).filter(Boolean);
|
|
91
|
+
// Why: sort to stay stable across minor DOM reordering without affecting the set of critical strings.
|
|
92
|
+
normalized.sort((a, b) => a.localeCompare(b));
|
|
93
|
+
return {
|
|
94
|
+
critical_count: normalized.length,
|
|
95
|
+
critical_hash: hashContent(normalized.join("\n")),
|
|
96
|
+
viewport,
|
|
97
|
+
};
|
|
98
|
+
}
|
|
@@ -0,0 +1,8 @@
|
|
|
1
|
+
export declare function getStableKey(el: Element): string;
|
|
2
|
+
export declare function getElementIndex(el: Element): number;
|
|
3
|
+
export declare function getTextNodeIndex(node: Text): number;
|
|
4
|
+
export declare function buildElementPath(el: Element): string;
|
|
5
|
+
export declare function normalizeWhitespace(value: string): string;
|
|
6
|
+
export declare function isTranslatableText(text: string): boolean;
|
|
7
|
+
export declare function buildStableId(el: Element, text: string, textIndex: number): string;
|
|
8
|
+
export declare function buildSelector(el: Element): string | null;
|
|
@@ -0,0 +1,74 @@
|
|
|
1
|
+
import { hashContent } from "./hash";
|
|
2
|
+
export function getStableKey(el) {
|
|
3
|
+
const owner = el.closest("[data-lovalingo-key]");
|
|
4
|
+
const key = owner?.getAttribute("data-lovalingo-key") || "";
|
|
5
|
+
return key.trim();
|
|
6
|
+
}
|
|
7
|
+
export function getElementIndex(el) {
|
|
8
|
+
const parent = el.parentElement;
|
|
9
|
+
if (!parent)
|
|
10
|
+
return 0;
|
|
11
|
+
const children = Array.from(parent.children);
|
|
12
|
+
const idx = children.indexOf(el);
|
|
13
|
+
return idx >= 0 ? idx : 0;
|
|
14
|
+
}
|
|
15
|
+
export function getTextNodeIndex(node) {
|
|
16
|
+
let index = 0;
|
|
17
|
+
let prev = node.previousSibling;
|
|
18
|
+
while (prev) {
|
|
19
|
+
if (prev.nodeType === Node.TEXT_NODE)
|
|
20
|
+
index += 1;
|
|
21
|
+
prev = prev.previousSibling;
|
|
22
|
+
}
|
|
23
|
+
return index;
|
|
24
|
+
}
|
|
25
|
+
export function buildElementPath(el) {
|
|
26
|
+
const parts = [];
|
|
27
|
+
let current = el;
|
|
28
|
+
while (current && current.tagName && current !== document.body) {
|
|
29
|
+
const tag = current.tagName.toLowerCase();
|
|
30
|
+
const idx = getElementIndex(current);
|
|
31
|
+
parts.push(`${tag}[${idx}]`);
|
|
32
|
+
current = current.parentElement;
|
|
33
|
+
}
|
|
34
|
+
parts.push("body");
|
|
35
|
+
return parts.reverse().join("/");
|
|
36
|
+
}
|
|
37
|
+
export function normalizeWhitespace(value) {
|
|
38
|
+
return (value || "").toString().replace(/\s+/g, " ").trim();
|
|
39
|
+
}
|
|
40
|
+
export function isTranslatableText(text) {
|
|
41
|
+
if (!text || text.trim().length < 2)
|
|
42
|
+
return false;
|
|
43
|
+
if (/^(__[A-Z0-9_]+__\s*)+$/.test(text))
|
|
44
|
+
return false;
|
|
45
|
+
if (/^\d+(\.\d+)?$/.test(text))
|
|
46
|
+
return false;
|
|
47
|
+
if (!/[a-zA-Z\u00C0-\u024F\u1E00-\u1EFF]/.test(text))
|
|
48
|
+
return false;
|
|
49
|
+
return true;
|
|
50
|
+
}
|
|
51
|
+
export function buildStableId(el, text, textIndex) {
|
|
52
|
+
const key = getStableKey(el);
|
|
53
|
+
const path = buildElementPath(el);
|
|
54
|
+
const raw = `${path}#text[${textIndex}]|${text.trim()}|${key}`;
|
|
55
|
+
return hashContent(raw);
|
|
56
|
+
}
|
|
57
|
+
export function buildSelector(el) {
|
|
58
|
+
const id = el.id;
|
|
59
|
+
if (id)
|
|
60
|
+
return `#${id.replace(/[^a-zA-Z0-9_-]/g, "\\$&")}`;
|
|
61
|
+
const className = el.className;
|
|
62
|
+
if (typeof className === "string" && className.trim()) {
|
|
63
|
+
const classes = className
|
|
64
|
+
.split(/\s+/)
|
|
65
|
+
.map((c) => c.trim())
|
|
66
|
+
.filter(Boolean)
|
|
67
|
+
.slice(0, 3)
|
|
68
|
+
.map((c) => `.${c.replace(/[^a-zA-Z0-9_-]/g, "\\$&")}`)
|
|
69
|
+
.join("");
|
|
70
|
+
if (classes)
|
|
71
|
+
return classes;
|
|
72
|
+
}
|
|
73
|
+
return null;
|
|
74
|
+
}
|
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
import { EXCLUDE_SELECTOR, unsafeSelector } from "./markerEngineConstants";
|
|
2
|
+
import { getCustomExcludeSelector } from "./markerEngineState";
|
|
3
|
+
export function isExcludedElement(el) {
|
|
4
|
+
if (!el)
|
|
5
|
+
return false;
|
|
6
|
+
if (el.closest(EXCLUDE_SELECTOR))
|
|
7
|
+
return true;
|
|
8
|
+
const customExcludeSelector = getCustomExcludeSelector();
|
|
9
|
+
if (customExcludeSelector) {
|
|
10
|
+
try {
|
|
11
|
+
if (el.closest(customExcludeSelector))
|
|
12
|
+
return true;
|
|
13
|
+
}
|
|
14
|
+
catch {
|
|
15
|
+
// ignore invalid selector strings
|
|
16
|
+
}
|
|
17
|
+
}
|
|
18
|
+
return false;
|
|
19
|
+
}
|
|
20
|
+
export function findUnsafeContainer(el) {
|
|
21
|
+
if (!el)
|
|
22
|
+
return null;
|
|
23
|
+
if (!unsafeSelector)
|
|
24
|
+
return null;
|
|
25
|
+
return el.closest(unsafeSelector);
|
|
26
|
+
}
|
|
@@ -0,0 +1,81 @@
|
|
|
1
|
+
import { ATTRIBUTE_MARKS } from "./markerEngineConstants";
|
|
2
|
+
import { findUnsafeContainer, isExcludedElement } from "./markerEngineFilters";
|
|
3
|
+
import { getActiveTranslationMap } from "./markerEngineState";
|
|
4
|
+
import { isTranslatableText, normalizeWhitespace } from "./markerEngineDomUtils";
|
|
5
|
+
import { getOrInitAttrOriginal, getOrInitTextOriginal } from "./markerEngineOriginals";
|
|
6
|
+
export function scanDomForMisses(opts) {
|
|
7
|
+
const root = document.body;
|
|
8
|
+
const misses = [];
|
|
9
|
+
if (!root) {
|
|
10
|
+
return { misses };
|
|
11
|
+
}
|
|
12
|
+
// Why: allow live miss scans even when bundles are empty so first-time pages still report.
|
|
13
|
+
const translationMap = getActiveTranslationMap();
|
|
14
|
+
const hasTranslations = Boolean(translationMap && translationMap.size > 0);
|
|
15
|
+
const max = Math.max(0, Math.floor(opts.max || 0));
|
|
16
|
+
if (max <= 0)
|
|
17
|
+
return { misses };
|
|
18
|
+
const ignore = opts.ignore || new Set();
|
|
19
|
+
const seen = new Set();
|
|
20
|
+
const recordMiss = (text, context) => {
|
|
21
|
+
if (!text || seen.has(text) || ignore.has(text))
|
|
22
|
+
return;
|
|
23
|
+
if (hasTranslations && translationMap.has(text))
|
|
24
|
+
return;
|
|
25
|
+
if (misses.length >= max)
|
|
26
|
+
return;
|
|
27
|
+
seen.add(text);
|
|
28
|
+
misses.push({ source_text: text, semantic_context: context });
|
|
29
|
+
};
|
|
30
|
+
const walker = document.createTreeWalker(root, NodeFilter.SHOW_TEXT);
|
|
31
|
+
let node = walker.nextNode();
|
|
32
|
+
while (node && misses.length < max) {
|
|
33
|
+
if (node.nodeType !== Node.TEXT_NODE) {
|
|
34
|
+
node = walker.nextNode();
|
|
35
|
+
continue;
|
|
36
|
+
}
|
|
37
|
+
const textNode = node;
|
|
38
|
+
const parent = textNode.parentElement;
|
|
39
|
+
if (!parent || isExcludedElement(parent) || findUnsafeContainer(parent)) {
|
|
40
|
+
node = walker.nextNode();
|
|
41
|
+
continue;
|
|
42
|
+
}
|
|
43
|
+
const raw = textNode.nodeValue || "";
|
|
44
|
+
const trimmed = raw.trim();
|
|
45
|
+
if (!trimmed || !isTranslatableText(trimmed)) {
|
|
46
|
+
node = walker.nextNode();
|
|
47
|
+
continue;
|
|
48
|
+
}
|
|
49
|
+
const original = getOrInitTextOriginal(textNode, parent);
|
|
50
|
+
const key = normalizeWhitespace(original.trimmed);
|
|
51
|
+
if (key) {
|
|
52
|
+
recordMiss(key, "text");
|
|
53
|
+
}
|
|
54
|
+
node = walker.nextNode();
|
|
55
|
+
}
|
|
56
|
+
if (misses.length < max) {
|
|
57
|
+
const nodes = root.querySelectorAll("[title],[aria-label],[placeholder]");
|
|
58
|
+
nodes.forEach((el) => {
|
|
59
|
+
if (misses.length >= max)
|
|
60
|
+
return;
|
|
61
|
+
if (isExcludedElement(el) || findUnsafeContainer(el))
|
|
62
|
+
return;
|
|
63
|
+
for (const { attr } of ATTRIBUTE_MARKS) {
|
|
64
|
+
if (misses.length >= max)
|
|
65
|
+
break;
|
|
66
|
+
const value = el.getAttribute(attr);
|
|
67
|
+
if (!value)
|
|
68
|
+
continue;
|
|
69
|
+
const trimmed = value.trim();
|
|
70
|
+
if (!trimmed || !isTranslatableText(trimmed))
|
|
71
|
+
continue;
|
|
72
|
+
const original = normalizeWhitespace(getOrInitAttrOriginal(el, attr));
|
|
73
|
+
if (!original)
|
|
74
|
+
continue;
|
|
75
|
+
const context = attr === "title" ? "attr:title" : attr === "aria-label" ? "attr:aria-label" : "attr:placeholder";
|
|
76
|
+
recordMiss(original, context);
|
|
77
|
+
}
|
|
78
|
+
});
|
|
79
|
+
}
|
|
80
|
+
return { misses };
|
|
81
|
+
}
|
|
@@ -0,0 +1,5 @@
|
|
|
1
|
+
import type { TextOriginal } from "./markerEngineTypes";
|
|
2
|
+
export declare const originalTextByNode: WeakMap<Text, TextOriginal>;
|
|
3
|
+
export declare const originalAttrByEl: WeakMap<HTMLElement, Map<string, string>>;
|
|
4
|
+
export declare function getOrInitTextOriginal(node: Text, parent: Element): TextOriginal;
|
|
5
|
+
export declare function getOrInitAttrOriginal(el: HTMLElement, attr: string): string;
|
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
import { buildStableId, getTextNodeIndex } from "./markerEngineDomUtils";
|
|
2
|
+
export const originalTextByNode = new WeakMap();
|
|
3
|
+
export const originalAttrByEl = new WeakMap();
|
|
4
|
+
export function getOrInitTextOriginal(node, parent) {
|
|
5
|
+
const existing = originalTextByNode.get(node);
|
|
6
|
+
if (existing)
|
|
7
|
+
return existing;
|
|
8
|
+
const raw = node.nodeValue || "";
|
|
9
|
+
const leading = raw.match(/^\s*/)?.[0] ?? "";
|
|
10
|
+
const trailing = raw.match(/\s*$/)?.[0] ?? "";
|
|
11
|
+
const trimmed = raw.trim();
|
|
12
|
+
const id = buildStableId(parent, trimmed, getTextNodeIndex(node));
|
|
13
|
+
const created = { raw, trimmed, leading, trailing, id };
|
|
14
|
+
originalTextByNode.set(node, created);
|
|
15
|
+
return created;
|
|
16
|
+
}
|
|
17
|
+
export function getOrInitAttrOriginal(el, attr) {
|
|
18
|
+
let map = originalAttrByEl.get(el);
|
|
19
|
+
if (!map) {
|
|
20
|
+
map = new Map();
|
|
21
|
+
originalAttrByEl.set(el, map);
|
|
22
|
+
}
|
|
23
|
+
const existing = map.get(attr);
|
|
24
|
+
if (existing != null)
|
|
25
|
+
return existing;
|
|
26
|
+
const value = (el.getAttribute(attr) || "").toString();
|
|
27
|
+
map.set(attr, value);
|
|
28
|
+
return value;
|
|
29
|
+
}
|
|
@@ -0,0 +1,162 @@
|
|
|
1
|
+
import { ATTRIBUTE_MARKS, DEFAULT_CRITICAL_BUFFER_PX, DEFAULT_CRITICAL_MAX, } from "./markerEngineConstants";
|
|
2
|
+
import { isExcludedElement, findUnsafeContainer } from "./markerEngineFilters";
|
|
3
|
+
import { buildSelector, isTranslatableText, normalizeWhitespace } from "./markerEngineDomUtils";
|
|
4
|
+
import { getOrInitAttrOriginal, getOrInitTextOriginal } from "./markerEngineOriginals";
|
|
5
|
+
import { buildEmptyStats, finalizeStats } from "./markerEngineStats";
|
|
6
|
+
import { getTextNodeRect, isInViewport } from "./markerEngineViewport";
|
|
7
|
+
function considerTextNode(node, stats, segments, occurrences, seen, maxSegments, critical) {
|
|
8
|
+
const raw = node.nodeValue || "";
|
|
9
|
+
if (!raw)
|
|
10
|
+
return;
|
|
11
|
+
const trimmed = raw.trim();
|
|
12
|
+
if (!trimmed)
|
|
13
|
+
return;
|
|
14
|
+
stats.totalTextNodes += 1;
|
|
15
|
+
stats.totalChars += raw.length;
|
|
16
|
+
const parent = node.parentElement;
|
|
17
|
+
if (!parent)
|
|
18
|
+
return;
|
|
19
|
+
if (isExcludedElement(parent)) {
|
|
20
|
+
stats.skippedExcludedNodes += 1;
|
|
21
|
+
stats.skippedExcludedChars += raw.length;
|
|
22
|
+
return;
|
|
23
|
+
}
|
|
24
|
+
const unsafe = findUnsafeContainer(parent);
|
|
25
|
+
if (unsafe) {
|
|
26
|
+
stats.skippedUnsafeNodes += 1;
|
|
27
|
+
stats.skippedUnsafeChars += raw.length;
|
|
28
|
+
return;
|
|
29
|
+
}
|
|
30
|
+
if (!isTranslatableText(trimmed)) {
|
|
31
|
+
stats.skippedNonTranslatableNodes += 1;
|
|
32
|
+
stats.skippedNonTranslatableChars += raw.length;
|
|
33
|
+
return;
|
|
34
|
+
}
|
|
35
|
+
const original = getOrInitTextOriginal(node, parent);
|
|
36
|
+
stats.markedNodes += 1;
|
|
37
|
+
stats.markedChars += raw.length;
|
|
38
|
+
if (segments.length < maxSegments) {
|
|
39
|
+
const originalText = normalizeWhitespace(original.trimmed) || null;
|
|
40
|
+
const currentText = normalizeWhitespace(node.nodeValue || "") || null;
|
|
41
|
+
segments.push({
|
|
42
|
+
kind: "text",
|
|
43
|
+
selector: buildSelector(parent),
|
|
44
|
+
original: originalText,
|
|
45
|
+
current: currentText,
|
|
46
|
+
html: null,
|
|
47
|
+
});
|
|
48
|
+
if (originalText && !seen.has(originalText)) {
|
|
49
|
+
seen.add(originalText);
|
|
50
|
+
occurrences.push({ source_text: originalText, semantic_context: "text" });
|
|
51
|
+
}
|
|
52
|
+
if (critical?.enabled && originalText && !critical.seen.has(originalText)) {
|
|
53
|
+
const rect = getTextNodeRect(node);
|
|
54
|
+
if (isInViewport(rect, critical.viewportHeight, critical.bufferPx)) {
|
|
55
|
+
critical.seen.add(originalText);
|
|
56
|
+
critical.occurrences.push({ source_text: originalText, semantic_context: "critical:text" });
|
|
57
|
+
}
|
|
58
|
+
}
|
|
59
|
+
}
|
|
60
|
+
}
|
|
61
|
+
function considerAttributes(root, segments, occurrences, seen, maxSegments, critical) {
|
|
62
|
+
const nodes = root.querySelectorAll("[title],[aria-label],[placeholder]");
|
|
63
|
+
nodes.forEach((el) => {
|
|
64
|
+
if (isExcludedElement(el))
|
|
65
|
+
return;
|
|
66
|
+
if (findUnsafeContainer(el))
|
|
67
|
+
return;
|
|
68
|
+
for (const { attr } of ATTRIBUTE_MARKS) {
|
|
69
|
+
const value = el.getAttribute(attr);
|
|
70
|
+
if (!value)
|
|
71
|
+
continue;
|
|
72
|
+
const trimmed = value.trim();
|
|
73
|
+
if (!trimmed || !isTranslatableText(trimmed))
|
|
74
|
+
continue;
|
|
75
|
+
const original = normalizeWhitespace(getOrInitAttrOriginal(el, attr)) || null;
|
|
76
|
+
const current = normalizeWhitespace(el.getAttribute(attr) || "") || null;
|
|
77
|
+
const kind = (attr === "title" ? "title" : attr === "aria-label" ? "aria-label" : "placeholder");
|
|
78
|
+
if (segments.length < maxSegments) {
|
|
79
|
+
segments.push({
|
|
80
|
+
kind,
|
|
81
|
+
selector: buildSelector(el),
|
|
82
|
+
original,
|
|
83
|
+
current,
|
|
84
|
+
html: null,
|
|
85
|
+
});
|
|
86
|
+
}
|
|
87
|
+
if (original && !seen.has(original)) {
|
|
88
|
+
seen.add(original);
|
|
89
|
+
occurrences.push({ source_text: original, semantic_context: `attr:${attr}` });
|
|
90
|
+
}
|
|
91
|
+
if (critical?.enabled && original && !critical.seen.has(original)) {
|
|
92
|
+
let rect = null;
|
|
93
|
+
try {
|
|
94
|
+
rect = el.getBoundingClientRect();
|
|
95
|
+
}
|
|
96
|
+
catch {
|
|
97
|
+
rect = null;
|
|
98
|
+
}
|
|
99
|
+
if (isInViewport(rect, critical.viewportHeight, critical.bufferPx)) {
|
|
100
|
+
critical.seen.add(original);
|
|
101
|
+
critical.occurrences.push({ source_text: original, semantic_context: `critical:attr:${attr}` });
|
|
102
|
+
}
|
|
103
|
+
}
|
|
104
|
+
}
|
|
105
|
+
});
|
|
106
|
+
}
|
|
107
|
+
export function scanDom(opts) {
|
|
108
|
+
const root = document.body;
|
|
109
|
+
if (!root) {
|
|
110
|
+
const empty = buildEmptyStats();
|
|
111
|
+
return { version: 1, stats: empty, segments: [], occurrences: [], truncated: false };
|
|
112
|
+
}
|
|
113
|
+
const stats = buildEmptyStats();
|
|
114
|
+
const maxSegments = Math.max(0, Math.floor(opts.maxSegments || 0)) || 20000;
|
|
115
|
+
const includeCritical = opts.includeCritical === true;
|
|
116
|
+
const viewportHeight = includeCritical ? Math.max(0, Math.floor(window.innerHeight || 0)) : 0;
|
|
117
|
+
const viewportWidth = includeCritical ? Math.max(0, Math.floor(window.innerWidth || 0)) : 0;
|
|
118
|
+
// Why: include a small buffer so "near the fold" text is ready without delaying first paint.
|
|
119
|
+
const critical = includeCritical
|
|
120
|
+
? {
|
|
121
|
+
enabled: true,
|
|
122
|
+
viewportHeight,
|
|
123
|
+
bufferPx: DEFAULT_CRITICAL_BUFFER_PX,
|
|
124
|
+
max: DEFAULT_CRITICAL_MAX,
|
|
125
|
+
seen: new Set(),
|
|
126
|
+
occurrences: [],
|
|
127
|
+
}
|
|
128
|
+
: null;
|
|
129
|
+
const walker = document.createTreeWalker(root, NodeFilter.SHOW_TEXT);
|
|
130
|
+
const nodes = [];
|
|
131
|
+
const segments = [];
|
|
132
|
+
const occurrences = [];
|
|
133
|
+
const seen = new Set();
|
|
134
|
+
let node = walker.nextNode();
|
|
135
|
+
while (node) {
|
|
136
|
+
if (node.nodeType === Node.TEXT_NODE)
|
|
137
|
+
nodes.push(node);
|
|
138
|
+
node = walker.nextNode();
|
|
139
|
+
}
|
|
140
|
+
nodes.forEach((textNode) => {
|
|
141
|
+
if (critical?.enabled && critical.occurrences.length >= critical.max) {
|
|
142
|
+
critical.enabled = false;
|
|
143
|
+
}
|
|
144
|
+
considerTextNode(textNode, stats, segments, occurrences, seen, maxSegments, critical);
|
|
145
|
+
});
|
|
146
|
+
considerAttributes(root, segments, occurrences, seen, maxSegments, critical);
|
|
147
|
+
finalizeStats(stats);
|
|
148
|
+
const truncated = segments.length >= maxSegments;
|
|
149
|
+
return {
|
|
150
|
+
version: 1,
|
|
151
|
+
stats,
|
|
152
|
+
segments,
|
|
153
|
+
occurrences,
|
|
154
|
+
...(includeCritical
|
|
155
|
+
? {
|
|
156
|
+
critical_occurrences: critical?.occurrences ?? [],
|
|
157
|
+
viewport: { width: viewportWidth, height: viewportHeight },
|
|
158
|
+
}
|
|
159
|
+
: {}),
|
|
160
|
+
truncated,
|
|
161
|
+
};
|
|
162
|
+
}
|