@lovalingo/lovalingo 0.1.2 โ 0.2.0
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 +5 -3
- package/dist/components/AixsterProvider.js +60 -56
- package/dist/utils/markerEngine.d.ts +23 -0
- package/dist/utils/markerEngine.js +294 -57
- package/dist/version.d.ts +1 -1
- package/dist/version.js +1 -1
- package/package.json +1 -1
package/README.md
CHANGED
|
@@ -1,10 +1,10 @@
|
|
|
1
1
|
# @lovalingo/lovalingo
|
|
2
2
|
|
|
3
|
-
Lovalingo is a translation
|
|
3
|
+
Lovalingo is a React translation library and AI-powered i18n alternative for Lovable, v0, Bolt, and other vibe-coding tools. It delivers SEO-friendly i18n URLs, zero-flash auto-translation, and deterministic JSON bundles. Best Weglot alternative for developers; learn more at https://lovalingo.com.
|
|
4
4
|
|
|
5
|
-
|
|
5
|
+
Built for React and Next.js apps, Lovalingo does **not** generate translations in the browser and keeps pricing simple (free up to 500 visitors). See https://lovalingo.com/use-cases for examples.
|
|
6
6
|
|
|
7
|
-
##
|
|
7
|
+
## i18n alternative for lovable and vibecoding tools
|
|
8
8
|
|
|
9
9
|
1. Your app renders normally (source language).
|
|
10
10
|
2. Lovalingo loads the current localeโs bundle from the backend.
|
|
@@ -30,6 +30,8 @@ Debug (runtime logs): set `window.__lovalingoDebug = true` before initializing `
|
|
|
30
30
|
npm install @lovalingo/lovalingo react-router-dom
|
|
31
31
|
```
|
|
32
32
|
|
|
33
|
+
Pricing and onboarding: https://lovalingo.com
|
|
34
|
+
|
|
33
35
|
## React Router
|
|
34
36
|
|
|
35
37
|
### Query mode (default)
|
|
@@ -1,9 +1,8 @@
|
|
|
1
1
|
import React, { useMemo, useState, useEffect, useCallback, useRef } from 'react';
|
|
2
2
|
import { LovalingoContext } from '../context/LovalingoContext';
|
|
3
3
|
import { LovalingoAPI } from '../utils/api';
|
|
4
|
-
import { Translator } from '../utils/translator';
|
|
5
4
|
import { applyDomRules } from '../utils/domRules';
|
|
6
|
-
import { startMarkerEngine } from '../utils/markerEngine';
|
|
5
|
+
import { applyActiveTranslations, restoreDom, setActiveTranslations, setMarkerEngineExclusions, startMarkerEngine } from '../utils/markerEngine';
|
|
7
6
|
import { logDebug, warnDebug, errorDebug } from '../utils/logger';
|
|
8
7
|
import { LanguageSwitcher } from './LanguageSwitcher';
|
|
9
8
|
import { NavigationOverlay } from './NavigationOverlay';
|
|
@@ -51,14 +50,12 @@ navigateRef, // For path mode routing
|
|
|
51
50
|
const [isLoading, setIsLoading] = useState(false);
|
|
52
51
|
const [isNavigationLoading, setIsNavigationLoading] = useState(false);
|
|
53
52
|
const [editMode, setEditMode] = useState(initialEditMode);
|
|
54
|
-
const translatorRef = useRef(new Translator());
|
|
55
53
|
// Enhanced path normalization with supportedLocales for path mode
|
|
56
54
|
const enhancedPathConfig = routing === 'path'
|
|
57
55
|
? { ...pathNormalization, supportedLocales: allLocales }
|
|
58
56
|
: pathNormalization;
|
|
59
57
|
const apiRef = useRef(new LovalingoAPI(resolvedApiKey, apiBase, enhancedPathConfig));
|
|
60
58
|
const [entitlements, setEntitlements] = useState(() => apiRef.current.getEntitlements());
|
|
61
|
-
const observerRef = useRef(null);
|
|
62
59
|
const retryTimeoutRef = useRef(null);
|
|
63
60
|
const isNavigatingRef = useRef(false);
|
|
64
61
|
const isInternalNavigationRef = useRef(false);
|
|
@@ -276,8 +273,8 @@ navigateRef, // For path mode routing
|
|
|
276
273
|
if (targetLocale === defaultLocale) {
|
|
277
274
|
if (showOverlay)
|
|
278
275
|
setIsNavigationLoading(false);
|
|
279
|
-
|
|
280
|
-
|
|
276
|
+
setActiveTranslations(null);
|
|
277
|
+
restoreDom(document.body); // React-safe: only text/attrs, no DOM structure mutation
|
|
281
278
|
isNavigatingRef.current = false;
|
|
282
279
|
return;
|
|
283
280
|
}
|
|
@@ -291,10 +288,10 @@ navigateRef, // For path mode routing
|
|
|
291
288
|
if (cachedEntry && cachedExclusions) {
|
|
292
289
|
// CACHE HIT - Use cached data immediately (FAST!)
|
|
293
290
|
logDebug(`[Lovalingo] Using cached translations for ${targetLocale} on ${currentPath}`);
|
|
294
|
-
|
|
295
|
-
|
|
291
|
+
setActiveTranslations(cachedEntry.translations);
|
|
292
|
+
setMarkerEngineExclusions(cachedExclusions);
|
|
296
293
|
if (mode === 'dom') {
|
|
297
|
-
|
|
294
|
+
applyActiveTranslations(document.body);
|
|
298
295
|
}
|
|
299
296
|
if (autoApplyRules) {
|
|
300
297
|
if (Array.isArray(cachedDomRules)) {
|
|
@@ -314,7 +311,7 @@ navigateRef, // For path mode routing
|
|
|
314
311
|
}
|
|
315
312
|
logDebug(`[Lovalingo] ๐ Retry scan for late-rendering content`);
|
|
316
313
|
if (mode === 'dom') {
|
|
317
|
-
|
|
314
|
+
applyActiveTranslations(document.body);
|
|
318
315
|
}
|
|
319
316
|
if (autoApplyRules) {
|
|
320
317
|
const rules = domRulesCacheRef.current.get(cacheKey) || cachedDomRules || [];
|
|
@@ -356,10 +353,10 @@ navigateRef, // For path mode routing
|
|
|
356
353
|
if (autoApplyRules) {
|
|
357
354
|
domRulesCacheRef.current.set(cacheKey, domRules);
|
|
358
355
|
}
|
|
359
|
-
|
|
360
|
-
|
|
356
|
+
setActiveTranslations(translations);
|
|
357
|
+
setMarkerEngineExclusions(exclusions);
|
|
361
358
|
if (mode === 'dom') {
|
|
362
|
-
|
|
359
|
+
applyActiveTranslations(document.body);
|
|
363
360
|
}
|
|
364
361
|
if (autoApplyRules) {
|
|
365
362
|
applyDomRules(domRules);
|
|
@@ -372,7 +369,7 @@ navigateRef, // For path mode routing
|
|
|
372
369
|
}
|
|
373
370
|
logDebug(`[Lovalingo] ๐ Retry scan for late-rendering content`);
|
|
374
371
|
if (mode === "dom") {
|
|
375
|
-
|
|
372
|
+
applyActiveTranslations(document.body);
|
|
376
373
|
}
|
|
377
374
|
if (autoApplyRules) {
|
|
378
375
|
const rules = domRulesCacheRef.current.get(cacheKey) || domRules || [];
|
|
@@ -392,7 +389,47 @@ navigateRef, // For path mode routing
|
|
|
392
389
|
setIsLoading(false);
|
|
393
390
|
isNavigatingRef.current = false;
|
|
394
391
|
}
|
|
395
|
-
}, [defaultLocale]);
|
|
392
|
+
}, [autoApplyRules, defaultLocale, mode]);
|
|
393
|
+
// SPA router hook-in: track History API navigations (React Router/Next/etc) without app changes.
|
|
394
|
+
useEffect(() => {
|
|
395
|
+
const historyObj = window.history;
|
|
396
|
+
const originalPushState = historyObj.pushState.bind(historyObj);
|
|
397
|
+
const originalReplaceState = historyObj.replaceState.bind(historyObj);
|
|
398
|
+
const onNavigate = () => {
|
|
399
|
+
try {
|
|
400
|
+
apiRef.current.trackPageview(window.location.pathname + window.location.search);
|
|
401
|
+
}
|
|
402
|
+
catch {
|
|
403
|
+
// ignore
|
|
404
|
+
}
|
|
405
|
+
const nextLocale = detectLocale();
|
|
406
|
+
if (nextLocale !== locale) {
|
|
407
|
+
setLocaleState(nextLocale);
|
|
408
|
+
void loadData(nextLocale, locale, false);
|
|
409
|
+
}
|
|
410
|
+
else if (mode === "dom" && nextLocale !== defaultLocale) {
|
|
411
|
+
applyActiveTranslations(document.body);
|
|
412
|
+
}
|
|
413
|
+
};
|
|
414
|
+
historyObj.pushState = ((...args) => {
|
|
415
|
+
const ret = originalPushState(...args);
|
|
416
|
+
onNavigate();
|
|
417
|
+
return ret;
|
|
418
|
+
});
|
|
419
|
+
historyObj.replaceState = ((...args) => {
|
|
420
|
+
const ret = originalReplaceState(...args);
|
|
421
|
+
onNavigate();
|
|
422
|
+
return ret;
|
|
423
|
+
});
|
|
424
|
+
window.addEventListener("popstate", onNavigate);
|
|
425
|
+
window.addEventListener("hashchange", onNavigate);
|
|
426
|
+
return () => {
|
|
427
|
+
historyObj.pushState = originalPushState;
|
|
428
|
+
historyObj.replaceState = originalReplaceState;
|
|
429
|
+
window.removeEventListener("popstate", onNavigate);
|
|
430
|
+
window.removeEventListener("hashchange", onNavigate);
|
|
431
|
+
};
|
|
432
|
+
}, [defaultLocale, detectLocale, loadData, locale, mode]);
|
|
396
433
|
// Change locale
|
|
397
434
|
const setLocale = useCallback((newLocale) => {
|
|
398
435
|
void (async () => {
|
|
@@ -729,50 +766,17 @@ navigateRef, // For path mode routing
|
|
|
729
766
|
document.removeEventListener('click', onClickCapture, true);
|
|
730
767
|
};
|
|
731
768
|
}, [routing, autoPrefixLinks, allLocales, locale, navigateRef]);
|
|
732
|
-
//
|
|
733
|
-
useEffect(() => {
|
|
734
|
-
if (mode !== 'dom')
|
|
735
|
-
return; // Skip for context mode
|
|
736
|
-
if (locale === defaultLocale)
|
|
737
|
-
return;
|
|
738
|
-
const observer = new MutationObserver((mutations) => {
|
|
739
|
-
// SKIP translation during navigation to prevent React conflicts
|
|
740
|
-
if (isNavigatingRef.current) {
|
|
741
|
-
return;
|
|
742
|
-
}
|
|
743
|
-
mutations.forEach((mutation) => {
|
|
744
|
-
// Avoid feedback loops: ignore mutations caused by Lovalingo DOM updates.
|
|
745
|
-
if (mutation.target instanceof HTMLElement) {
|
|
746
|
-
if (mutation.target.closest('[data-Lovalingo-translating="1"]')) {
|
|
747
|
-
return;
|
|
748
|
-
}
|
|
749
|
-
}
|
|
750
|
-
mutation.addedNodes.forEach((node) => {
|
|
751
|
-
if (node.nodeType === Node.ELEMENT_NODE) {
|
|
752
|
-
const el = node;
|
|
753
|
-
if (el.closest?.('[data-Lovalingo-translating="1"]'))
|
|
754
|
-
return;
|
|
755
|
-
translatorRef.current.translateElement(node);
|
|
756
|
-
}
|
|
757
|
-
});
|
|
758
|
-
});
|
|
759
|
-
});
|
|
760
|
-
observer.observe(document.body, {
|
|
761
|
-
childList: true,
|
|
762
|
-
subtree: true,
|
|
763
|
-
});
|
|
764
|
-
observerRef.current = observer;
|
|
765
|
-
return () => {
|
|
766
|
-
observer.disconnect();
|
|
767
|
-
observerRef.current = null;
|
|
768
|
-
};
|
|
769
|
-
}, [locale, defaultLocale, mode]);
|
|
769
|
+
// Dynamic DOM updates are handled by the marker engine observer (React-safe in-place text/attr updates).
|
|
770
770
|
// No periodic string-miss reporting. Page discovery is tracked via pageview only.
|
|
771
771
|
const translateElement = useCallback((element) => {
|
|
772
|
-
|
|
772
|
+
if (mode !== "dom")
|
|
773
|
+
return;
|
|
774
|
+
applyActiveTranslations(element);
|
|
773
775
|
}, []);
|
|
774
776
|
const translateDOM = useCallback(() => {
|
|
775
|
-
|
|
777
|
+
if (mode !== "dom")
|
|
778
|
+
return;
|
|
779
|
+
applyActiveTranslations(document.body);
|
|
776
780
|
}, []);
|
|
777
781
|
const toggleEditMode = useCallback(() => {
|
|
778
782
|
setEditMode(prev => !prev);
|
|
@@ -780,7 +784,7 @@ navigateRef, // For path mode routing
|
|
|
780
784
|
const excludeElement = useCallback(async (selector) => {
|
|
781
785
|
await apiRef.current.saveExclusion(selector, 'css');
|
|
782
786
|
const exclusions = await apiRef.current.fetchExclusions();
|
|
783
|
-
|
|
787
|
+
setMarkerEngineExclusions(exclusions);
|
|
784
788
|
}, []);
|
|
785
789
|
const contextValue = {
|
|
786
790
|
locale,
|
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import type { Exclusion, Translation } from "../types";
|
|
1
2
|
type MarkerStats = {
|
|
2
3
|
totalTextNodes: number;
|
|
3
4
|
markedNodes: number;
|
|
@@ -15,7 +16,29 @@ type MarkerStats = {
|
|
|
15
16
|
type MarkerEngineOptions = {
|
|
16
17
|
throttleMs?: number;
|
|
17
18
|
};
|
|
19
|
+
export type DomScanOccurrence = {
|
|
20
|
+
source_text: string;
|
|
21
|
+
semantic_context?: string;
|
|
22
|
+
};
|
|
23
|
+
export type DomScanSegment = {
|
|
24
|
+
kind: "text" | "title" | "aria-label" | "placeholder";
|
|
25
|
+
selector: string | null;
|
|
26
|
+
original: string | null;
|
|
27
|
+
current: string | null;
|
|
28
|
+
html: null;
|
|
29
|
+
};
|
|
30
|
+
export type DomScanResult = {
|
|
31
|
+
version: 1;
|
|
32
|
+
stats: MarkerStats;
|
|
33
|
+
segments: DomScanSegment[];
|
|
34
|
+
occurrences: DomScanOccurrence[];
|
|
35
|
+
truncated: boolean;
|
|
36
|
+
};
|
|
18
37
|
export declare function startMarkerEngine(options?: MarkerEngineOptions): typeof stopMarkerEngine;
|
|
19
38
|
export declare function stopMarkerEngine(): void;
|
|
20
39
|
export declare function getMarkerStats(): MarkerStats;
|
|
40
|
+
export declare function setMarkerEngineExclusions(exclusions: Exclusion[] | null): void;
|
|
41
|
+
export declare function setActiveTranslations(translations: Translation[] | null): void;
|
|
42
|
+
export declare function applyActiveTranslations(root?: ParentNode | null): number;
|
|
43
|
+
export declare function restoreDom(root?: ParentNode | null): void;
|
|
21
44
|
export {};
|
|
@@ -1,9 +1,7 @@
|
|
|
1
1
|
import { hashContent } from "./hash";
|
|
2
2
|
const DEFAULT_THROTTLE_MS = 150;
|
|
3
3
|
const EXCLUDE_SELECTOR = "[data-lovalingo-exclude],[data-notranslate],[translate-no],[data-no-translate]";
|
|
4
|
-
const MARKER_SELECTOR = "[data-lovalingo-original]";
|
|
5
4
|
const UNSAFE_CONTAINER_TAGS = new Set(["script", "style", "noscript", "template", "svg", "canvas"]);
|
|
6
|
-
const DIRECT_MARK_TAGS = new Set(["option", "textarea"]);
|
|
7
5
|
const ATTRIBUTE_MARKS = [
|
|
8
6
|
{ attr: "title", marker: "data-lovalingo-title-original" },
|
|
9
7
|
{ attr: "aria-label", marker: "data-lovalingo-aria-label-original" },
|
|
@@ -15,6 +13,11 @@ let scheduled = null;
|
|
|
15
13
|
let running = false;
|
|
16
14
|
let lastStats = buildEmptyStats();
|
|
17
15
|
let throttleMs = DEFAULT_THROTTLE_MS;
|
|
16
|
+
let applying = false;
|
|
17
|
+
let customExcludeSelector = null;
|
|
18
|
+
let activeTranslationMap = null;
|
|
19
|
+
const originalTextByNode = new WeakMap();
|
|
20
|
+
const originalAttrByEl = new WeakMap();
|
|
18
21
|
function buildEmptyStats() {
|
|
19
22
|
return {
|
|
20
23
|
totalTextNodes: 0,
|
|
@@ -37,11 +40,31 @@ function setGlobalStats(stats) {
|
|
|
37
40
|
return;
|
|
38
41
|
window.__lovalingoMarkersReady = true;
|
|
39
42
|
window.__lovalingoMarkerStats = stats;
|
|
43
|
+
const g = window;
|
|
44
|
+
if (!g.__lovalingo)
|
|
45
|
+
g.__lovalingo = {};
|
|
46
|
+
if (!g.__lovalingo.dom)
|
|
47
|
+
g.__lovalingo.dom = {};
|
|
48
|
+
g.__lovalingo.dom.getStats = () => lastStats;
|
|
49
|
+
g.__lovalingo.dom.scan = () => scanDom({ maxSegments: 20000 });
|
|
50
|
+
g.__lovalingo.dom.apply = (bundle) => ({ applied: applyTranslationMap(bundle, document.body) });
|
|
51
|
+
g.__lovalingo.dom.restore = () => restoreDom(document.body);
|
|
40
52
|
}
|
|
41
53
|
function isExcludedElement(el) {
|
|
42
54
|
if (!el)
|
|
43
55
|
return false;
|
|
44
|
-
|
|
56
|
+
if (el.closest(EXCLUDE_SELECTOR))
|
|
57
|
+
return true;
|
|
58
|
+
if (customExcludeSelector) {
|
|
59
|
+
try {
|
|
60
|
+
if (el.closest(customExcludeSelector))
|
|
61
|
+
return true;
|
|
62
|
+
}
|
|
63
|
+
catch {
|
|
64
|
+
// ignore invalid selector strings
|
|
65
|
+
}
|
|
66
|
+
}
|
|
67
|
+
return false;
|
|
45
68
|
}
|
|
46
69
|
function findUnsafeContainer(el) {
|
|
47
70
|
if (!el)
|
|
@@ -85,6 +108,9 @@ function buildElementPath(el) {
|
|
|
85
108
|
parts.push("body");
|
|
86
109
|
return parts.reverse().join("/");
|
|
87
110
|
}
|
|
111
|
+
function normalizeWhitespace(value) {
|
|
112
|
+
return (value || "").toString().replace(/\s+/g, " ").trim();
|
|
113
|
+
}
|
|
88
114
|
function isTranslatableText(text) {
|
|
89
115
|
if (!text || text.trim().length < 2)
|
|
90
116
|
return false;
|
|
@@ -102,18 +128,51 @@ function buildStableId(el, text, textIndex) {
|
|
|
102
128
|
const raw = `${path}#text[${textIndex}]|${text.trim()}|${key}`;
|
|
103
129
|
return hashContent(raw);
|
|
104
130
|
}
|
|
105
|
-
function
|
|
106
|
-
|
|
107
|
-
|
|
131
|
+
function buildSelector(el) {
|
|
132
|
+
const id = el.id;
|
|
133
|
+
if (id)
|
|
134
|
+
return `#${id.replace(/[^a-zA-Z0-9_-]/g, "\\$&")}`;
|
|
135
|
+
const className = el.className;
|
|
136
|
+
if (typeof className === "string" && className.trim()) {
|
|
137
|
+
const classes = className
|
|
138
|
+
.split(/\s+/)
|
|
139
|
+
.map((c) => c.trim())
|
|
140
|
+
.filter(Boolean)
|
|
141
|
+
.slice(0, 3)
|
|
142
|
+
.map((c) => `.${c.replace(/[^a-zA-Z0-9_-]/g, "\\$&")}`)
|
|
143
|
+
.join("");
|
|
144
|
+
if (classes)
|
|
145
|
+
return classes;
|
|
108
146
|
}
|
|
109
|
-
|
|
110
|
-
|
|
147
|
+
return null;
|
|
148
|
+
}
|
|
149
|
+
function getOrInitTextOriginal(node, parent) {
|
|
150
|
+
const existing = originalTextByNode.get(node);
|
|
151
|
+
if (existing)
|
|
152
|
+
return existing;
|
|
153
|
+
const raw = node.nodeValue || "";
|
|
154
|
+
const leading = raw.match(/^\s*/)?.[0] ?? "";
|
|
155
|
+
const trailing = raw.match(/\s*$/)?.[0] ?? "";
|
|
156
|
+
const trimmed = raw.trim();
|
|
157
|
+
const id = buildStableId(parent, trimmed, getTextNodeIndex(node));
|
|
158
|
+
const created = { raw, trimmed, leading, trailing, id };
|
|
159
|
+
originalTextByNode.set(node, created);
|
|
160
|
+
return created;
|
|
161
|
+
}
|
|
162
|
+
function getOrInitAttrOriginal(el, attr) {
|
|
163
|
+
let map = originalAttrByEl.get(el);
|
|
164
|
+
if (!map) {
|
|
165
|
+
map = new Map();
|
|
166
|
+
originalAttrByEl.set(el, map);
|
|
111
167
|
}
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
168
|
+
const existing = map.get(attr);
|
|
169
|
+
if (existing != null)
|
|
170
|
+
return existing;
|
|
171
|
+
const value = (el.getAttribute(attr) || "").toString();
|
|
172
|
+
map.set(attr, value);
|
|
173
|
+
return value;
|
|
115
174
|
}
|
|
116
|
-
function
|
|
175
|
+
function considerTextNode(node, stats, segments, occurrences, seen, maxSegments) {
|
|
117
176
|
const raw = node.nodeValue || "";
|
|
118
177
|
if (!raw)
|
|
119
178
|
return;
|
|
@@ -134,9 +193,6 @@ function markTextNode(node, stats) {
|
|
|
134
193
|
if (unsafe) {
|
|
135
194
|
stats.skippedUnsafeNodes += 1;
|
|
136
195
|
stats.skippedUnsafeChars += raw.length;
|
|
137
|
-
if (!unsafe.hasAttribute("data-lovalingo-unsafe")) {
|
|
138
|
-
unsafe.setAttribute("data-lovalingo-unsafe", unsafe.tagName.toLowerCase());
|
|
139
|
-
}
|
|
140
196
|
return;
|
|
141
197
|
}
|
|
142
198
|
if (!isTranslatableText(trimmed)) {
|
|
@@ -144,53 +200,55 @@ function markTextNode(node, stats) {
|
|
|
144
200
|
stats.skippedNonTranslatableChars += raw.length;
|
|
145
201
|
return;
|
|
146
202
|
}
|
|
147
|
-
const
|
|
148
|
-
if (existingMarker) {
|
|
149
|
-
if (!existingMarker.getAttribute("data-lovalingo-id")) {
|
|
150
|
-
const textIndex = getTextNodeIndex(node);
|
|
151
|
-
existingMarker.setAttribute("data-lovalingo-id", buildStableId(parent, raw, textIndex));
|
|
152
|
-
}
|
|
153
|
-
stats.markedNodes += 1;
|
|
154
|
-
stats.markedChars += raw.length;
|
|
155
|
-
return;
|
|
156
|
-
}
|
|
157
|
-
const parentTag = parent.tagName.toLowerCase();
|
|
158
|
-
if (DIRECT_MARK_TAGS.has(parentTag)) {
|
|
159
|
-
markElementDirect(parent, raw, stats);
|
|
160
|
-
return;
|
|
161
|
-
}
|
|
162
|
-
const wrapper = document.createElement("span");
|
|
163
|
-
wrapper.setAttribute("data-lovalingo-original", trimmed);
|
|
164
|
-
wrapper.setAttribute("data-lovalingo-id", buildStableId(parent, raw, getTextNodeIndex(node)));
|
|
165
|
-
wrapper.setAttribute("data-lovalingo-kind", "text");
|
|
166
|
-
wrapper.setAttribute("data-lovalingo-marker", "1");
|
|
167
|
-
wrapper.textContent = raw;
|
|
168
|
-
try {
|
|
169
|
-
parent.replaceChild(wrapper, node);
|
|
170
|
-
}
|
|
171
|
-
catch {
|
|
172
|
-
return;
|
|
173
|
-
}
|
|
203
|
+
const original = getOrInitTextOriginal(node, parent);
|
|
174
204
|
stats.markedNodes += 1;
|
|
175
205
|
stats.markedChars += raw.length;
|
|
206
|
+
if (segments.length < maxSegments) {
|
|
207
|
+
const originalText = normalizeWhitespace(original.trimmed) || null;
|
|
208
|
+
const currentText = normalizeWhitespace(node.nodeValue || "") || null;
|
|
209
|
+
segments.push({
|
|
210
|
+
kind: "text",
|
|
211
|
+
selector: buildSelector(parent),
|
|
212
|
+
original: originalText,
|
|
213
|
+
current: currentText,
|
|
214
|
+
html: null,
|
|
215
|
+
});
|
|
216
|
+
if (originalText && !seen.has(originalText)) {
|
|
217
|
+
seen.add(originalText);
|
|
218
|
+
occurrences.push({ source_text: originalText, semantic_context: "text" });
|
|
219
|
+
}
|
|
220
|
+
}
|
|
176
221
|
}
|
|
177
|
-
function
|
|
222
|
+
function considerAttributes(root, segments, occurrences, seen, maxSegments) {
|
|
178
223
|
const nodes = root.querySelectorAll("[title],[aria-label],[placeholder]");
|
|
179
224
|
nodes.forEach((el) => {
|
|
180
225
|
if (isExcludedElement(el))
|
|
181
226
|
return;
|
|
182
227
|
if (findUnsafeContainer(el))
|
|
183
228
|
return;
|
|
184
|
-
for (const { attr
|
|
185
|
-
if (el.hasAttribute(marker))
|
|
186
|
-
continue;
|
|
229
|
+
for (const { attr } of ATTRIBUTE_MARKS) {
|
|
187
230
|
const value = el.getAttribute(attr);
|
|
188
231
|
if (!value)
|
|
189
232
|
continue;
|
|
190
233
|
const trimmed = value.trim();
|
|
191
234
|
if (!trimmed || !isTranslatableText(trimmed))
|
|
192
235
|
continue;
|
|
193
|
-
el
|
|
236
|
+
const original = normalizeWhitespace(getOrInitAttrOriginal(el, attr)) || null;
|
|
237
|
+
const current = normalizeWhitespace(el.getAttribute(attr) || "") || null;
|
|
238
|
+
const kind = (attr === "title" ? "title" : attr === "aria-label" ? "aria-label" : "placeholder");
|
|
239
|
+
if (segments.length < maxSegments) {
|
|
240
|
+
segments.push({
|
|
241
|
+
kind,
|
|
242
|
+
selector: buildSelector(el),
|
|
243
|
+
original,
|
|
244
|
+
current,
|
|
245
|
+
html: null,
|
|
246
|
+
});
|
|
247
|
+
}
|
|
248
|
+
if (original && !seen.has(original)) {
|
|
249
|
+
seen.add(original);
|
|
250
|
+
occurrences.push({ source_text: original, semantic_context: `attr:${attr}` });
|
|
251
|
+
}
|
|
194
252
|
}
|
|
195
253
|
});
|
|
196
254
|
}
|
|
@@ -206,27 +264,38 @@ function finalizeStats(stats) {
|
|
|
206
264
|
stats.coverageRatio = eligibleNodes > 0 ? stats.markedNodes / eligibleNodes : 1;
|
|
207
265
|
stats.coverageRatioChars = eligibleChars > 0 ? stats.markedChars / eligibleChars : 1;
|
|
208
266
|
}
|
|
209
|
-
function
|
|
210
|
-
if (!running)
|
|
211
|
-
return;
|
|
267
|
+
function scanDom(opts) {
|
|
212
268
|
const root = document.body;
|
|
213
269
|
if (!root) {
|
|
214
|
-
|
|
215
|
-
|
|
270
|
+
const empty = buildEmptyStats();
|
|
271
|
+
setGlobalStats(empty);
|
|
272
|
+
return { version: 1, stats: empty, segments: [], occurrences: [], truncated: false };
|
|
216
273
|
}
|
|
217
274
|
const stats = buildEmptyStats();
|
|
275
|
+
const maxSegments = Math.max(0, Math.floor(opts.maxSegments || 0)) || 20000;
|
|
218
276
|
const walker = document.createTreeWalker(root, NodeFilter.SHOW_TEXT);
|
|
219
277
|
const nodes = [];
|
|
278
|
+
const segments = [];
|
|
279
|
+
const occurrences = [];
|
|
280
|
+
const seen = new Set();
|
|
220
281
|
let node = walker.nextNode();
|
|
221
282
|
while (node) {
|
|
222
283
|
if (node.nodeType === Node.TEXT_NODE)
|
|
223
284
|
nodes.push(node);
|
|
224
285
|
node = walker.nextNode();
|
|
225
286
|
}
|
|
226
|
-
nodes.forEach((textNode) =>
|
|
227
|
-
|
|
287
|
+
nodes.forEach((textNode) => considerTextNode(textNode, stats, segments, occurrences, seen, maxSegments));
|
|
288
|
+
considerAttributes(root, segments, occurrences, seen, maxSegments);
|
|
228
289
|
finalizeStats(stats);
|
|
229
290
|
setGlobalStats(stats);
|
|
291
|
+
const truncated = segments.length >= maxSegments;
|
|
292
|
+
return {
|
|
293
|
+
version: 1,
|
|
294
|
+
stats,
|
|
295
|
+
segments,
|
|
296
|
+
occurrences,
|
|
297
|
+
truncated,
|
|
298
|
+
};
|
|
230
299
|
}
|
|
231
300
|
function scheduleScan() {
|
|
232
301
|
if (!running)
|
|
@@ -235,7 +304,16 @@ function scheduleScan() {
|
|
|
235
304
|
return;
|
|
236
305
|
scheduled = window.setTimeout(() => {
|
|
237
306
|
scheduled = null;
|
|
238
|
-
|
|
307
|
+
try {
|
|
308
|
+
scanDom({ maxSegments: 20000 });
|
|
309
|
+
if (activeTranslationMap) {
|
|
310
|
+
applying = true;
|
|
311
|
+
applyActiveTranslations(document.body);
|
|
312
|
+
}
|
|
313
|
+
}
|
|
314
|
+
finally {
|
|
315
|
+
applying = false;
|
|
316
|
+
}
|
|
239
317
|
}, throttleMs);
|
|
240
318
|
}
|
|
241
319
|
export function startMarkerEngine(options = {}) {
|
|
@@ -252,13 +330,17 @@ export function startMarkerEngine(options = {}) {
|
|
|
252
330
|
window.setTimeout(startObserver, 50);
|
|
253
331
|
return;
|
|
254
332
|
}
|
|
255
|
-
observer = new MutationObserver(() =>
|
|
333
|
+
observer = new MutationObserver(() => {
|
|
334
|
+
if (applying)
|
|
335
|
+
return;
|
|
336
|
+
scheduleScan();
|
|
337
|
+
});
|
|
256
338
|
observer.observe(document.body, {
|
|
257
339
|
childList: true,
|
|
258
340
|
subtree: true,
|
|
259
341
|
characterData: true,
|
|
260
342
|
});
|
|
261
|
-
|
|
343
|
+
scanDom({ maxSegments: 20000 });
|
|
262
344
|
};
|
|
263
345
|
startObserver();
|
|
264
346
|
return stopMarkerEngine;
|
|
@@ -277,3 +359,158 @@ export function stopMarkerEngine() {
|
|
|
277
359
|
export function getMarkerStats() {
|
|
278
360
|
return lastStats;
|
|
279
361
|
}
|
|
362
|
+
export function setMarkerEngineExclusions(exclusions) {
|
|
363
|
+
if (!exclusions || exclusions.length === 0) {
|
|
364
|
+
customExcludeSelector = null;
|
|
365
|
+
return;
|
|
366
|
+
}
|
|
367
|
+
const selectors = exclusions
|
|
368
|
+
.filter((e) => e && e.type === "css" && typeof e.selector === "string" && e.selector.trim())
|
|
369
|
+
.map((e) => e.selector.trim());
|
|
370
|
+
customExcludeSelector = selectors.length ? selectors.join(",") : null;
|
|
371
|
+
}
|
|
372
|
+
export function setActiveTranslations(translations) {
|
|
373
|
+
if (!translations || translations.length === 0) {
|
|
374
|
+
activeTranslationMap = null;
|
|
375
|
+
return;
|
|
376
|
+
}
|
|
377
|
+
const map = new Map();
|
|
378
|
+
for (const t of translations) {
|
|
379
|
+
const source = (t?.source_text || "").toString().trim();
|
|
380
|
+
const translated = (t?.translated_text ?? "").toString();
|
|
381
|
+
if (!source || !translated)
|
|
382
|
+
continue;
|
|
383
|
+
map.set(source, translated);
|
|
384
|
+
}
|
|
385
|
+
activeTranslationMap = map;
|
|
386
|
+
}
|
|
387
|
+
function applyTranslationMap(bundle, root) {
|
|
388
|
+
if (!root)
|
|
389
|
+
return 0;
|
|
390
|
+
const map = new Map();
|
|
391
|
+
for (const [k, v] of Object.entries(bundle || {})) {
|
|
392
|
+
const source = (k || "").toString().trim();
|
|
393
|
+
const translated = (v ?? "").toString();
|
|
394
|
+
if (!source || !translated)
|
|
395
|
+
continue;
|
|
396
|
+
map.set(source, translated);
|
|
397
|
+
}
|
|
398
|
+
activeTranslationMap = map;
|
|
399
|
+
return applyActiveTranslations(root);
|
|
400
|
+
}
|
|
401
|
+
export function applyActiveTranslations(root = document.body) {
|
|
402
|
+
if (!root || !activeTranslationMap || activeTranslationMap.size === 0)
|
|
403
|
+
return 0;
|
|
404
|
+
const map = activeTranslationMap;
|
|
405
|
+
let applied = 0;
|
|
406
|
+
const walk = document.createTreeWalker(root, NodeFilter.SHOW_TEXT);
|
|
407
|
+
const nodes = [];
|
|
408
|
+
let node = walk.nextNode();
|
|
409
|
+
while (node) {
|
|
410
|
+
if (node.nodeType === Node.TEXT_NODE)
|
|
411
|
+
nodes.push(node);
|
|
412
|
+
node = walk.nextNode();
|
|
413
|
+
}
|
|
414
|
+
for (const textNode of nodes) {
|
|
415
|
+
const parent = textNode.parentElement;
|
|
416
|
+
if (!parent)
|
|
417
|
+
continue;
|
|
418
|
+
const raw = textNode.nodeValue || "";
|
|
419
|
+
const trimmed = raw.trim();
|
|
420
|
+
if (!trimmed)
|
|
421
|
+
continue;
|
|
422
|
+
if (isExcludedElement(parent))
|
|
423
|
+
continue;
|
|
424
|
+
if (findUnsafeContainer(parent))
|
|
425
|
+
continue;
|
|
426
|
+
if (!isTranslatableText(trimmed))
|
|
427
|
+
continue;
|
|
428
|
+
const original = getOrInitTextOriginal(textNode, parent);
|
|
429
|
+
const translation = map.get(original.trimmed);
|
|
430
|
+
if (!translation)
|
|
431
|
+
continue;
|
|
432
|
+
const next = `${original.leading}${translation}${original.trailing}`;
|
|
433
|
+
if (textNode.nodeValue === next)
|
|
434
|
+
continue;
|
|
435
|
+
try {
|
|
436
|
+
textNode.nodeValue = next;
|
|
437
|
+
applied += 1;
|
|
438
|
+
}
|
|
439
|
+
catch {
|
|
440
|
+
// ignore
|
|
441
|
+
}
|
|
442
|
+
}
|
|
443
|
+
if (root instanceof HTMLElement) {
|
|
444
|
+
const elements = root.querySelectorAll("[title],[aria-label],[placeholder]");
|
|
445
|
+
elements.forEach((el) => {
|
|
446
|
+
if (isExcludedElement(el))
|
|
447
|
+
return;
|
|
448
|
+
if (findUnsafeContainer(el))
|
|
449
|
+
return;
|
|
450
|
+
for (const { attr } of ATTRIBUTE_MARKS) {
|
|
451
|
+
const current = el.getAttribute(attr);
|
|
452
|
+
if (!current)
|
|
453
|
+
continue;
|
|
454
|
+
const trimmed = current.trim();
|
|
455
|
+
if (!trimmed || !isTranslatableText(trimmed))
|
|
456
|
+
continue;
|
|
457
|
+
const original = normalizeWhitespace(getOrInitAttrOriginal(el, attr));
|
|
458
|
+
const translation = map.get(original);
|
|
459
|
+
if (!translation)
|
|
460
|
+
continue;
|
|
461
|
+
if (el.getAttribute(attr) === translation)
|
|
462
|
+
continue;
|
|
463
|
+
try {
|
|
464
|
+
el.setAttribute(attr, translation);
|
|
465
|
+
applied += 1;
|
|
466
|
+
}
|
|
467
|
+
catch {
|
|
468
|
+
// ignore
|
|
469
|
+
}
|
|
470
|
+
}
|
|
471
|
+
});
|
|
472
|
+
}
|
|
473
|
+
return applied;
|
|
474
|
+
}
|
|
475
|
+
export function restoreDom(root = document.body) {
|
|
476
|
+
if (!root)
|
|
477
|
+
return;
|
|
478
|
+
const walk = document.createTreeWalker(root, NodeFilter.SHOW_TEXT);
|
|
479
|
+
let node = walk.nextNode();
|
|
480
|
+
while (node) {
|
|
481
|
+
if (node.nodeType === Node.TEXT_NODE) {
|
|
482
|
+
const textNode = node;
|
|
483
|
+
const original = originalTextByNode.get(textNode);
|
|
484
|
+
if (original && textNode.nodeValue !== original.raw) {
|
|
485
|
+
try {
|
|
486
|
+
textNode.nodeValue = original.raw;
|
|
487
|
+
}
|
|
488
|
+
catch {
|
|
489
|
+
// ignore
|
|
490
|
+
}
|
|
491
|
+
}
|
|
492
|
+
}
|
|
493
|
+
node = walk.nextNode();
|
|
494
|
+
}
|
|
495
|
+
if (root instanceof HTMLElement) {
|
|
496
|
+
const elements = root.querySelectorAll("[title],[aria-label],[placeholder]");
|
|
497
|
+
elements.forEach((el) => {
|
|
498
|
+
const originals = originalAttrByEl.get(el);
|
|
499
|
+
if (!originals)
|
|
500
|
+
return;
|
|
501
|
+
for (const { attr } of ATTRIBUTE_MARKS) {
|
|
502
|
+
const original = originals.get(attr);
|
|
503
|
+
if (original == null)
|
|
504
|
+
continue;
|
|
505
|
+
if (el.getAttribute(attr) === original)
|
|
506
|
+
continue;
|
|
507
|
+
try {
|
|
508
|
+
el.setAttribute(attr, original);
|
|
509
|
+
}
|
|
510
|
+
catch {
|
|
511
|
+
// ignore
|
|
512
|
+
}
|
|
513
|
+
}
|
|
514
|
+
});
|
|
515
|
+
}
|
|
516
|
+
}
|
package/dist/version.d.ts
CHANGED
|
@@ -1 +1 @@
|
|
|
1
|
-
export declare const VERSION = "0.
|
|
1
|
+
export declare const VERSION = "0.2.0";
|
package/dist/version.js
CHANGED
|
@@ -1 +1 @@
|
|
|
1
|
-
export const VERSION = "0.
|
|
1
|
+
export const VERSION = "0.2.0";
|
package/package.json
CHANGED