@lovalingo/lovalingo 0.0.24 → 0.0.25

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.
@@ -6,5 +6,22 @@ interface LovalingoProviderProps extends LovalingoConfig {
6
6
  seo?: boolean;
7
7
  navigateRef?: React.MutableRefObject<((path: string) => void) | undefined>;
8
8
  }
9
+ type LovalingoSeleniumBridge = {
10
+ getStatus: () => {
11
+ mode: LovalingoConfig["mode"];
12
+ locale: string;
13
+ defaultLocale: string;
14
+ path: string;
15
+ missed: number;
16
+ };
17
+ flushMisses: () => Promise<{
18
+ reported: number;
19
+ locale: string;
20
+ path: string;
21
+ }>;
22
+ };
23
+ declare global {
24
+ var __LOVALINGO_SELENIUM__: LovalingoSeleniumBridge | undefined;
25
+ }
9
26
  export declare const LovalingoProvider: React.FC<LovalingoProviderProps>;
10
27
  export {};
@@ -434,6 +434,38 @@ navigateRef, // For path mode routing
434
434
  translatingHashesRef.current.delete(hash);
435
435
  }
436
436
  }, [defaultLocale, locale, hashTranslations]);
437
+ // Selenium bridge: allows automation to force-flush misses instead of relying on the 5s interval.
438
+ useEffect(() => {
439
+ if (typeof window === "undefined")
440
+ return;
441
+ const bridge = {
442
+ getStatus: () => ({
443
+ mode,
444
+ locale,
445
+ defaultLocale,
446
+ path: window.location.pathname + window.location.search,
447
+ missed: translatorRef.current.getMissedStrings().length,
448
+ }),
449
+ flushMisses: async () => {
450
+ // Only flush in DOM mode + non-default locale, matching the normal periodic reporter.
451
+ if (mode !== "dom" || locale === defaultLocale) {
452
+ return { reported: 0, locale, path: window.location.pathname + window.location.search };
453
+ }
454
+ const missed = translatorRef.current.getMissedStrings();
455
+ if (missed.length > 0) {
456
+ await apiRef.current.reportMisses(missed, defaultLocale, locale);
457
+ translatorRef.current.clearMissedStrings();
458
+ }
459
+ return { reported: missed.length, locale, path: window.location.pathname + window.location.search };
460
+ },
461
+ };
462
+ globalThis.__LOVALINGO_SELENIUM__ = bridge;
463
+ return () => {
464
+ if (globalThis.__LOVALINGO_SELENIUM__ === bridge) {
465
+ delete globalThis.__LOVALINGO_SELENIUM__;
466
+ }
467
+ };
468
+ }, [defaultLocale, locale, mode]);
437
469
  // Initialize
438
470
  useEffect(() => {
439
471
  const initialLocale = detectLocale();
package/dist/types.d.ts CHANGED
@@ -55,7 +55,7 @@ export interface Exclusion {
55
55
  type: 'css' | 'xpath';
56
56
  }
57
57
  export interface PlaceholderData {
58
- type: 'inline-element' | 'non-translatable' | 'interactive';
58
+ type: 'inline-formatting' | 'non-translatable' | 'interactive';
59
59
  originalHTML: string;
60
60
  textContent: string;
61
61
  tag: string;
@@ -17,6 +17,8 @@ export declare class LovalingoAPI {
17
17
  private hasApiKey;
18
18
  private warnMissingApiKey;
19
19
  private logActivationRequired;
20
+ private isActivationRequiredPayload;
21
+ private isActivationRequiredResponse;
20
22
  getEntitlements(): ProjectEntitlements | null;
21
23
  fetchEntitlements(localeHint: string): Promise<ProjectEntitlements | null>;
22
24
  trackPageview(pathOrUrl: string): Promise<void>;
package/dist/utils/api.js CHANGED
@@ -19,6 +19,20 @@ export class LovalingoAPI {
19
19
  `Publish a public routes manifest at "/.well-known/lovalingo-routes.json" on your domain, ` +
20
20
  `then verify it in the Lovalingo dashboard to activate translations + SEO.`);
21
21
  }
22
+ isActivationRequiredPayload(data) {
23
+ if (!data || typeof data !== "object")
24
+ return false;
25
+ const status = data.status;
26
+ const errorCode = data.error_code;
27
+ return status === "activation_required" || errorCode === "PROJECT_NOT_ACTIVATED";
28
+ }
29
+ isActivationRequiredResponse(response, data) {
30
+ if (response.status === 403)
31
+ return true;
32
+ if (response.headers.get("X-Lovalingo-Status") === "activation_required")
33
+ return true;
34
+ return typeof data !== "undefined" ? this.isActivationRequiredPayload(data) : false;
35
+ }
22
36
  getEntitlements() {
23
37
  return this.entitlements;
24
38
  }
@@ -30,13 +44,17 @@ export class LovalingoAPI {
30
44
  }
31
45
  const normalizedPath = processPath(window.location.pathname, this.pathConfig);
32
46
  const response = await fetch(`${this.apiBase}/functions/v1/bundle?key=${this.apiKey}&locale=${localeHint}&path=${normalizedPath}`);
33
- if (response.status === 403) {
47
+ if (this.isActivationRequiredResponse(response)) {
34
48
  this.logActivationRequired('fetchEntitlements', response);
35
49
  return null;
36
50
  }
37
51
  if (!response.ok)
38
52
  return null;
39
53
  const data = await response.json();
54
+ if (this.isActivationRequiredResponse(response, data)) {
55
+ this.logActivationRequired("fetchEntitlements", response);
56
+ return null;
57
+ }
40
58
  if (data?.entitlements) {
41
59
  this.entitlements = {
42
60
  ...data.entitlements,
@@ -77,13 +95,17 @@ export class LovalingoAPI {
77
95
  // Use path normalization utility
78
96
  const normalizedPath = processPath(window.location.pathname, this.pathConfig);
79
97
  const response = await fetch(`${this.apiBase}/functions/v1/bundle?key=${this.apiKey}&locale=${targetLocale}&path=${normalizedPath}`);
80
- if (response.status === 403) {
98
+ if (this.isActivationRequiredResponse(response)) {
81
99
  this.logActivationRequired('fetchTranslations', response);
82
100
  return [];
83
101
  }
84
102
  if (!response.ok)
85
103
  throw new Error('Failed to fetch translations');
86
104
  const data = await response.json();
105
+ if (this.isActivationRequiredResponse(response, data)) {
106
+ this.logActivationRequired("fetchTranslations", response);
107
+ return [];
108
+ }
87
109
  if (data?.entitlements) {
88
110
  this.entitlements = {
89
111
  ...data.entitlements,
@@ -169,7 +191,7 @@ export class LovalingoAPI {
169
191
  path: normalizedPath,
170
192
  }),
171
193
  });
172
- if (response.status === 403) {
194
+ if (this.isActivationRequiredResponse(response)) {
173
195
  this.logActivationRequired('reportMisses', response);
174
196
  return;
175
197
  }
@@ -179,6 +201,10 @@ export class LovalingoAPI {
179
201
  throw new Error(`Miss reporting failed: ${response.status}`);
180
202
  }
181
203
  const result = await response.json();
204
+ if (this.isActivationRequiredResponse(response, result)) {
205
+ this.logActivationRequired("reportMisses", response);
206
+ return;
207
+ }
182
208
  console.log('[Lovalingo] ✅ Misses reported:', result);
183
209
  }
184
210
  catch (error) {
@@ -229,7 +255,7 @@ export class LovalingoAPI {
229
255
  targetLocale,
230
256
  }),
231
257
  });
232
- if (response.status === 403) {
258
+ if (this.isActivationRequiredResponse(response)) {
233
259
  this.logActivationRequired('translateRealtime', response);
234
260
  return null;
235
261
  }
@@ -239,6 +265,10 @@ export class LovalingoAPI {
239
265
  return null;
240
266
  }
241
267
  const result = await response.json();
268
+ if (this.isActivationRequiredResponse(response, result)) {
269
+ this.logActivationRequired("translateRealtime", response);
270
+ return null;
271
+ }
242
272
  if (result.success && result.translation) {
243
273
  console.log(`[Lovalingo] ✅ Translated: "${result.translation.substring(0, 40)}..." ${result.cached ? '(cached)' : '(new)'}`);
244
274
  return result.translation;
@@ -21,6 +21,9 @@ export declare class Translator {
21
21
  * This catches cases like: <span>text</span>, <div>text</div>, etc.
22
22
  */
23
23
  private shouldTreatAsSemantic;
24
+ private isLargeInteractiveContainer;
25
+ private sanitizePlaceholderMap;
26
+ private cleanupPreserveIds;
24
27
  setTranslations(translations: Translation[]): void;
25
28
  setExclusions(exclusions: Exclusion[]): void;
26
29
  getMissedStrings(): MissedTranslation[];
@@ -34,7 +34,7 @@ export class Translator {
34
34
  if (!text || text.trim().length < 2)
35
35
  return false;
36
36
  // Check if it's only placeholder tokens
37
- if (/^(__[A-Z]+_\d+__\s*)+$/.test(text))
37
+ if (/^(__[A-Z0-9_]+__\s*)+$/.test(text))
38
38
  return false;
39
39
  // Check if it's numeric only
40
40
  if (/^\d+(\.\d+)?$/.test(text))
@@ -69,6 +69,47 @@ export class Translator {
69
69
  return false;
70
70
  return this.isTranslatableText(textContent);
71
71
  }
72
+ isLargeInteractiveContainer(element) {
73
+ const textLen = (element.textContent || '').trim().length;
74
+ if (textLen > 200)
75
+ return true;
76
+ const descendantCount = element.querySelectorAll('*').length;
77
+ if (descendantCount > 12)
78
+ return true;
79
+ 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'));
80
+ if (hasBlockDescendant)
81
+ return true;
82
+ const hasNestedInteractive = Boolean(element.querySelector('button,a,input,textarea,select,[role="button"],[role="link"],[contenteditable]'));
83
+ if (hasNestedInteractive)
84
+ return true;
85
+ return false;
86
+ }
87
+ sanitizePlaceholderMap(placeholderMap) {
88
+ const out = {};
89
+ if (!placeholderMap || typeof placeholderMap !== 'object')
90
+ return out;
91
+ const stripPreserveIds = (html) => (html || '').replace(/\sdata-lovalingo-preserve-id=("[^"]*"|'[^']*')/gi, '');
92
+ for (const [token, data] of Object.entries(placeholderMap)) {
93
+ if (!data)
94
+ continue;
95
+ const next = {
96
+ ...data,
97
+ originalHTML: stripPreserveIds(data.originalHTML || ''),
98
+ attributes: data.attributes ? { ...data.attributes } : {},
99
+ };
100
+ for (const key of Object.keys(next.attributes || {})) {
101
+ if (key.toLowerCase() === 'data-lovalingo-preserve-id') {
102
+ delete next.attributes[key];
103
+ }
104
+ }
105
+ out[token] = next;
106
+ }
107
+ return out;
108
+ }
109
+ cleanupPreserveIds(element, markedElements) {
110
+ markedElements?.forEach((el) => el.removeAttribute('data-Lovalingo-preserve-id'));
111
+ element.querySelectorAll('[data-Lovalingo-preserve-id]').forEach((el) => el.removeAttribute('data-Lovalingo-preserve-id'));
112
+ }
72
113
  setTranslations(translations) {
73
114
  this.translationMap.clear();
74
115
  if (!Array.isArray(translations)) {
@@ -138,7 +179,8 @@ export class Translator {
138
179
  * Uses DOM APIs to reliably parse and tokenize HTML
139
180
  */
140
181
  extractTranslatableContent(semanticElement) {
141
- const rawHTML = semanticElement.innerHTML;
182
+ // Prefer a stable snapshot without temporary preserve markers.
183
+ const rawHTML = semanticElement.getAttribute('data-Lovalingo-original-html') || semanticElement.innerHTML;
142
184
  const semanticContext = semanticElement.tagName.toLowerCase();
143
185
  // Clone element to avoid modifying original DOM
144
186
  const clone = semanticElement.cloneNode(true);
@@ -156,6 +198,22 @@ export class Translator {
156
198
  'img', 'video', 'audio', 'iframe', 'object', 'embed',
157
199
  'code', 'pre'
158
200
  ]);
201
+ const WRAP_INTERACTIVE_TAGS = new Set(['button', 'label', 'summary']);
202
+ const WRAP_INTERACTIVE_ROLES = new Set(['button', 'link', 'tab']);
203
+ const getOuterHTML = (el) => {
204
+ try {
205
+ return el.outerHTML || '';
206
+ }
207
+ catch {
208
+ return '';
209
+ }
210
+ };
211
+ const getOpenTag = (outerHTML) => {
212
+ const idx = outerHTML.indexOf('>');
213
+ if (idx === -1)
214
+ return '';
215
+ return outerHTML.slice(0, idx + 1);
216
+ };
159
217
  /**
160
218
  * Recursively process DOM nodes
161
219
  * Returns true if node was replaced with token
@@ -166,10 +224,10 @@ export class Translator {
166
224
  const tagName = element.tagName.toLowerCase();
167
225
  // Remove skip tags entirely
168
226
  if (SKIP_TAGS.has(tagName)) {
169
- const token = `__SKIP_${placeholderCounter}__`;
227
+ const token = `__PRESERVE_${placeholderCounter}__`;
170
228
  placeholderMap[token] = {
171
229
  type: 'non-translatable',
172
- originalHTML: element.outerHTML,
230
+ originalHTML: getOuterHTML(element),
173
231
  textContent: '',
174
232
  tag: tagName,
175
233
  attributes: {}
@@ -179,63 +237,82 @@ export class Translator {
179
237
  element.replaceWith(textNode);
180
238
  return true;
181
239
  }
182
- // Handle interactive elements as islands (but not the root semantic element)
183
- if (this.isInteractive(element) && element !== clone) {
184
- const token = `__ISLAND_${placeholderCounter}__`;
240
+ // Process children first (depth-first)
241
+ const children = Array.from(element.childNodes);
242
+ children.forEach(child => processNode(child));
243
+ const textContent = element.textContent || '';
244
+ const trimmedContent = textContent.trim();
245
+ // Skip wrapping/preserving when there's nothing meaningful inside.
246
+ if (!trimmedContent || trimmedContent.length < 2) {
247
+ return false;
248
+ }
249
+ // Skip icon elements (or icon-like wrappers).
250
+ const isIcon = tagName === 'svg' ||
251
+ tagName === 'i' ||
252
+ element.hasAttribute('aria-hidden') ||
253
+ element.classList.contains('icon') ||
254
+ element.classList.contains('lucide');
255
+ if (isIcon) {
256
+ return false;
257
+ }
258
+ // Preserve explicit non-translatable inline nodes as a single token, so surrounding text can still translate.
259
+ const isNonTranslatable = this.nonTranslatableTerms.has(trimmedContent) ||
260
+ element.hasAttribute('data-no-translate') ||
261
+ element.hasAttribute('translate-no') ||
262
+ element.hasAttribute('data-no-translate');
263
+ if (isNonTranslatable && element !== clone) {
264
+ const token = `__PRESERVE_${placeholderCounter}__`;
185
265
  placeholderMap[token] = {
186
- type: 'interactive',
187
- originalHTML: element.outerHTML,
188
- textContent: element.textContent || '',
266
+ type: 'non-translatable',
267
+ originalHTML: getOuterHTML(element),
268
+ textContent,
189
269
  tag: tagName,
190
- attributes: {}
270
+ attributes: {},
191
271
  };
192
272
  placeholderCounter++;
193
273
  const textNode = document.createTextNode(token);
194
274
  element.replaceWith(textNode);
195
275
  return true;
196
276
  }
197
- // Process children first (depth-first)
198
- const children = Array.from(element.childNodes);
199
- children.forEach(child => processNode(child));
200
- // Then decide if this element itself should be tokenized
201
- if (INLINE_TAGS.has(tagName)) {
202
- const textContent = element.textContent || '';
203
- const trimmedContent = textContent.trim();
204
- // LAYER 1: Skip tokenization for non-translatable content
205
- // Skip if no actual text content
206
- if (!trimmedContent || trimmedContent.length < 2) {
207
- return false; // Don't tokenize empty/too-short elements
208
- }
209
- // Skip icon elements
210
- const isIcon = tagName === 'svg' ||
211
- tagName === 'i' ||
212
- element.hasAttribute('aria-hidden') ||
213
- element.classList.contains('icon') ||
214
- element.classList.contains('lucide');
215
- if (isIcon) {
216
- return false; // Don't tokenize icons
217
- }
218
- // Check if this is a non-translatable term
219
- const isNonTranslatable = this.nonTranslatableTerms.has(trimmedContent) ||
220
- element.hasAttribute('data-no-translate') ||
221
- element.hasAttribute('translate-no');
222
- const token = `__INLINE_${placeholderCounter}__`;
223
- // Store full HTML and attributes
277
+ const role = (element.getAttribute('role') || '').toLowerCase();
278
+ const shouldWrapInteractive = element !== clone &&
279
+ this.isInteractive(element) &&
280
+ !['input', 'textarea', 'select'].includes(tagName) &&
281
+ (WRAP_INTERACTIVE_TAGS.has(tagName) || WRAP_INTERACTIVE_ROLES.has(role));
282
+ const shouldWrapInline = element !== clone && INLINE_TAGS.has(tagName);
283
+ if (shouldWrapInline || shouldWrapInteractive) {
284
+ // Wrap with OPEN/CLOSE tokens so the model can translate inner text and still preserve markup.
285
+ const openToken = `__OPEN_${placeholderCounter}__`;
286
+ const closeToken = `__CLOSE_${placeholderCounter}__`;
287
+ placeholderCounter++;
288
+ const outer = getOuterHTML(element);
289
+ const openTag = getOpenTag(outer);
290
+ const closeTag = `</${tagName}>`;
224
291
  const attributes = {};
225
292
  Array.from(element.attributes).forEach(attr => {
226
293
  attributes[attr.name] = attr.value;
227
294
  });
228
- placeholderMap[token] = {
229
- type: isNonTranslatable ? 'non-translatable' : 'inline-element',
230
- originalHTML: element.outerHTML,
231
- textContent: textContent,
295
+ placeholderMap[openToken] = {
296
+ type: 'inline-formatting',
297
+ originalHTML: openTag,
298
+ textContent,
232
299
  tag: tagName,
233
- attributes
300
+ attributes,
234
301
  };
235
- placeholderCounter++;
236
- // Replace element with token text node
237
- const textNode = document.createTextNode(token);
238
- element.replaceWith(textNode);
302
+ placeholderMap[closeToken] = {
303
+ type: 'inline-formatting',
304
+ originalHTML: closeTag,
305
+ textContent: '',
306
+ tag: tagName,
307
+ attributes: {},
308
+ };
309
+ const fragment = document.createDocumentFragment();
310
+ fragment.appendChild(document.createTextNode(openToken));
311
+ while (element.firstChild) {
312
+ fragment.appendChild(element.firstChild);
313
+ }
314
+ fragment.appendChild(document.createTextNode(closeToken));
315
+ element.replaceWith(fragment);
239
316
  return true;
240
317
  }
241
318
  }
@@ -290,7 +367,9 @@ export class Translator {
290
367
  const tokens = Object.keys(placeholderMap).sort((a, b) => {
291
368
  const numA = parseInt(a.match(/\d+/)?.[0] || '0');
292
369
  const numB = parseInt(b.match(/\d+/)?.[0] || '0');
293
- return numA - numB;
370
+ if (numA !== numB)
371
+ return numA - numB;
372
+ return a.localeCompare(b);
294
373
  });
295
374
  for (const token of tokens) {
296
375
  const data = placeholderMap[token];
@@ -475,14 +554,19 @@ export class Translator {
475
554
  const SKIP_TAGS = new Set(['SCRIPT', 'STYLE', 'NOSCRIPT', 'IFRAME', 'CODE', 'PRE']);
476
555
  if (SKIP_TAGS.has(element.tagName))
477
556
  return;
478
- // Interactive containers (like <a> cards) must not be structurally rewritten.
479
- // We only translate attributes here and allow children to be translated normally.
480
557
  if (this.isInteractive(element)) {
481
- this.translateAttribute(element, 'title', 'title');
482
- this.translateAttribute(element, 'aria-label', 'aria-label');
483
- if (element.tagName === 'INPUT' || element.tagName === 'TEXTAREA' || element.tagName === 'SELECT') {
484
- this.translateAttribute(element, 'placeholder', 'placeholder');
485
- }
558
+ const tag = element.tagName.toLowerCase();
559
+ const role = (element.getAttribute('role') || '').toLowerCase();
560
+ const isKnownLeafControl = ['button', 'label', 'summary', 'input', 'textarea', 'select'].includes(tag);
561
+ const isRoleLeaf = role === 'button' || role === 'tab';
562
+ const isLink = tag === 'a' || role === 'link';
563
+ // For leaf controls and simple interactive labels: translate safely in-place and stop.
564
+ // For interactive containers (cards, composite widgets): translate attributes and recurse to children.
565
+ const shouldRecurse = (!isKnownLeafControl && !isRoleLeaf && !isLink) ||
566
+ ((isLink || isRoleLeaf) && this.isLargeInteractiveContainer(element));
567
+ this.translateInteractive(element);
568
+ if (!shouldRecurse)
569
+ return;
486
570
  }
487
571
  // Semantic boundaries - translate these as complete units
488
572
  const SEMANTIC_BOUNDARIES = new Set([
@@ -498,86 +582,68 @@ export class Translator {
498
582
  }
499
583
  // STEP 1: Mark all child elements before extraction
500
584
  const markedElements = this.markElements(element);
501
- // Extract content with DOM processing
502
- const content = this.extractTranslatableContent(element);
503
- // Skip if no valid content
504
- if (!content.processedText || content.processedText.length <= 1) {
505
- return;
506
- }
507
- // Store original processed text for comparison
508
- const originalTextAttr = element.getAttribute('data-Lovalingo-original');
509
- if (!originalTextAttr) {
510
- element.setAttribute('data-Lovalingo-original', content.processedText);
511
- }
512
- // CRITICAL FIX: Always use stored original text, not current DOM state
513
- const sourceText = originalTextAttr || content.processedText;
514
- // LAYER 4: Pre-translation validation
515
- if (!this.isTranslatableText(sourceText)) {
516
- console.log('[Lovalingo] ⏭️ Skipping non-translatable content:', sourceText.substring(0, 50));
517
- return;
518
- }
519
- // Try to find translation
520
- const translated = this.translationMap.get(sourceText);
521
- if (translated) {
522
- console.log(`[Lovalingo] ✅ Translating: "${sourceText.substring(0, 50)}..." → "${translated.substring(0, 50)}..."`);
523
- // Reconstruct HTML with tokens replaced
524
- let reconstructedHTML = this.reconstructHTML(translated, content.placeholderMap);
525
- // LAYER 3: Check for leftover tokens and fallback to original
526
- if (/__[A-Z]+_\d+__/.test(reconstructedHTML)) {
527
- console.warn('[Lovalingo] ⚠️ Tokens remain after reconstruction, using original HTML');
528
- reconstructedHTML = content.rawHTML; // Fallback to original
585
+ try {
586
+ const content = this.extractTranslatableContent(element);
587
+ // If there's no meaningful boundary content, recurse to children.
588
+ if (!content.processedText || content.processedText.length <= 1) {
589
+ Array.from(element.children).forEach((child) => {
590
+ if (child instanceof HTMLElement)
591
+ this.translateElement(child);
592
+ });
593
+ return;
529
594
  }
530
- // STEP 2 & 3: Update element safely based on type
531
- // Mark for MutationObserver suppression (prevents feedback loops on DOM updates).
532
- element.setAttribute('data-Lovalingo-translating', '1');
533
- setTimeout(() => {
534
- try {
535
- element.removeAttribute('data-Lovalingo-translating');
536
- }
537
- catch { }
538
- }, 50);
539
- if (element.tagName === 'BUTTON') {
540
- // For buttons: Check complexity first
541
- const hasSVG = element.querySelector('svg');
542
- const hasMultipleSpans = element.querySelectorAll('span').length > 2;
543
- if (hasSVG && hasMultipleSpans) {
544
- console.warn('[Lovalingo] ⚠️ Complex button detected, skipping DOM translation (AutoTranslate will handle)');
545
- return;
595
+ // Store original processed text for comparison
596
+ const originalTextAttr = element.getAttribute('data-Lovalingo-original');
597
+ if (!originalTextAttr) {
598
+ element.setAttribute('data-Lovalingo-original', content.processedText);
599
+ }
600
+ // Always use stored original text, not current DOM state
601
+ const sourceText = originalTextAttr || content.processedText;
602
+ // If boundary isn't translatable, recurse to children.
603
+ if (!this.isTranslatableText(sourceText)) {
604
+ console.log('[Lovalingo] ⏭️ Skipping non-translatable content:', sourceText.substring(0, 50));
605
+ Array.from(element.children).forEach((child) => {
606
+ if (child instanceof HTMLElement)
607
+ this.translateElement(child);
608
+ });
609
+ return;
610
+ }
611
+ const translated = this.translationMap.get(sourceText);
612
+ if (translated) {
613
+ console.log(`[Lovalingo] ✅ Translating: "${sourceText.substring(0, 50)}..." → "${translated.substring(0, 50)}..."`);
614
+ let reconstructedHTML = this.reconstructHTML(translated, content.placeholderMap);
615
+ if (/__[A-Z0-9_]+__/.test(reconstructedHTML)) {
616
+ console.warn('[Lovalingo] ⚠️ Tokens remain after reconstruction, using original HTML');
617
+ reconstructedHTML = content.rawHTML;
546
618
  }
547
- // For buttons: ONLY update text nodes, never touch DOM structure
548
- console.log(`[Lovalingo] 🔘 Button translation (text-only): "${sourceText.substring(0, 50)}..." → "${translated.substring(0, 50)}..."`);
549
- const tempDiv = document.createElement('div');
550
- tempDiv.innerHTML = reconstructedHTML;
551
- this.updateTextNodesOnly(element, tempDiv);
619
+ // Mark for MutationObserver suppression (prevents feedback loops on DOM updates).
620
+ element.setAttribute('data-Lovalingo-translating', '1');
621
+ setTimeout(() => {
622
+ element.removeAttribute('data-Lovalingo-translating');
623
+ }, 50);
624
+ // Full reconstruction + live-node transplant.
625
+ this.updateElementChildren(element, reconstructedHTML, markedElements);
626
+ // Translate interactive descendants safely (preserves event handlers)
627
+ const interactiveSelector = 'button, a, label, input, textarea, select, [role="button"], [role="link"], [contenteditable], [contenteditable="true"], [tabindex]';
628
+ element.querySelectorAll(interactiveSelector).forEach(el => {
629
+ if (el instanceof HTMLElement)
630
+ this.translateInteractive(el);
631
+ });
552
632
  }
553
633
  else {
554
- // For other elements: Full reconstruction
555
- this.updateElementChildren(element, reconstructedHTML, markedElements);
634
+ console.log(`[Lovalingo] Miss: "${sourceText.substring(0, 80)}..."`);
635
+ if (sourceText.length < 5000 && this.isTranslatableText(sourceText)) {
636
+ this.missedStrings.set(sourceText, {
637
+ text: sourceText,
638
+ raw: content.rawHTML,
639
+ placeholderMap: this.sanitizePlaceholderMap(content.placeholderMap),
640
+ semanticContext: content.semanticContext
641
+ });
642
+ }
556
643
  }
557
- // Clean up preserve IDs
558
- element.querySelectorAll('[data-Lovalingo-preserve-id]').forEach(el => {
559
- el.removeAttribute('data-Lovalingo-preserve-id');
560
- });
561
- // Translate interactive descendants safely (preserves event handlers)
562
- const interactiveSelector = 'button, a, label, input, textarea, select, [role="button"], [role="link"], [contenteditable], [contenteditable="true"], [tabindex]';
563
- element.querySelectorAll(interactiveSelector).forEach(el => {
564
- if (el instanceof HTMLElement)
565
- this.translateInteractive(el);
566
- });
567
644
  }
568
- else {
569
- // Track as miss
570
- console.log(`[Lovalingo] ❌ Miss: "${sourceText.substring(0, 80)}..."`);
571
- // LAYER 2: Only queue translatable content
572
- if (sourceText.length < 5000 && this.isTranslatableText(sourceText)) {
573
- // Use sourceText (original) as key for automatic deduplication
574
- this.missedStrings.set(sourceText, {
575
- text: sourceText,
576
- raw: content.rawHTML,
577
- placeholderMap: content.placeholderMap,
578
- semanticContext: content.semanticContext
579
- });
580
- }
645
+ finally {
646
+ this.cleanupPreserveIds(element, markedElements);
581
647
  }
582
648
  return; // Don't process children if we handled the boundary
583
649
  }
@@ -595,72 +661,53 @@ export class Translator {
595
661
  }
596
662
  // Mark all child elements before extraction
597
663
  const markedElements = this.markElements(element);
598
- // Extract content with DOM processing
599
- const content = this.extractTranslatableContent(element);
600
- // Skip if no valid content
601
- if (!content.processedText || content.processedText.length <= 1) {
602
- return;
603
- }
604
- // Store original processed text
605
- const originalTextAttr = element.getAttribute('data-Lovalingo-original');
606
- if (!originalTextAttr) {
607
- element.setAttribute('data-Lovalingo-original', content.processedText);
608
- }
609
- // Use stored original text
610
- const sourceText = originalTextAttr || content.processedText;
611
- // Validate
612
- if (!this.isTranslatableText(sourceText)) {
613
- console.log('[Lovalingo] ⏭️ Skipping non-translatable content (generic):', sourceText.substring(0, 50));
614
- return;
615
- }
616
- // Try to find translation
617
- const translated = this.translationMap.get(sourceText);
618
- if (translated) {
619
- console.log(`[Lovalingo] ✅ Generic semantic: "${sourceText.substring(0, 50)}..." → "${translated.substring(0, 50)}..."`);
620
- // Reconstruct HTML with tokens replaced
621
- let reconstructedHTML = this.reconstructHTML(translated, content.placeholderMap);
622
- // Check for leftover tokens
623
- if (/__[A-Z]+_\d+__/.test(reconstructedHTML)) {
624
- console.warn('[Lovalingo] ⚠️ Tokens remain (generic), using original HTML');
625
- reconstructedHTML = content.rawHTML;
664
+ try {
665
+ const content = this.extractTranslatableContent(element);
666
+ // Skip if no valid content
667
+ if (!content.processedText || content.processedText.length <= 1) {
668
+ return;
626
669
  }
627
- // Update element based on type
628
- if (element.tagName === 'BUTTON') {
629
- const hasSVG = element.querySelector('svg');
630
- const hasMultipleSpans = element.querySelectorAll('span').length > 2;
631
- if (hasSVG && hasMultipleSpans) {
632
- console.warn('[Lovalingo] ⚠️ Complex button (generic), skipping');
633
- return;
670
+ // Store original processed text
671
+ const originalTextAttr = element.getAttribute('data-Lovalingo-original');
672
+ if (!originalTextAttr) {
673
+ element.setAttribute('data-Lovalingo-original', content.processedText);
674
+ }
675
+ // Use stored original text
676
+ const sourceText = originalTextAttr || content.processedText;
677
+ if (!this.isTranslatableText(sourceText)) {
678
+ console.log('[Lovalingo] ⏭️ Skipping non-translatable content (generic):', sourceText.substring(0, 50));
679
+ return;
680
+ }
681
+ const translated = this.translationMap.get(sourceText);
682
+ if (translated) {
683
+ console.log(`[Lovalingo] ✅ Generic semantic: "${sourceText.substring(0, 50)}..." → "${translated.substring(0, 50)}..."`);
684
+ let reconstructedHTML = this.reconstructHTML(translated, content.placeholderMap);
685
+ if (/__[A-Z0-9_]+__/.test(reconstructedHTML)) {
686
+ console.warn('[Lovalingo] ⚠️ Tokens remain (generic), using original HTML');
687
+ reconstructedHTML = content.rawHTML;
634
688
  }
635
- const tempDiv = document.createElement('div');
636
- tempDiv.innerHTML = reconstructedHTML;
637
- this.updateTextNodesOnly(element, tempDiv);
689
+ this.updateElementChildren(element, reconstructedHTML, markedElements);
690
+ // Translate interactive descendants
691
+ const interactiveSelector = 'button, a, label, input, textarea, select, [role="button"], [role="link"], [contenteditable], [contenteditable="true"], [tabindex]';
692
+ element.querySelectorAll(interactiveSelector).forEach(el => {
693
+ if (el instanceof HTMLElement)
694
+ this.translateInteractive(el);
695
+ });
638
696
  }
639
697
  else {
640
- this.updateElementChildren(element, reconstructedHTML, markedElements);
698
+ console.log(`[Lovalingo] ❌ Miss (generic): "${sourceText.substring(0, 80)}..."`);
699
+ if (sourceText.length < 5000 && this.isTranslatableText(sourceText)) {
700
+ this.missedStrings.set(sourceText, {
701
+ text: sourceText,
702
+ raw: content.rawHTML,
703
+ placeholderMap: this.sanitizePlaceholderMap(content.placeholderMap),
704
+ semanticContext: element.tagName.toLowerCase()
705
+ });
706
+ }
641
707
  }
642
- // Clean up preserve IDs
643
- element.querySelectorAll('[data-Lovalingo-preserve-id]').forEach(el => {
644
- el.removeAttribute('data-Lovalingo-preserve-id');
645
- });
646
- // Translate interactive descendants
647
- const interactiveSelector = 'button, a, label, input, textarea, select, [role="button"], [role="link"], [contenteditable], [contenteditable="true"], [tabindex]';
648
- element.querySelectorAll(interactiveSelector).forEach(el => {
649
- if (el instanceof HTMLElement)
650
- this.translateInteractive(el);
651
- });
652
708
  }
653
- else {
654
- // Track as miss
655
- console.log(`[Lovalingo] ❌ Miss (generic): "${sourceText.substring(0, 80)}..."`);
656
- if (sourceText.length < 5000 && this.isTranslatableText(sourceText)) {
657
- this.missedStrings.set(sourceText, {
658
- text: sourceText,
659
- raw: content.rawHTML,
660
- placeholderMap: content.placeholderMap,
661
- semanticContext: element.tagName.toLowerCase()
662
- });
663
- }
709
+ finally {
710
+ this.cleanupPreserveIds(element, markedElements);
664
711
  }
665
712
  return; // Don't recurse to children
666
713
  }
@@ -695,33 +742,42 @@ export class Translator {
695
742
  * Translate interactive element safely (preserves event handlers)
696
743
  */
697
744
  translateInteractive(el) {
745
+ const tag = el.tagName;
746
+ const role = (el.getAttribute('role') || '').toLowerCase();
747
+ // Always translate common interactive attributes.
748
+ this.translateAttribute(el, 'title', 'title');
749
+ this.translateAttribute(el, 'aria-label', 'aria-label');
750
+ if (tag === 'INPUT' || tag === 'TEXTAREA' || tag === 'SELECT') {
751
+ this.translateAttribute(el, 'placeholder', 'placeholder');
752
+ return;
753
+ }
754
+ // For large interactive containers, let nested semantic nodes handle visible text.
755
+ if ((tag === 'A' || role === 'link' || role === 'button' || role === 'tab') && el.children.length > 0 && this.isLargeInteractiveContainer(el)) {
756
+ return;
757
+ }
698
758
  // Idempotently store original plain text once
699
759
  const plainOriginalKey = 'data-Lovalingo-original';
700
760
  if (!el.getAttribute(plainOriginalKey)) {
701
761
  el.setAttribute(plainOriginalKey, (el.textContent || '').trim());
702
762
  }
703
- const tag = el.tagName;
704
- const role = el.getAttribute('role');
705
- // For complex interactive containers (e.g. <a> wrapping a whole card), do NOT rewrite structure.
706
- // We only translate attributes and let child semantic nodes handle visible text.
707
- if ((tag === 'A' || role === 'link') && el.children.length > 0) {
708
- this.translateAttribute(el, 'title', 'title');
709
- this.translateAttribute(el, 'aria-label', 'aria-label');
710
- return;
711
- }
712
763
  // Translate text nodes for label-like elements (only safe for simple/leaf controls)
713
- if (tag === 'BUTTON' || tag === 'A' || tag === 'LABEL' || role === 'button' || role === 'link') {
764
+ if (tag === 'BUTTON' || tag === 'A' || tag === 'LABEL' || tag === 'SUMMARY' || role === 'button' || role === 'link') {
714
765
  const content = this.extractTranslatableContent(el);
715
766
  const tokenizedKeyAttr = 'data-Lovalingo-original-tokenized';
716
- const tokenized = content.processedText;
717
- if (!el.getAttribute(tokenizedKeyAttr) && tokenized) {
718
- el.setAttribute(tokenizedKeyAttr, tokenized);
767
+ const tokenized = (el.getAttribute(tokenizedKeyAttr) || content.processedText || '').trim();
768
+ if (!el.getAttribute(tokenizedKeyAttr) && content.processedText) {
769
+ el.setAttribute(tokenizedKeyAttr, content.processedText);
719
770
  }
720
771
  const plainOriginal = el.getAttribute(plainOriginalKey) || (el.textContent || '').trim();
721
772
  const source = (tokenized && this.isTranslatableText(tokenized)) ? tokenized : plainOriginal;
722
773
  if (this.isTranslatableText(source)) {
723
774
  const translated = this.translationMap.get(source);
724
775
  if (translated) {
776
+ // Ensure restoreDOM can revert translated controls.
777
+ const nearestOriginalHtml = el.closest?.('[data-Lovalingo-original-html]');
778
+ if ((!nearestOriginalHtml || nearestOriginalHtml === el) && !el.getAttribute('data-Lovalingo-original-html')) {
779
+ el.setAttribute('data-Lovalingo-original-html', el.innerHTML);
780
+ }
725
781
  const temp = document.createElement('div');
726
782
  temp.innerHTML = this.reconstructHTML(translated, content.placeholderMap);
727
783
  this.updateTextNodesOnly(el, temp);
@@ -731,21 +787,11 @@ export class Translator {
731
787
  this.missedStrings.set(source, {
732
788
  text: source,
733
789
  raw: content.rawHTML,
734
- placeholderMap: content.placeholderMap,
790
+ placeholderMap: this.sanitizePlaceholderMap(content.placeholderMap),
735
791
  semanticContext: tag.toLowerCase()
736
792
  });
737
793
  }
738
794
  }
739
- // Translate attributes
740
- this.translateAttribute(el, 'title', 'title');
741
- this.translateAttribute(el, 'aria-label', 'aria-label');
742
- return;
743
- }
744
- // Inputs/Textareas/Selects: attributes only
745
- if (tag === 'INPUT' || tag === 'TEXTAREA' || tag === 'SELECT') {
746
- this.translateAttribute(el, 'placeholder', 'placeholder');
747
- this.translateAttribute(el, 'title', 'title');
748
- this.translateAttribute(el, 'aria-label', 'aria-label');
749
795
  }
750
796
  }
751
797
  /**
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@lovalingo/lovalingo",
3
- "version": "0.0.24",
3
+ "version": "0.0.25",
4
4
  "description": "React translation library with automatic routing, real-time AI translation, and zero-flash rendering. One-line language routing setup.",
5
5
  "main": "dist/index.js",
6
6
  "types": "dist/index.d.ts",