@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.
- package/dist/components/AixsterProvider.d.ts +17 -0
- package/dist/components/AixsterProvider.js +32 -0
- package/dist/types.d.ts +1 -1
- package/dist/utils/api.d.ts +2 -0
- package/dist/utils/api.js +34 -4
- package/dist/utils/translator.d.ts +3 -0
- package/dist/utils/translator.js +259 -213
- package/package.json +1 -1
|
@@ -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-
|
|
58
|
+
type: 'inline-formatting' | 'non-translatable' | 'interactive';
|
|
59
59
|
originalHTML: string;
|
|
60
60
|
textContent: string;
|
|
61
61
|
tag: string;
|
package/dist/utils/api.d.ts
CHANGED
|
@@ -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
|
|
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
|
|
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
|
|
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
|
|
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[];
|
package/dist/utils/translator.js
CHANGED
|
@@ -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-
|
|
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
|
-
|
|
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 = `
|
|
227
|
+
const token = `__PRESERVE_${placeholderCounter}__`;
|
|
170
228
|
placeholderMap[token] = {
|
|
171
229
|
type: 'non-translatable',
|
|
172
|
-
originalHTML: element
|
|
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
|
-
//
|
|
183
|
-
|
|
184
|
-
|
|
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: '
|
|
187
|
-
originalHTML: element
|
|
188
|
-
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
|
-
|
|
198
|
-
const
|
|
199
|
-
|
|
200
|
-
|
|
201
|
-
|
|
202
|
-
|
|
203
|
-
|
|
204
|
-
//
|
|
205
|
-
|
|
206
|
-
|
|
207
|
-
|
|
208
|
-
|
|
209
|
-
|
|
210
|
-
const
|
|
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[
|
|
229
|
-
type:
|
|
230
|
-
originalHTML:
|
|
231
|
-
textContent
|
|
295
|
+
placeholderMap[openToken] = {
|
|
296
|
+
type: 'inline-formatting',
|
|
297
|
+
originalHTML: openTag,
|
|
298
|
+
textContent,
|
|
232
299
|
tag: tagName,
|
|
233
|
-
attributes
|
|
300
|
+
attributes,
|
|
234
301
|
};
|
|
235
|
-
|
|
236
|
-
|
|
237
|
-
|
|
238
|
-
|
|
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
|
-
|
|
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
|
-
|
|
482
|
-
|
|
483
|
-
|
|
484
|
-
|
|
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
|
-
|
|
502
|
-
|
|
503
|
-
|
|
504
|
-
|
|
505
|
-
|
|
506
|
-
|
|
507
|
-
|
|
508
|
-
|
|
509
|
-
|
|
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
|
-
//
|
|
531
|
-
|
|
532
|
-
|
|
533
|
-
|
|
534
|
-
|
|
535
|
-
|
|
536
|
-
|
|
537
|
-
|
|
538
|
-
|
|
539
|
-
|
|
540
|
-
|
|
541
|
-
|
|
542
|
-
|
|
543
|
-
|
|
544
|
-
|
|
545
|
-
|
|
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
|
-
//
|
|
548
|
-
|
|
549
|
-
|
|
550
|
-
|
|
551
|
-
|
|
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
|
-
|
|
555
|
-
this.
|
|
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
|
-
|
|
569
|
-
|
|
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
|
-
|
|
599
|
-
|
|
600
|
-
|
|
601
|
-
|
|
602
|
-
|
|
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
|
-
//
|
|
628
|
-
|
|
629
|
-
|
|
630
|
-
|
|
631
|
-
|
|
632
|
-
|
|
633
|
-
|
|
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
|
-
|
|
636
|
-
|
|
637
|
-
|
|
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
|
-
|
|
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
|
-
|
|
654
|
-
|
|
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) &&
|
|
718
|
-
el.setAttribute(tokenizedKeyAttr,
|
|
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.
|
|
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",
|