@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
package/dist/utils/translator.js
DELETED
|
@@ -1,802 +0,0 @@
|
|
|
1
|
-
import { logDebug, warnDebug, errorDebug } from './logger';
|
|
2
|
-
export class Translator {
|
|
3
|
-
constructor() {
|
|
4
|
-
this.translationMap = new Map();
|
|
5
|
-
this.exclusions = [];
|
|
6
|
-
// Brand names that should NEVER be translated
|
|
7
|
-
this.nonTranslatableTerms = new Set([
|
|
8
|
-
'Lovalingo',
|
|
9
|
-
'Lovable', 'v0', 'Claude Code', 'Bolt', 'Base44',
|
|
10
|
-
'Replit', 'GitHub', 'Supabase', 'OpenAI', 'Anthropic'
|
|
11
|
-
]);
|
|
12
|
-
}
|
|
13
|
-
/**
|
|
14
|
-
* Check if element is interactive (should be preserved as island)
|
|
15
|
-
*/
|
|
16
|
-
isInteractive(el) {
|
|
17
|
-
const tag = el.tagName.toLowerCase();
|
|
18
|
-
if (['button', 'a', 'label', 'input', 'textarea', 'select'].includes(tag))
|
|
19
|
-
return true;
|
|
20
|
-
if (el.hasAttribute('contenteditable'))
|
|
21
|
-
return true;
|
|
22
|
-
const role = el.getAttribute('role');
|
|
23
|
-
if (role === 'button' || role === 'link' || role === 'tab')
|
|
24
|
-
return true;
|
|
25
|
-
if (el.tabIndex >= 0)
|
|
26
|
-
return true;
|
|
27
|
-
if (el.hasAttribute('onclick'))
|
|
28
|
-
return true;
|
|
29
|
-
return false;
|
|
30
|
-
}
|
|
31
|
-
/**
|
|
32
|
-
* Check if text contains actual translatable content
|
|
33
|
-
*/
|
|
34
|
-
isTranslatableText(text) {
|
|
35
|
-
if (!text || text.trim().length < 2)
|
|
36
|
-
return false;
|
|
37
|
-
// Check if it's only placeholder tokens
|
|
38
|
-
if (/^(__[A-Z0-9_]+__\s*)+$/.test(text))
|
|
39
|
-
return false;
|
|
40
|
-
// Check if it's numeric only
|
|
41
|
-
if (/^\d+(\.\d+)?$/.test(text))
|
|
42
|
-
return false;
|
|
43
|
-
// Check if contains at least one letter
|
|
44
|
-
if (!/[a-zA-Z\u00C0-\u024F\u1E00-\u1EFF]/.test(text))
|
|
45
|
-
return false;
|
|
46
|
-
return true;
|
|
47
|
-
}
|
|
48
|
-
/**
|
|
49
|
-
* Check if element should be treated as semantic boundary
|
|
50
|
-
* even though it's not in the predefined SEMANTIC_BOUNDARIES set
|
|
51
|
-
*
|
|
52
|
-
* Phase 1: Conservative approach - only leaf elements (no element children)
|
|
53
|
-
* This catches cases like: <span>text</span>, <div>text</div>, etc.
|
|
54
|
-
*/
|
|
55
|
-
shouldTreatAsSemantic(element) {
|
|
56
|
-
// Skip interactive elements, they have dedicated handling
|
|
57
|
-
if (this.isInteractive(element))
|
|
58
|
-
return false;
|
|
59
|
-
// New rule: treat elements that have direct translatable text nodes
|
|
60
|
-
const hasDirectTranslatableText = Array.from(element.childNodes).some((n) => n.nodeType === Node.TEXT_NODE && this.isTranslatableText((n.textContent || '').trim()));
|
|
61
|
-
if (hasDirectTranslatableText) {
|
|
62
|
-
return true;
|
|
63
|
-
}
|
|
64
|
-
// Fallback: legacy conservative rule for true leaf nodes
|
|
65
|
-
if (element.children.length > 0) {
|
|
66
|
-
return false;
|
|
67
|
-
}
|
|
68
|
-
const textContent = (element.textContent || '').trim();
|
|
69
|
-
if (!textContent)
|
|
70
|
-
return false;
|
|
71
|
-
return this.isTranslatableText(textContent);
|
|
72
|
-
}
|
|
73
|
-
isLargeInteractiveContainer(element) {
|
|
74
|
-
const textLen = (element.textContent || '').trim().length;
|
|
75
|
-
if (textLen > 200)
|
|
76
|
-
return true;
|
|
77
|
-
const descendantCount = element.querySelectorAll('*').length;
|
|
78
|
-
if (descendantCount > 12)
|
|
79
|
-
return true;
|
|
80
|
-
const hasBlockDescendant = Boolean(element.querySelector('div,p,h1,h2,h3,h4,h5,h6,section,article,header,footer,main,nav,aside,ul,ol,li,table,tr,td,th,form,fieldset'));
|
|
81
|
-
if (hasBlockDescendant)
|
|
82
|
-
return true;
|
|
83
|
-
const hasNestedInteractive = Boolean(element.querySelector('button,a,input,textarea,select,[role="button"],[role="link"],[contenteditable]'));
|
|
84
|
-
if (hasNestedInteractive)
|
|
85
|
-
return true;
|
|
86
|
-
return false;
|
|
87
|
-
}
|
|
88
|
-
sanitizePlaceholderMap(placeholderMap) {
|
|
89
|
-
const out = {};
|
|
90
|
-
if (!placeholderMap || typeof placeholderMap !== 'object')
|
|
91
|
-
return out;
|
|
92
|
-
const stripPreserveIds = (html) => (html || '').replace(/\sdata-lovalingo-preserve-id=("[^"]*"|'[^']*')/gi, '');
|
|
93
|
-
for (const [token, data] of Object.entries(placeholderMap)) {
|
|
94
|
-
if (!data)
|
|
95
|
-
continue;
|
|
96
|
-
const next = {
|
|
97
|
-
...data,
|
|
98
|
-
originalHTML: stripPreserveIds(data.originalHTML || ''),
|
|
99
|
-
attributes: data.attributes ? { ...data.attributes } : {},
|
|
100
|
-
};
|
|
101
|
-
for (const key of Object.keys(next.attributes || {})) {
|
|
102
|
-
if (key.toLowerCase() === 'data-lovalingo-preserve-id') {
|
|
103
|
-
delete next.attributes[key];
|
|
104
|
-
}
|
|
105
|
-
}
|
|
106
|
-
out[token] = next;
|
|
107
|
-
}
|
|
108
|
-
return out;
|
|
109
|
-
}
|
|
110
|
-
cleanupPreserveIds(element, markedElements) {
|
|
111
|
-
markedElements?.forEach((el) => el.removeAttribute('data-Lovalingo-preserve-id'));
|
|
112
|
-
element.querySelectorAll('[data-Lovalingo-preserve-id]').forEach((el) => el.removeAttribute('data-Lovalingo-preserve-id'));
|
|
113
|
-
}
|
|
114
|
-
setTranslations(translations) {
|
|
115
|
-
this.translationMap.clear();
|
|
116
|
-
if (!Array.isArray(translations)) {
|
|
117
|
-
errorDebug('[Lovalingo] setTranslations expected array, got:', typeof translations);
|
|
118
|
-
return;
|
|
119
|
-
}
|
|
120
|
-
translations.forEach(t => {
|
|
121
|
-
if (t?.source_text && t?.translated_text) {
|
|
122
|
-
// Key is the TOKENIZED text, not the original
|
|
123
|
-
this.translationMap.set(t.source_text.trim(), t.translated_text);
|
|
124
|
-
}
|
|
125
|
-
});
|
|
126
|
-
logDebug(`[Lovalingo] ✅ Loaded ${this.translationMap.size} translations`);
|
|
127
|
-
}
|
|
128
|
-
setExclusions(exclusions) {
|
|
129
|
-
this.exclusions = exclusions || [];
|
|
130
|
-
}
|
|
131
|
-
isExcluded(element) {
|
|
132
|
-
// Check CSS selectors
|
|
133
|
-
for (const exclusion of this.exclusions) {
|
|
134
|
-
if (exclusion.type === 'css') {
|
|
135
|
-
try {
|
|
136
|
-
if (element.matches(exclusion.selector))
|
|
137
|
-
return true;
|
|
138
|
-
}
|
|
139
|
-
catch (e) {
|
|
140
|
-
// Invalid selector, skip
|
|
141
|
-
}
|
|
142
|
-
}
|
|
143
|
-
else if (exclusion.type === 'xpath') {
|
|
144
|
-
try {
|
|
145
|
-
const result = document.evaluate(exclusion.selector, document, null, XPathResult.FIRST_ORDERED_NODE_TYPE, null);
|
|
146
|
-
if (result.singleNodeValue === element)
|
|
147
|
-
return true;
|
|
148
|
-
}
|
|
149
|
-
catch (e) {
|
|
150
|
-
// Invalid xpath, skip
|
|
151
|
-
}
|
|
152
|
-
}
|
|
153
|
-
}
|
|
154
|
-
// Check data attributes
|
|
155
|
-
if (element.hasAttribute('data-notranslate') ||
|
|
156
|
-
element.hasAttribute('translate-no') ||
|
|
157
|
-
element.hasAttribute('data-no-translate')) {
|
|
158
|
-
return true;
|
|
159
|
-
}
|
|
160
|
-
// Check if inside excluded parent
|
|
161
|
-
let parent = element.parentElement;
|
|
162
|
-
while (parent) {
|
|
163
|
-
if (parent.hasAttribute('data-notranslate') ||
|
|
164
|
-
parent.hasAttribute('translate-no') ||
|
|
165
|
-
parent.hasAttribute('data-no-translate')) {
|
|
166
|
-
return true;
|
|
167
|
-
}
|
|
168
|
-
parent = parent.parentElement;
|
|
169
|
-
}
|
|
170
|
-
return false;
|
|
171
|
-
}
|
|
172
|
-
/**
|
|
173
|
-
* DOM-BASED EXTRACTION
|
|
174
|
-
* Uses DOM APIs to reliably parse and tokenize HTML
|
|
175
|
-
*/
|
|
176
|
-
extractTranslatableContent(semanticElement) {
|
|
177
|
-
// Prefer a stable snapshot without temporary preserve markers.
|
|
178
|
-
const rawHTML = semanticElement.getAttribute('data-Lovalingo-original-html') || semanticElement.innerHTML;
|
|
179
|
-
const semanticContext = semanticElement.tagName.toLowerCase();
|
|
180
|
-
// Clone element to avoid modifying original DOM
|
|
181
|
-
const clone = semanticElement.cloneNode(true);
|
|
182
|
-
let placeholderCounter = 0;
|
|
183
|
-
const placeholderMap = {};
|
|
184
|
-
// Inline elements that should be tokenized
|
|
185
|
-
const INLINE_TAGS = new Set([
|
|
186
|
-
'span', 'strong', 'em', 'b', 'i', 'u', 'mark', 'small',
|
|
187
|
-
'sub', 'sup', 'code', 'kbd', 'samp', 'var', 'abbr',
|
|
188
|
-
'a', 'time', 'data'
|
|
189
|
-
]);
|
|
190
|
-
// Elements to completely remove (keep as tokens but don't translate)
|
|
191
|
-
const SKIP_TAGS = new Set([
|
|
192
|
-
'script', 'style', 'noscript', 'svg', 'canvas',
|
|
193
|
-
'img', 'video', 'audio', 'iframe', 'object', 'embed',
|
|
194
|
-
'code', 'pre'
|
|
195
|
-
]);
|
|
196
|
-
const WRAP_INTERACTIVE_TAGS = new Set(['button', 'label', 'summary']);
|
|
197
|
-
const WRAP_INTERACTIVE_ROLES = new Set(['button', 'link', 'tab']);
|
|
198
|
-
const getOuterHTML = (el) => {
|
|
199
|
-
try {
|
|
200
|
-
return el.outerHTML || '';
|
|
201
|
-
}
|
|
202
|
-
catch {
|
|
203
|
-
return '';
|
|
204
|
-
}
|
|
205
|
-
};
|
|
206
|
-
const getOpenTag = (outerHTML) => {
|
|
207
|
-
const idx = outerHTML.indexOf('>');
|
|
208
|
-
if (idx === -1)
|
|
209
|
-
return '';
|
|
210
|
-
return outerHTML.slice(0, idx + 1);
|
|
211
|
-
};
|
|
212
|
-
/**
|
|
213
|
-
* Recursively process DOM nodes
|
|
214
|
-
* Returns true if node was replaced with token
|
|
215
|
-
*/
|
|
216
|
-
const processNode = (node) => {
|
|
217
|
-
if (node.nodeType === Node.ELEMENT_NODE) {
|
|
218
|
-
const element = node;
|
|
219
|
-
const tagName = element.tagName.toLowerCase();
|
|
220
|
-
// Remove skip tags entirely
|
|
221
|
-
if (SKIP_TAGS.has(tagName)) {
|
|
222
|
-
const token = `__PRESERVE_${placeholderCounter}__`;
|
|
223
|
-
placeholderMap[token] = {
|
|
224
|
-
type: 'non-translatable',
|
|
225
|
-
originalHTML: getOuterHTML(element),
|
|
226
|
-
textContent: '',
|
|
227
|
-
tag: tagName,
|
|
228
|
-
attributes: {}
|
|
229
|
-
};
|
|
230
|
-
placeholderCounter++;
|
|
231
|
-
const textNode = document.createTextNode(token); // Use token, not space
|
|
232
|
-
element.replaceWith(textNode);
|
|
233
|
-
return true;
|
|
234
|
-
}
|
|
235
|
-
// Process children first (depth-first)
|
|
236
|
-
const children = Array.from(element.childNodes);
|
|
237
|
-
children.forEach(child => processNode(child));
|
|
238
|
-
const textContent = element.textContent || '';
|
|
239
|
-
const trimmedContent = textContent.trim();
|
|
240
|
-
// Skip wrapping/preserving when there's nothing meaningful inside.
|
|
241
|
-
if (!trimmedContent || trimmedContent.length < 2) {
|
|
242
|
-
return false;
|
|
243
|
-
}
|
|
244
|
-
// Skip icon elements (or icon-like wrappers).
|
|
245
|
-
const isIcon = tagName === 'svg' ||
|
|
246
|
-
tagName === 'i' ||
|
|
247
|
-
element.hasAttribute('aria-hidden') ||
|
|
248
|
-
element.classList.contains('icon') ||
|
|
249
|
-
element.classList.contains('lucide');
|
|
250
|
-
if (isIcon) {
|
|
251
|
-
return false;
|
|
252
|
-
}
|
|
253
|
-
// Preserve explicit non-translatable inline nodes as a single token, so surrounding text can still translate.
|
|
254
|
-
const isNonTranslatable = this.nonTranslatableTerms.has(trimmedContent) ||
|
|
255
|
-
element.hasAttribute('data-no-translate') ||
|
|
256
|
-
element.hasAttribute('translate-no') ||
|
|
257
|
-
element.hasAttribute('data-no-translate');
|
|
258
|
-
if (isNonTranslatable && element !== clone) {
|
|
259
|
-
const token = `__PRESERVE_${placeholderCounter}__`;
|
|
260
|
-
placeholderMap[token] = {
|
|
261
|
-
type: 'non-translatable',
|
|
262
|
-
originalHTML: getOuterHTML(element),
|
|
263
|
-
textContent,
|
|
264
|
-
tag: tagName,
|
|
265
|
-
attributes: {},
|
|
266
|
-
};
|
|
267
|
-
placeholderCounter++;
|
|
268
|
-
const textNode = document.createTextNode(token);
|
|
269
|
-
element.replaceWith(textNode);
|
|
270
|
-
return true;
|
|
271
|
-
}
|
|
272
|
-
const role = (element.getAttribute('role') || '').toLowerCase();
|
|
273
|
-
const shouldWrapInteractive = element !== clone &&
|
|
274
|
-
this.isInteractive(element) &&
|
|
275
|
-
!['input', 'textarea', 'select'].includes(tagName) &&
|
|
276
|
-
(WRAP_INTERACTIVE_TAGS.has(tagName) || WRAP_INTERACTIVE_ROLES.has(role));
|
|
277
|
-
const shouldWrapInline = element !== clone && INLINE_TAGS.has(tagName);
|
|
278
|
-
if (shouldWrapInline || shouldWrapInteractive) {
|
|
279
|
-
// Wrap with OPEN/CLOSE tokens so the model can translate inner text and still preserve markup.
|
|
280
|
-
const openToken = `__OPEN_${placeholderCounter}__`;
|
|
281
|
-
const closeToken = `__CLOSE_${placeholderCounter}__`;
|
|
282
|
-
placeholderCounter++;
|
|
283
|
-
const outer = getOuterHTML(element);
|
|
284
|
-
const openTag = getOpenTag(outer);
|
|
285
|
-
const closeTag = `</${tagName}>`;
|
|
286
|
-
const attributes = {};
|
|
287
|
-
Array.from(element.attributes).forEach(attr => {
|
|
288
|
-
attributes[attr.name] = attr.value;
|
|
289
|
-
});
|
|
290
|
-
placeholderMap[openToken] = {
|
|
291
|
-
type: 'inline-formatting',
|
|
292
|
-
originalHTML: openTag,
|
|
293
|
-
textContent,
|
|
294
|
-
tag: tagName,
|
|
295
|
-
attributes,
|
|
296
|
-
};
|
|
297
|
-
placeholderMap[closeToken] = {
|
|
298
|
-
type: 'inline-formatting',
|
|
299
|
-
originalHTML: closeTag,
|
|
300
|
-
textContent: '',
|
|
301
|
-
tag: tagName,
|
|
302
|
-
attributes: {},
|
|
303
|
-
};
|
|
304
|
-
const fragment = document.createDocumentFragment();
|
|
305
|
-
fragment.appendChild(document.createTextNode(openToken));
|
|
306
|
-
while (element.firstChild) {
|
|
307
|
-
fragment.appendChild(element.firstChild);
|
|
308
|
-
}
|
|
309
|
-
fragment.appendChild(document.createTextNode(closeToken));
|
|
310
|
-
element.replaceWith(fragment);
|
|
311
|
-
return true;
|
|
312
|
-
}
|
|
313
|
-
}
|
|
314
|
-
return false;
|
|
315
|
-
};
|
|
316
|
-
// Process all nodes in the cloned tree
|
|
317
|
-
const walker = document.createTreeWalker(clone, NodeFilter.SHOW_ELEMENT, null);
|
|
318
|
-
const nodesToProcess = [];
|
|
319
|
-
let node = walker.nextNode();
|
|
320
|
-
while (node) {
|
|
321
|
-
nodesToProcess.push(node);
|
|
322
|
-
node = walker.nextNode();
|
|
323
|
-
}
|
|
324
|
-
// Process from deepest to shallowest to handle nesting
|
|
325
|
-
nodesToProcess.reverse().forEach(processNode);
|
|
326
|
-
// Extract final text
|
|
327
|
-
let processedText = clone.textContent || '';
|
|
328
|
-
// Normalize whitespace
|
|
329
|
-
processedText = processedText
|
|
330
|
-
.replace(/\s+/g, ' ') // Multiple spaces → single space
|
|
331
|
-
.trim();
|
|
332
|
-
// LAYER 2: Validate content using isTranslatableText
|
|
333
|
-
if (!this.isTranslatableText(processedText)) {
|
|
334
|
-
// This is expected for icons/flags/short labels; only warn when it looks like we actually had meaningful text.
|
|
335
|
-
const hadLettersInRaw = /[a-zA-Z\u00C0-\u024F\u1E00-\u1EFF]/.test(rawHTML);
|
|
336
|
-
const hadPlaceholders = Object.keys(placeholderMap).length > 0;
|
|
337
|
-
if (hadLettersInRaw || hadPlaceholders) {
|
|
338
|
-
warnDebug('[Lovalingo] ⚠️ Non-translatable content after extraction:', semanticElement.outerHTML.substring(0, 140));
|
|
339
|
-
}
|
|
340
|
-
return {
|
|
341
|
-
rawHTML,
|
|
342
|
-
processedText: '',
|
|
343
|
-
placeholderMap: {},
|
|
344
|
-
semanticContext
|
|
345
|
-
};
|
|
346
|
-
}
|
|
347
|
-
logDebug(`[Lovalingo] 📝 Extracted: "${processedText.substring(0, 80)}..." with ${Object.keys(placeholderMap).length} placeholders`);
|
|
348
|
-
return {
|
|
349
|
-
rawHTML,
|
|
350
|
-
processedText,
|
|
351
|
-
placeholderMap,
|
|
352
|
-
semanticContext
|
|
353
|
-
};
|
|
354
|
-
}
|
|
355
|
-
/**
|
|
356
|
-
* RECONSTRUCTION: Replace tokens with original HTML
|
|
357
|
-
*/
|
|
358
|
-
reconstructHTML(translatedText, placeholderMap) {
|
|
359
|
-
let result = translatedText;
|
|
360
|
-
// Replace each token with its original HTML
|
|
361
|
-
// Sort by token number to ensure correct order
|
|
362
|
-
const tokens = Object.keys(placeholderMap).sort((a, b) => {
|
|
363
|
-
const numA = parseInt(a.match(/\d+/)?.[0] || '0');
|
|
364
|
-
const numB = parseInt(b.match(/\d+/)?.[0] || '0');
|
|
365
|
-
if (numA !== numB)
|
|
366
|
-
return numA - numB;
|
|
367
|
-
return a.localeCompare(b);
|
|
368
|
-
});
|
|
369
|
-
for (const token of tokens) {
|
|
370
|
-
const data = placeholderMap[token];
|
|
371
|
-
if (data?.originalHTML) {
|
|
372
|
-
// Escape special regex characters in token
|
|
373
|
-
const escapedToken = token.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
|
|
374
|
-
const regex = new RegExp(escapedToken, 'g');
|
|
375
|
-
result = result.replace(regex, data.originalHTML);
|
|
376
|
-
}
|
|
377
|
-
}
|
|
378
|
-
return result;
|
|
379
|
-
}
|
|
380
|
-
/**
|
|
381
|
-
* Restore element to original HTML while preserving event listeners
|
|
382
|
-
*/
|
|
383
|
-
restoreElement(element) {
|
|
384
|
-
if (this.isExcluded(element))
|
|
385
|
-
return;
|
|
386
|
-
// Skip non-translatable elements
|
|
387
|
-
const SKIP_TAGS = new Set(['SCRIPT', 'STYLE', 'NOSCRIPT', 'IFRAME', 'CODE', 'PRE']);
|
|
388
|
-
if (SKIP_TAGS.has(element.tagName))
|
|
389
|
-
return;
|
|
390
|
-
const SEMANTIC_BOUNDARIES = new Set([
|
|
391
|
-
'P', 'H1', 'H2', 'H3', 'H4', 'H5', 'H6',
|
|
392
|
-
'LI', 'TD', 'TH', 'BUTTON', 'LABEL', 'A',
|
|
393
|
-
'BLOCKQUOTE', 'FIGCAPTION', 'SUMMARY', 'CAPTION'
|
|
394
|
-
]);
|
|
395
|
-
if (SEMANTIC_BOUNDARIES.has(element.tagName)) {
|
|
396
|
-
const originalHTML = element.getAttribute('data-Lovalingo-original-html');
|
|
397
|
-
if (originalHTML) {
|
|
398
|
-
logDebug(`[Lovalingo] 🔄 Restoring: "${element.textContent?.substring(0, 50)}..."`);
|
|
399
|
-
// Mark current live elements before restoring
|
|
400
|
-
const markedElements = this.markElements(element);
|
|
401
|
-
// Use safe update that preserves live elements
|
|
402
|
-
this.updateElementChildren(element, originalHTML, markedElements);
|
|
403
|
-
// Clean up preserve IDs
|
|
404
|
-
element.querySelectorAll('[data-Lovalingo-preserve-id]').forEach(el => {
|
|
405
|
-
el.removeAttribute('data-Lovalingo-preserve-id');
|
|
406
|
-
});
|
|
407
|
-
// Keep the original HTML attribute for future switches
|
|
408
|
-
}
|
|
409
|
-
return;
|
|
410
|
-
}
|
|
411
|
-
// For non-semantic boundaries, recurse to children
|
|
412
|
-
Array.from(element.children).forEach(child => {
|
|
413
|
-
if (child instanceof HTMLElement) {
|
|
414
|
-
this.restoreElement(child);
|
|
415
|
-
}
|
|
416
|
-
});
|
|
417
|
-
// Restore attributes
|
|
418
|
-
this.restoreAttribute(element, 'placeholder');
|
|
419
|
-
this.restoreAttribute(element, 'title');
|
|
420
|
-
this.restoreAttribute(element, 'alt');
|
|
421
|
-
}
|
|
422
|
-
/**
|
|
423
|
-
* Restore all elements to original state
|
|
424
|
-
*/
|
|
425
|
-
restoreDOM() {
|
|
426
|
-
logDebug('[Lovalingo] 🔄 Restoring DOM to original state...');
|
|
427
|
-
const startTime = performance.now();
|
|
428
|
-
this.restoreElement(document.body);
|
|
429
|
-
const elapsed = performance.now() - startTime;
|
|
430
|
-
logDebug(`[Lovalingo] 🏁 DOM restoration complete in ${elapsed.toFixed(2)}ms`);
|
|
431
|
-
}
|
|
432
|
-
/**
|
|
433
|
-
* Mark all descendant elements with unique IDs before translation
|
|
434
|
-
* This allows us to reuse original DOM nodes (preserving event listeners)
|
|
435
|
-
*/
|
|
436
|
-
markElements(element) {
|
|
437
|
-
const markedElements = new Map();
|
|
438
|
-
const descendants = element.querySelectorAll('*');
|
|
439
|
-
descendants.forEach((child, index) => {
|
|
440
|
-
if (child instanceof HTMLElement) {
|
|
441
|
-
const id = `Lovalingo-preserve-${Date.now()}-${index}`;
|
|
442
|
-
child.setAttribute('data-Lovalingo-preserve-id', id);
|
|
443
|
-
markedElements.set(id, child);
|
|
444
|
-
}
|
|
445
|
-
});
|
|
446
|
-
return markedElements;
|
|
447
|
-
}
|
|
448
|
-
/**
|
|
449
|
-
* After reconstruction, transplant original LIVE elements into new structure
|
|
450
|
-
* This preserves event listeners and framework connections
|
|
451
|
-
*/
|
|
452
|
-
transplantOriginalElements(tempContainer, markedElements) {
|
|
453
|
-
if (markedElements.size === 0) {
|
|
454
|
-
return;
|
|
455
|
-
}
|
|
456
|
-
// Find all elements with preserve IDs in the temp structure
|
|
457
|
-
const preserveElements = tempContainer.querySelectorAll('[data-Lovalingo-preserve-id]');
|
|
458
|
-
preserveElements.forEach((tempElement) => {
|
|
459
|
-
if (!(tempElement instanceof HTMLElement))
|
|
460
|
-
return;
|
|
461
|
-
const id = tempElement.getAttribute('data-Lovalingo-preserve-id');
|
|
462
|
-
if (id && markedElements.has(id)) {
|
|
463
|
-
// Get the LIVE original element from the DOM
|
|
464
|
-
const originalElement = markedElements.get(id);
|
|
465
|
-
// Update text content if different
|
|
466
|
-
const tempText = tempElement.textContent;
|
|
467
|
-
const originalText = originalElement.textContent;
|
|
468
|
-
if (tempText !== originalText) {
|
|
469
|
-
// Update only text nodes within the original element
|
|
470
|
-
this.updateTextNodesOnly(originalElement, tempElement);
|
|
471
|
-
}
|
|
472
|
-
// Replace temp element with the LIVE original (not a clone!)
|
|
473
|
-
// This moves the original element into the temp structure
|
|
474
|
-
tempElement.replaceWith(originalElement);
|
|
475
|
-
}
|
|
476
|
-
});
|
|
477
|
-
}
|
|
478
|
-
/**
|
|
479
|
-
* Directly update the parent element's children by transplanting from temp
|
|
480
|
-
* This avoids using innerHTML which would destroy event listeners
|
|
481
|
-
*/
|
|
482
|
-
updateElementChildren(parent, newHTML, markedElements) {
|
|
483
|
-
// Safety check: Ensure parent is still in the DOM
|
|
484
|
-
if (!parent.isConnected || !document.body.contains(parent)) {
|
|
485
|
-
warnDebug('[Lovalingo] Parent element not in DOM, skipping translation');
|
|
486
|
-
return;
|
|
487
|
-
}
|
|
488
|
-
try {
|
|
489
|
-
// Parse new structure
|
|
490
|
-
const temp = document.createElement('div');
|
|
491
|
-
temp.innerHTML = newHTML;
|
|
492
|
-
// Transplant original live elements into temp structure
|
|
493
|
-
this.transplantOriginalElements(temp, markedElements);
|
|
494
|
-
// Now replace parent's children with temp's children
|
|
495
|
-
// Use try-catch for each removeChild to handle React conflicts gracefully
|
|
496
|
-
while (parent.firstChild) {
|
|
497
|
-
try {
|
|
498
|
-
parent.removeChild(parent.firstChild);
|
|
499
|
-
}
|
|
500
|
-
catch (e) {
|
|
501
|
-
// Node might have been removed by React already, skip
|
|
502
|
-
warnDebug('[Lovalingo] Could not remove child, likely already removed by framework');
|
|
503
|
-
break;
|
|
504
|
-
}
|
|
505
|
-
}
|
|
506
|
-
// Move all nodes from temp to parent
|
|
507
|
-
while (temp.firstChild) {
|
|
508
|
-
try {
|
|
509
|
-
parent.appendChild(temp.firstChild);
|
|
510
|
-
}
|
|
511
|
-
catch (e) {
|
|
512
|
-
warnDebug('[Lovalingo] Could not append child:', e);
|
|
513
|
-
break;
|
|
514
|
-
}
|
|
515
|
-
}
|
|
516
|
-
}
|
|
517
|
-
catch (error) {
|
|
518
|
-
errorDebug('[Lovalingo] Error updating element children:', error);
|
|
519
|
-
}
|
|
520
|
-
}
|
|
521
|
-
/**
|
|
522
|
-
* Update only text nodes within an element, preserving child elements
|
|
523
|
-
* Enhanced to handle nested structures recursively
|
|
524
|
-
*/
|
|
525
|
-
updateTextNodesOnly(original, translated) {
|
|
526
|
-
const updateTextRecursively = (origNode, transNode) => {
|
|
527
|
-
if (origNode.nodeType === Node.TEXT_NODE && transNode.nodeType === Node.TEXT_NODE) {
|
|
528
|
-
// Update text content in place
|
|
529
|
-
origNode.textContent = transNode.textContent;
|
|
530
|
-
return;
|
|
531
|
-
}
|
|
532
|
-
if (origNode.nodeType === Node.ELEMENT_NODE && transNode.nodeType === Node.ELEMENT_NODE) {
|
|
533
|
-
const origChildren = Array.from(origNode.childNodes);
|
|
534
|
-
const transChildren = Array.from(transNode.childNodes);
|
|
535
|
-
// Match children by node type and tag name
|
|
536
|
-
origChildren.forEach((origChild, i) => {
|
|
537
|
-
if (transChildren[i]) {
|
|
538
|
-
updateTextRecursively(origChild, transChildren[i]);
|
|
539
|
-
}
|
|
540
|
-
});
|
|
541
|
-
}
|
|
542
|
-
};
|
|
543
|
-
updateTextRecursively(original, translated);
|
|
544
|
-
}
|
|
545
|
-
translateElement(element) {
|
|
546
|
-
if (this.isExcluded(element))
|
|
547
|
-
return;
|
|
548
|
-
// Skip non-translatable elements
|
|
549
|
-
const SKIP_TAGS = new Set(['SCRIPT', 'STYLE', 'NOSCRIPT', 'IFRAME', 'CODE', 'PRE']);
|
|
550
|
-
if (SKIP_TAGS.has(element.tagName))
|
|
551
|
-
return;
|
|
552
|
-
if (this.isInteractive(element)) {
|
|
553
|
-
const tag = element.tagName.toLowerCase();
|
|
554
|
-
const role = (element.getAttribute('role') || '').toLowerCase();
|
|
555
|
-
const isKnownLeafControl = ['button', 'label', 'summary', 'input', 'textarea', 'select'].includes(tag);
|
|
556
|
-
const isRoleLeaf = role === 'button' || role === 'tab';
|
|
557
|
-
const isLink = tag === 'a' || role === 'link';
|
|
558
|
-
// For leaf controls and simple interactive labels: translate safely in-place and stop.
|
|
559
|
-
// For interactive containers (cards, composite widgets): translate attributes and recurse to children.
|
|
560
|
-
const shouldRecurse = (!isKnownLeafControl && !isRoleLeaf && !isLink) ||
|
|
561
|
-
((isLink || isRoleLeaf) && this.isLargeInteractiveContainer(element));
|
|
562
|
-
this.translateInteractive(element);
|
|
563
|
-
if (!shouldRecurse)
|
|
564
|
-
return;
|
|
565
|
-
}
|
|
566
|
-
// Semantic boundaries - translate these as complete units
|
|
567
|
-
const SEMANTIC_BOUNDARIES = new Set([
|
|
568
|
-
'P', 'H1', 'H2', 'H3', 'H4', 'H5', 'H6',
|
|
569
|
-
'LI', 'TD', 'TH',
|
|
570
|
-
'BLOCKQUOTE', 'FIGCAPTION', 'SUMMARY', 'CAPTION'
|
|
571
|
-
]);
|
|
572
|
-
if (SEMANTIC_BOUNDARIES.has(element.tagName)) {
|
|
573
|
-
// Store original HTML on first translation
|
|
574
|
-
const originalHTMLAttr = element.getAttribute('data-Lovalingo-original-html');
|
|
575
|
-
if (!originalHTMLAttr) {
|
|
576
|
-
element.setAttribute('data-Lovalingo-original-html', element.innerHTML);
|
|
577
|
-
}
|
|
578
|
-
// STEP 1: Mark all child elements before extraction
|
|
579
|
-
const markedElements = this.markElements(element);
|
|
580
|
-
try {
|
|
581
|
-
const content = this.extractTranslatableContent(element);
|
|
582
|
-
// If there's no meaningful boundary content, recurse to children.
|
|
583
|
-
if (!content.processedText || content.processedText.length <= 1) {
|
|
584
|
-
Array.from(element.children).forEach((child) => {
|
|
585
|
-
if (child instanceof HTMLElement)
|
|
586
|
-
this.translateElement(child);
|
|
587
|
-
});
|
|
588
|
-
return;
|
|
589
|
-
}
|
|
590
|
-
// Store original processed text for comparison
|
|
591
|
-
const originalTextAttr = element.getAttribute('data-Lovalingo-original');
|
|
592
|
-
if (!originalTextAttr) {
|
|
593
|
-
element.setAttribute('data-Lovalingo-original', content.processedText);
|
|
594
|
-
}
|
|
595
|
-
// Always use stored original text, not current DOM state
|
|
596
|
-
const sourceText = originalTextAttr || content.processedText;
|
|
597
|
-
// If boundary isn't translatable, recurse to children.
|
|
598
|
-
if (!this.isTranslatableText(sourceText)) {
|
|
599
|
-
logDebug('[Lovalingo] ⏭️ Skipping non-translatable content:', sourceText.substring(0, 50));
|
|
600
|
-
Array.from(element.children).forEach((child) => {
|
|
601
|
-
if (child instanceof HTMLElement)
|
|
602
|
-
this.translateElement(child);
|
|
603
|
-
});
|
|
604
|
-
return;
|
|
605
|
-
}
|
|
606
|
-
const translated = this.translationMap.get(sourceText);
|
|
607
|
-
if (translated) {
|
|
608
|
-
logDebug(`[Lovalingo] ✅ Translating: "${sourceText.substring(0, 50)}..." → "${translated.substring(0, 50)}..."`);
|
|
609
|
-
let reconstructedHTML = this.reconstructHTML(translated, content.placeholderMap);
|
|
610
|
-
if (/__[A-Z0-9_]+__/.test(reconstructedHTML)) {
|
|
611
|
-
warnDebug('[Lovalingo] ⚠️ Tokens remain after reconstruction, using original HTML');
|
|
612
|
-
reconstructedHTML = content.rawHTML;
|
|
613
|
-
}
|
|
614
|
-
// Mark for MutationObserver suppression (prevents feedback loops on DOM updates).
|
|
615
|
-
element.setAttribute('data-Lovalingo-translating', '1');
|
|
616
|
-
setTimeout(() => {
|
|
617
|
-
element.removeAttribute('data-Lovalingo-translating');
|
|
618
|
-
}, 50);
|
|
619
|
-
// Full reconstruction + live-node transplant.
|
|
620
|
-
this.updateElementChildren(element, reconstructedHTML, markedElements);
|
|
621
|
-
// Translate interactive descendants safely (preserves event handlers)
|
|
622
|
-
const interactiveSelector = 'button, a, label, input, textarea, select, [role="button"], [role="link"], [contenteditable], [contenteditable="true"], [tabindex]';
|
|
623
|
-
element.querySelectorAll(interactiveSelector).forEach(el => {
|
|
624
|
-
if (el instanceof HTMLElement)
|
|
625
|
-
this.translateInteractive(el);
|
|
626
|
-
});
|
|
627
|
-
}
|
|
628
|
-
else {
|
|
629
|
-
logDebug(`[Lovalingo] ❌ Miss: "${sourceText.substring(0, 80)}..."`);
|
|
630
|
-
}
|
|
631
|
-
}
|
|
632
|
-
finally {
|
|
633
|
-
this.cleanupPreserveIds(element, markedElements);
|
|
634
|
-
}
|
|
635
|
-
return; // Don't process children if we handled the boundary
|
|
636
|
-
}
|
|
637
|
-
// ============================================================
|
|
638
|
-
// CASE 2: Generic semantic boundaries (leaf text containers)
|
|
639
|
-
// ============================================================
|
|
640
|
-
// Check if this element should be treated as semantic boundary
|
|
641
|
-
// even though it's not in the predefined SEMANTIC_BOUNDARIES set
|
|
642
|
-
if (this.shouldTreatAsSemantic(element)) {
|
|
643
|
-
// Reuse EXACT same logic as Case 1 (SEMANTIC_BOUNDARIES)
|
|
644
|
-
// Store original HTML on first translation
|
|
645
|
-
const originalHTMLAttr = element.getAttribute('data-Lovalingo-original-html');
|
|
646
|
-
if (!originalHTMLAttr) {
|
|
647
|
-
element.setAttribute('data-Lovalingo-original-html', element.innerHTML);
|
|
648
|
-
}
|
|
649
|
-
// Mark all child elements before extraction
|
|
650
|
-
const markedElements = this.markElements(element);
|
|
651
|
-
try {
|
|
652
|
-
const content = this.extractTranslatableContent(element);
|
|
653
|
-
// Skip if no valid content
|
|
654
|
-
if (!content.processedText || content.processedText.length <= 1) {
|
|
655
|
-
return;
|
|
656
|
-
}
|
|
657
|
-
// Store original processed text
|
|
658
|
-
const originalTextAttr = element.getAttribute('data-Lovalingo-original');
|
|
659
|
-
if (!originalTextAttr) {
|
|
660
|
-
element.setAttribute('data-Lovalingo-original', content.processedText);
|
|
661
|
-
}
|
|
662
|
-
// Use stored original text
|
|
663
|
-
const sourceText = originalTextAttr || content.processedText;
|
|
664
|
-
if (!this.isTranslatableText(sourceText)) {
|
|
665
|
-
logDebug('[Lovalingo] ⏭️ Skipping non-translatable content (generic):', sourceText.substring(0, 50));
|
|
666
|
-
return;
|
|
667
|
-
}
|
|
668
|
-
const translated = this.translationMap.get(sourceText);
|
|
669
|
-
if (translated) {
|
|
670
|
-
logDebug(`[Lovalingo] ✅ Generic semantic: "${sourceText.substring(0, 50)}..." → "${translated.substring(0, 50)}..."`);
|
|
671
|
-
let reconstructedHTML = this.reconstructHTML(translated, content.placeholderMap);
|
|
672
|
-
if (/__[A-Z0-9_]+__/.test(reconstructedHTML)) {
|
|
673
|
-
warnDebug('[Lovalingo] ⚠️ Tokens remain (generic), using original HTML');
|
|
674
|
-
reconstructedHTML = content.rawHTML;
|
|
675
|
-
}
|
|
676
|
-
this.updateElementChildren(element, reconstructedHTML, markedElements);
|
|
677
|
-
// Translate interactive descendants
|
|
678
|
-
const interactiveSelector = 'button, a, label, input, textarea, select, [role="button"], [role="link"], [contenteditable], [contenteditable="true"], [tabindex]';
|
|
679
|
-
element.querySelectorAll(interactiveSelector).forEach(el => {
|
|
680
|
-
if (el instanceof HTMLElement)
|
|
681
|
-
this.translateInteractive(el);
|
|
682
|
-
});
|
|
683
|
-
}
|
|
684
|
-
else {
|
|
685
|
-
logDebug(`[Lovalingo] ❌ Miss (generic): "${sourceText.substring(0, 80)}..."`);
|
|
686
|
-
}
|
|
687
|
-
}
|
|
688
|
-
finally {
|
|
689
|
-
this.cleanupPreserveIds(element, markedElements);
|
|
690
|
-
}
|
|
691
|
-
return; // Don't recurse to children
|
|
692
|
-
}
|
|
693
|
-
// ============================================================
|
|
694
|
-
// CASE 3: Structural containers - recurse to children
|
|
695
|
-
// ============================================================
|
|
696
|
-
// For non-semantic boundaries, recurse to children
|
|
697
|
-
Array.from(element.children).forEach(child => {
|
|
698
|
-
if (child instanceof HTMLElement) {
|
|
699
|
-
this.translateElement(child);
|
|
700
|
-
}
|
|
701
|
-
});
|
|
702
|
-
// Translate input placeholders
|
|
703
|
-
if (element.tagName === 'INPUT' || element.tagName === 'TEXTAREA') {
|
|
704
|
-
this.translateAttribute(element, 'placeholder', 'placeholder');
|
|
705
|
-
}
|
|
706
|
-
// Translate title and alt attributes
|
|
707
|
-
this.translateAttribute(element, 'title', 'title');
|
|
708
|
-
this.translateAttribute(element, 'alt', 'alt');
|
|
709
|
-
}
|
|
710
|
-
/**
|
|
711
|
-
* Restore attribute to original value
|
|
712
|
-
*/
|
|
713
|
-
restoreAttribute(element, attr) {
|
|
714
|
-
const originalAttrKey = `data-Lovalingo-${attr}-original`;
|
|
715
|
-
const originalValue = element.getAttribute(originalAttrKey);
|
|
716
|
-
if (originalValue) {
|
|
717
|
-
element.setAttribute(attr, originalValue);
|
|
718
|
-
}
|
|
719
|
-
}
|
|
720
|
-
/**
|
|
721
|
-
* Translate interactive element safely (preserves event handlers)
|
|
722
|
-
*/
|
|
723
|
-
translateInteractive(el) {
|
|
724
|
-
const tag = el.tagName;
|
|
725
|
-
const role = (el.getAttribute('role') || '').toLowerCase();
|
|
726
|
-
// Always translate common interactive attributes.
|
|
727
|
-
this.translateAttribute(el, 'title', 'title');
|
|
728
|
-
this.translateAttribute(el, 'aria-label', 'aria-label');
|
|
729
|
-
if (tag === 'INPUT' || tag === 'TEXTAREA' || tag === 'SELECT') {
|
|
730
|
-
this.translateAttribute(el, 'placeholder', 'placeholder');
|
|
731
|
-
return;
|
|
732
|
-
}
|
|
733
|
-
// For large interactive containers, let nested semantic nodes handle visible text.
|
|
734
|
-
if ((tag === 'A' || role === 'link' || role === 'button' || role === 'tab') && el.children.length > 0 && this.isLargeInteractiveContainer(el)) {
|
|
735
|
-
return;
|
|
736
|
-
}
|
|
737
|
-
// Idempotently store original plain text once
|
|
738
|
-
const plainOriginalKey = 'data-Lovalingo-original';
|
|
739
|
-
if (!el.getAttribute(plainOriginalKey)) {
|
|
740
|
-
el.setAttribute(plainOriginalKey, (el.textContent || '').trim());
|
|
741
|
-
}
|
|
742
|
-
// Translate text nodes for label-like elements (only safe for simple/leaf controls)
|
|
743
|
-
if (tag === 'BUTTON' || tag === 'A' || tag === 'LABEL' || tag === 'SUMMARY' || role === 'button' || role === 'link') {
|
|
744
|
-
const content = this.extractTranslatableContent(el);
|
|
745
|
-
const tokenizedKeyAttr = 'data-Lovalingo-original-tokenized';
|
|
746
|
-
const tokenized = (el.getAttribute(tokenizedKeyAttr) || content.processedText || '').trim();
|
|
747
|
-
if (!el.getAttribute(tokenizedKeyAttr) && content.processedText) {
|
|
748
|
-
el.setAttribute(tokenizedKeyAttr, content.processedText);
|
|
749
|
-
}
|
|
750
|
-
const plainOriginal = el.getAttribute(plainOriginalKey) || (el.textContent || '').trim();
|
|
751
|
-
const source = (tokenized && this.isTranslatableText(tokenized)) ? tokenized : plainOriginal;
|
|
752
|
-
if (this.isTranslatableText(source)) {
|
|
753
|
-
const translated = this.translationMap.get(source);
|
|
754
|
-
if (translated) {
|
|
755
|
-
// Ensure restoreDOM can revert translated controls.
|
|
756
|
-
const nearestOriginalHtml = el.closest?.('[data-Lovalingo-original-html]');
|
|
757
|
-
if ((!nearestOriginalHtml || nearestOriginalHtml === el) && !el.getAttribute('data-Lovalingo-original-html')) {
|
|
758
|
-
el.setAttribute('data-Lovalingo-original-html', el.innerHTML);
|
|
759
|
-
}
|
|
760
|
-
const temp = document.createElement('div');
|
|
761
|
-
temp.innerHTML = this.reconstructHTML(translated, content.placeholderMap);
|
|
762
|
-
this.updateTextNodesOnly(el, temp);
|
|
763
|
-
}
|
|
764
|
-
}
|
|
765
|
-
}
|
|
766
|
-
}
|
|
767
|
-
/**
|
|
768
|
-
* Translate individual attributes
|
|
769
|
-
*/
|
|
770
|
-
translateAttribute(element, attr, context) {
|
|
771
|
-
if (this.isExcluded(element))
|
|
772
|
-
return;
|
|
773
|
-
const originalAttrKey = `data-Lovalingo-${attr}-original`;
|
|
774
|
-
// Get or store original
|
|
775
|
-
let sourceValue = element.getAttribute(originalAttrKey);
|
|
776
|
-
if (!sourceValue) {
|
|
777
|
-
const value = element.getAttribute(attr);
|
|
778
|
-
if (value) {
|
|
779
|
-
sourceValue = value.trim();
|
|
780
|
-
element.setAttribute(originalAttrKey, sourceValue);
|
|
781
|
-
}
|
|
782
|
-
}
|
|
783
|
-
if (!sourceValue || sourceValue.length <= 1)
|
|
784
|
-
return;
|
|
785
|
-
// LAYER 5: Validate attribute value is translatable
|
|
786
|
-
if (!this.isTranslatableText(sourceValue)) {
|
|
787
|
-
return; // Skip non-translatable attributes
|
|
788
|
-
}
|
|
789
|
-
// Try to translate
|
|
790
|
-
const translated = this.translationMap.get(sourceValue);
|
|
791
|
-
if (translated) {
|
|
792
|
-
element.setAttribute(attr, translated);
|
|
793
|
-
}
|
|
794
|
-
}
|
|
795
|
-
translateDOM() {
|
|
796
|
-
logDebug(`[Lovalingo] 🔄 translateDOM() called with ${this.translationMap.size} translations`);
|
|
797
|
-
const startTime = performance.now();
|
|
798
|
-
this.translateElement(document.body);
|
|
799
|
-
const elapsed = performance.now() - startTime;
|
|
800
|
-
logDebug(`[Lovalingo] 🏁 translateDOM() complete in ${elapsed.toFixed(2)}ms.`);
|
|
801
|
-
}
|
|
802
|
-
}
|