@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.
Files changed (67) hide show
  1. package/dist/__tests__/languageFlags.test.d.ts +1 -0
  2. package/dist/__tests__/languageFlags.test.js +42 -0
  3. package/dist/__tests__/mergeEntitlements.test.d.ts +1 -0
  4. package/dist/__tests__/mergeEntitlements.test.js +27 -0
  5. package/dist/components/LanguageSwitcher.js +80 -53
  6. package/dist/components/LovalingoProvider.js +18 -473
  7. package/dist/components/provider/__tests__/seoUtils.test.d.ts +1 -0
  8. package/dist/components/provider/__tests__/seoUtils.test.js +13 -0
  9. package/dist/components/provider/editModeUtils.d.ts +6 -0
  10. package/dist/components/provider/editModeUtils.js +59 -0
  11. package/dist/components/provider/localeUtils.d.ts +8 -0
  12. package/dist/components/provider/localeUtils.js +46 -0
  13. package/dist/components/provider/providerConstants.d.ts +12 -0
  14. package/dist/components/provider/providerConstants.js +11 -0
  15. package/dist/components/provider/seoUtils.d.ts +8 -0
  16. package/dist/components/provider/seoUtils.js +118 -0
  17. package/dist/components/provider/useEditModeOverlay.d.ts +7 -0
  18. package/dist/components/provider/useEditModeOverlay.js +134 -0
  19. package/dist/components/provider/useHistoryNavigationPatch.d.ts +3 -0
  20. package/dist/components/provider/useHistoryNavigationPatch.js +47 -0
  21. package/dist/components/provider/useProviderCache.d.ts +12 -0
  22. package/dist/components/provider/useProviderCache.js +82 -0
  23. package/dist/hooks/provider/useBundleLoading.d.ts +2 -1
  24. package/dist/hooks/provider/useBundleLoading.js +15 -3
  25. package/dist/utils/api.d.ts +3 -78
  26. package/dist/utils/api.js +1 -53
  27. package/dist/utils/apiTypes.d.ts +78 -0
  28. package/dist/utils/apiTypes.js +1 -0
  29. package/dist/utils/apiUtils.d.ts +4 -0
  30. package/dist/utils/apiUtils.js +54 -0
  31. package/dist/utils/languageFlags.d.ts +7 -0
  32. package/dist/utils/languageFlags.js +90 -0
  33. package/dist/utils/markerEngine.d.ts +8 -66
  34. package/dist/utils/markerEngine.js +19 -703
  35. package/dist/utils/markerEngineApply.d.ts +3 -0
  36. package/dist/utils/markerEngineApply.js +136 -0
  37. package/dist/utils/markerEngineConstants.d.ts +10 -0
  38. package/dist/utils/markerEngineConstants.js +12 -0
  39. package/dist/utils/markerEngineCritical.d.ts +2 -0
  40. package/dist/utils/markerEngineCritical.js +98 -0
  41. package/dist/utils/markerEngineDomUtils.d.ts +8 -0
  42. package/dist/utils/markerEngineDomUtils.js +74 -0
  43. package/dist/utils/markerEngineFilters.d.ts +2 -0
  44. package/dist/utils/markerEngineFilters.js +26 -0
  45. package/dist/utils/markerEngineMisses.d.ts +5 -0
  46. package/dist/utils/markerEngineMisses.js +81 -0
  47. package/dist/utils/markerEngineOriginals.d.ts +5 -0
  48. package/dist/utils/markerEngineOriginals.js +29 -0
  49. package/dist/utils/markerEngineScan.d.ts +5 -0
  50. package/dist/utils/markerEngineScan.js +162 -0
  51. package/dist/utils/markerEngineState.d.ts +4 -0
  52. package/dist/utils/markerEngineState.js +14 -0
  53. package/dist/utils/markerEngineStats.d.ts +3 -0
  54. package/dist/utils/markerEngineStats.js +28 -0
  55. package/dist/utils/markerEngineTranslations.d.ts +3 -0
  56. package/dist/utils/markerEngineTranslations.js +49 -0
  57. package/dist/utils/markerEngineTypes.d.ts +62 -0
  58. package/dist/utils/markerEngineTypes.js +1 -0
  59. package/dist/utils/markerEngineViewport.d.ts +2 -0
  60. package/dist/utils/markerEngineViewport.js +27 -0
  61. package/dist/utils/mergeEntitlements.d.ts +2 -0
  62. package/dist/utils/mergeEntitlements.js +7 -0
  63. package/dist/version.d.ts +1 -1
  64. package/dist/version.js +1 -1
  65. package/package.json +1 -1
  66. package/dist/utils/translator.d.ts +0 -80
  67. package/dist/utils/translator.js +0 -802
@@ -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
- }